From 9f09414dbeb3a04fd1071cf1d2e06f05f5429bb9 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Mon, 19 Jan 2026 14:28:41 +0800 Subject: [PATCH 1/7] refactor: make url in email template more better (#31166) --- .../invite_member_mail_template_en-US.html | 25 ++++++++++++++++- .../invite_member_mail_template_zh-CN.html | 25 ++++++++++++++++- ...ail_when_account_exist_template_en-US.html | 25 ++++++++++++++++- ...ail_when_account_exist_template_zh-CN.html | 25 ++++++++++++++++- .../invite_member_mail_template_en-US.html | 28 +++++++++++++++++-- .../invite_member_mail_template_zh-CN.html | 25 ++++++++++++++++- ...ail_when_account_exist_template_en-US.html | 25 ++++++++++++++++- ...ail_when_account_exist_template_zh-CN.html | 25 ++++++++++++++++- 8 files changed, 193 insertions(+), 10 deletions(-) diff --git a/api/templates/invite_member_mail_template_en-US.html b/api/templates/invite_member_mail_template_en-US.html index a07c5f4b16..7b296519f0 100644 --- a/api/templates/invite_member_mail_template_en-US.html +++ b/api/templates/invite_member_mail_template_en-US.html @@ -83,7 +83,30 @@

Dear {{ to }},

{{ inviter_name }} is pleased to invite you to join our workspace on Dify, a platform specifically designed for LLM application development. On Dify, you can explore, create, and collaborate to build and operate AI applications.

Click the button below to log in to Dify and join the workspace.

-

Login Here

+
+ Login Here +

+ If the button doesn't work, copy and paste this link into your browser:
+ + {{ url }} + +

+

Best regards,

Dify Team

diff --git a/api/templates/invite_member_mail_template_zh-CN.html b/api/templates/invite_member_mail_template_zh-CN.html index 27709a3c6d..c05b3ddb67 100644 --- a/api/templates/invite_member_mail_template_zh-CN.html +++ b/api/templates/invite_member_mail_template_zh-CN.html @@ -83,7 +83,30 @@

尊敬的 {{ to }},

{{ inviter_name }} 现邀请您加入我们在 Dify 的工作区,这是一个专为 LLM 应用开发而设计的平台。在 Dify 上,您可以探索、创造和合作,构建和运营 AI 应用。

点击下方按钮即可登录 Dify 并且加入空间。

-

在此登录

+
+ 在此登录 +

+ 如果按钮无法使用,请将以下链接复制到浏览器打开:
+ + {{ url }} + +

+

此致,

Dify 团队

diff --git a/api/templates/register_email_when_account_exist_template_en-US.html b/api/templates/register_email_when_account_exist_template_en-US.html index ac5042c274..e2bb99c989 100644 --- a/api/templates/register_email_when_account_exist_template_en-US.html +++ b/api/templates/register_email_when_account_exist_template_en-US.html @@ -115,7 +115,30 @@ We noticed you tried to sign up, but this email is already registered with an existing account. Please log in here:

- Log In +
+ Log In +

+ If the button doesn't work, copy and paste this link into your browser:
+ + {{ login_url }} + +

+

If you forgot your password, you can reset it here: Reset Password diff --git a/api/templates/register_email_when_account_exist_template_zh-CN.html b/api/templates/register_email_when_account_exist_template_zh-CN.html index 326b58343a..6a5bbd135b 100644 --- a/api/templates/register_email_when_account_exist_template_zh-CN.html +++ b/api/templates/register_email_when_account_exist_template_zh-CN.html @@ -115,7 +115,30 @@ 我们注意到您尝试注册,但此电子邮件已注册。 请在此登录:

- 登录 +
+ 登录 +

+ 如果按钮无法使用,请将以下链接复制到浏览器打开:
+ + {{ login_url }} + +

+

如果您忘记了密码,可以在此重置: 重置密码

diff --git a/api/templates/without-brand/invite_member_mail_template_en-US.html b/api/templates/without-brand/invite_member_mail_template_en-US.html index f9157284fa..687ece617a 100644 --- a/api/templates/without-brand/invite_member_mail_template_en-US.html +++ b/api/templates/without-brand/invite_member_mail_template_en-US.html @@ -92,12 +92,34 @@ platform specifically designed for LLM application development. On {{application_title}}, you can explore, create, and collaborate to build and operate AI applications.

Click the button below to log in to {{application_title}} and join the workspace.

-

Login Here

+
+ Login Here +

+ If the button doesn't work, copy and paste this link into your browser:
+ + {{ url }} + +

+

Best regards,

{{application_title}} Team

- \ No newline at end of file + diff --git a/api/templates/without-brand/invite_member_mail_template_zh-CN.html b/api/templates/without-brand/invite_member_mail_template_zh-CN.html index e787c90914..9ca1ef62cd 100644 --- a/api/templates/without-brand/invite_member_mail_template_zh-CN.html +++ b/api/templates/without-brand/invite_member_mail_template_zh-CN.html @@ -81,7 +81,30 @@

尊敬的 {{ to }},

{{ inviter_name }} 现邀请您加入我们在 {{application_title}} 的工作区,这是一个专为 LLM 应用开发而设计的平台。在 {{application_title}} 上,您可以探索、创造和合作,构建和运营 AI 应用。

点击下方按钮即可登录 {{application_title}} 并且加入空间。

-

在此登录

+
+ 在此登录 +

+ 如果按钮无法使用,请将以下链接复制到浏览器打开:
+ + {{ url }} + +

+

此致,

{{application_title}} 团队

diff --git a/api/templates/without-brand/register_email_when_account_exist_template_en-US.html b/api/templates/without-brand/register_email_when_account_exist_template_en-US.html index 2e74956e14..2a26aac5b9 100644 --- a/api/templates/without-brand/register_email_when_account_exist_template_en-US.html +++ b/api/templates/without-brand/register_email_when_account_exist_template_en-US.html @@ -111,7 +111,30 @@ We noticed you tried to sign up, but this email is already registered with an existing account. Please log in here:

- Log In +
+ Log In +

+ If the button doesn't work, copy and paste this link into your browser:
+ + {{ login_url }} + +

+

If you forgot your password, you can reset it here: Reset Password diff --git a/api/templates/without-brand/register_email_when_account_exist_template_zh-CN.html b/api/templates/without-brand/register_email_when_account_exist_template_zh-CN.html index a315f9154d..b9f93eb0fc 100644 --- a/api/templates/without-brand/register_email_when_account_exist_template_zh-CN.html +++ b/api/templates/without-brand/register_email_when_account_exist_template_zh-CN.html @@ -111,7 +111,30 @@ 我们注意到您尝试注册,但此电子邮件已注册。 请在此登录:

- 登录 +
+ 登录 +

+ 如果按钮无法使用,请将以下链接复制到浏览器打开:
+ + {{ login_url }} + +

+

如果您忘记了密码,可以在此重置: 重置密码

From 92dbc94f2f3d99e9c24b8389520ee1443a326c4f Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Mon, 19 Jan 2026 14:40:32 +0800 Subject: [PATCH 2/7] test: add unit tests for plugin detail panel components including action lists, strategy lists, and endpoint management (#31053) Co-authored-by: CodingOnStar --- .../index.spec.tsx | 101 ++ .../retrieval-method-info/index.spec.tsx | 148 +++ .../detail/embedding/skeleton/index.spec.tsx | 46 + .../list/dataset-footer/index.spec.tsx | 30 + .../list/new-dataset-card/index.spec.tsx | 49 + .../settings/chunk-structure/index.spec.tsx | 85 ++ .../plugin-detail-panel/action-list.spec.tsx | 130 ++ .../agent-strategy-list.spec.tsx | 131 ++ .../datasource-action-list.spec.tsx | 104 ++ .../detail-header.spec.tsx | 1002 +++++++++++++++ .../endpoint-card.spec.tsx | 386 ++++++ .../endpoint-list.spec.tsx | 222 ++++ .../endpoint-modal.spec.tsx | 519 ++++++++ .../plugin-detail-panel/index.spec.tsx | 1144 +++++++++++++++++ .../plugin-detail-panel/model-list.spec.tsx | 103 ++ .../operation-dropdown.spec.tsx | 215 ++++ .../plugins/plugin-detail-panel/store.spec.ts | 461 +++++++ .../strategy-detail.spec.tsx | 203 +++ .../strategy-item.spec.tsx | 102 ++ .../create/common-modal.spec.tsx | 183 +++ .../subscription-list/create/index.spec.tsx | 209 +++ .../create/oauth-client.spec.tsx | 56 + .../trigger/event-detail-drawer.spec.tsx | 287 +++++ .../trigger/event-list.spec.tsx | 146 +++ .../plugins/plugin-detail-panel/utils.spec.ts | 72 ++ 25 files changed, 6134 insertions(+) create mode 100644 web/app/components/datasets/common/economical-retrieval-method-config/index.spec.tsx create mode 100644 web/app/components/datasets/common/retrieval-method-info/index.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/embedding/skeleton/index.spec.tsx create mode 100644 web/app/components/datasets/list/dataset-footer/index.spec.tsx create mode 100644 web/app/components/datasets/list/new-dataset-card/index.spec.tsx create mode 100644 web/app/components/datasets/settings/chunk-structure/index.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/action-list.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/agent-strategy-list.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/datasource-action-list.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/detail-header.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/endpoint-card.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/endpoint-list.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/endpoint-modal.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/index.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/model-list.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/operation-dropdown.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/store.spec.ts create mode 100644 web/app/components/plugins/plugin-detail-panel/strategy-detail.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/strategy-item.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/trigger/event-list.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/utils.spec.ts diff --git a/web/app/components/datasets/common/economical-retrieval-method-config/index.spec.tsx b/web/app/components/datasets/common/economical-retrieval-method-config/index.spec.tsx new file mode 100644 index 0000000000..cd6b050336 --- /dev/null +++ b/web/app/components/datasets/common/economical-retrieval-method-config/index.spec.tsx @@ -0,0 +1,101 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { RETRIEVE_METHOD } from '@/types/app' +import EconomicalRetrievalMethodConfig from './index' + +// Mock dependencies +vi.mock('../../settings/option-card', () => ({ + default: ({ children, title, description, disabled, id }: { + children?: React.ReactNode + title?: string + description?: React.ReactNode + disabled?: boolean + id?: string + }) => ( +
+
{description}
+ {children} +
+ ), +})) + +vi.mock('../retrieval-param-config', () => ({ + default: ({ value, onChange, type }: { + value: Record + onChange: (value: Record) => void + type?: string + }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/base/icons/src/vender/knowledge', () => ({ + VectorSearch: () => , +})) + +describe('EconomicalRetrievalMethodConfig', () => { + const mockOnChange = vi.fn() + const defaultProps = { + value: { + search_method: RETRIEVE_METHOD.keywordSearch, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 2, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + onChange: mockOnChange, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render correctly', () => { + render() + + expect(screen.getByTestId('option-card')).toBeInTheDocument() + expect(screen.getByTestId('retrieval-param-config')).toBeInTheDocument() + // Check if title and description are rendered (mocked i18n returns key) + expect(screen.getByText('dataset.retrieval.keyword_search.description')).toBeInTheDocument() + }) + + it('should pass correct props to OptionCard', () => { + render() + + const card = screen.getByTestId('option-card') + expect(card).toHaveAttribute('data-disabled', 'true') + expect(card).toHaveAttribute('data-id', RETRIEVE_METHOD.keywordSearch) + }) + + it('should pass correct props to RetrievalParamConfig', () => { + render() + + const config = screen.getByTestId('retrieval-param-config') + expect(config).toHaveAttribute('data-type', RETRIEVE_METHOD.keywordSearch) + }) + + it('should handle onChange events', () => { + render() + + fireEvent.click(screen.getByText('Change Value')) + + expect(mockOnChange).toHaveBeenCalledTimes(1) + expect(mockOnChange).toHaveBeenCalledWith({ + ...defaultProps.value, + newProp: 'changed', + }) + }) + + it('should default disabled prop to false', () => { + render() + const card = screen.getByTestId('option-card') + expect(card).toHaveAttribute('data-disabled', 'false') + }) +}) diff --git a/web/app/components/datasets/common/retrieval-method-info/index.spec.tsx b/web/app/components/datasets/common/retrieval-method-info/index.spec.tsx new file mode 100644 index 0000000000..05750711dc --- /dev/null +++ b/web/app/components/datasets/common/retrieval-method-info/index.spec.tsx @@ -0,0 +1,148 @@ +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import { RETRIEVE_METHOD } from '@/types/app' +import { retrievalIcon } from '../../create/icons' +import RetrievalMethodInfo, { getIcon } from './index' + +// Mock next/image +vi.mock('next/image', () => ({ + default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => ( + {alt + ), +})) + +// Mock RadioCard +vi.mock('@/app/components/base/radio-card', () => ({ + default: ({ title, description, chosenConfig, icon }: { title: string, description: string, chosenConfig: ReactNode, icon: ReactNode }) => ( +
+
{title}
+
{description}
+
{icon}
+
{chosenConfig}
+
+ ), +})) + +// Mock icons +vi.mock('../../create/icons', () => ({ + retrievalIcon: { + vector: 'vector-icon.png', + fullText: 'fulltext-icon.png', + hybrid: 'hybrid-icon.png', + }, +})) + +describe('RetrievalMethodInfo', () => { + const defaultConfig = { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: 'test-provider', + reranking_model_name: 'test-model', + }, + top_k: 5, + score_threshold_enabled: true, + score_threshold: 0.8, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render correctly with full config', () => { + render() + + expect(screen.getByTestId('radio-card')).toBeInTheDocument() + + // Check Title & Description (mocked i18n returns key prefixed with ns) + expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.semantic_search.title') + expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description') + + // Check Icon + const icon = screen.getByTestId('method-icon') + expect(icon).toHaveAttribute('src', 'vector-icon.png') + + // Check Config Details + expect(screen.getByText('test-model')).toBeInTheDocument() // Rerank model + expect(screen.getByText('5')).toBeInTheDocument() // Top K + expect(screen.getByText('0.8')).toBeInTheDocument() // Score threshold + }) + + it('should not render reranking model if missing', () => { + const configWithoutRerank = { + ...defaultConfig, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + } + + render() + + expect(screen.queryByText('test-model')).not.toBeInTheDocument() + // Other fields should still be there + expect(screen.getByText('5')).toBeInTheDocument() + }) + + it('should handle different retrieval methods', () => { + // Test Hybrid + const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid } + const { unmount } = render() + + expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.hybrid_search.title') + expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'hybrid-icon.png') + + unmount() + + // Test FullText + const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText } + render() + expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.full_text_search.title') + expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'fulltext-icon.png') + }) + + describe('getIcon utility', () => { + it('should return correct icon for each type', () => { + expect(getIcon(RETRIEVE_METHOD.semantic)).toBe(retrievalIcon.vector) + expect(getIcon(RETRIEVE_METHOD.fullText)).toBe(retrievalIcon.fullText) + expect(getIcon(RETRIEVE_METHOD.hybrid)).toBe(retrievalIcon.hybrid) + expect(getIcon(RETRIEVE_METHOD.invertedIndex)).toBe(retrievalIcon.vector) + expect(getIcon(RETRIEVE_METHOD.keywordSearch)).toBe(retrievalIcon.vector) + }) + + it('should return default vector icon for unknown type', () => { + // Test fallback branch when type is not in the mapping + const unknownType = 'unknown_method' as RETRIEVE_METHOD + expect(getIcon(unknownType)).toBe(retrievalIcon.vector) + }) + }) + + it('should not render score threshold if disabled', () => { + const configWithoutScoreThreshold = { + ...defaultConfig, + score_threshold_enabled: false, + score_threshold: 0, + } + + render() + + // score_threshold is still rendered but may be undefined + expect(screen.queryByText('0.8')).not.toBeInTheDocument() + }) + + it('should render correctly with invertedIndex search method', () => { + const invertedIndexConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.invertedIndex } + render() + + // invertedIndex uses vector icon + expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png') + }) + + it('should render correctly with keywordSearch search method', () => { + const keywordSearchConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.keywordSearch } + render() + + // keywordSearch uses vector icon + expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png') + }) +}) diff --git a/web/app/components/datasets/documents/detail/embedding/skeleton/index.spec.tsx b/web/app/components/datasets/documents/detail/embedding/skeleton/index.spec.tsx new file mode 100644 index 0000000000..e0dc60b668 --- /dev/null +++ b/web/app/components/datasets/documents/detail/embedding/skeleton/index.spec.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react' +import EmbeddingSkeleton from './index' + +// Mock Skeleton components +vi.mock('@/app/components/base/skeleton', () => ({ + SkeletonContainer: ({ children }: { children?: React.ReactNode }) =>
{children}
, + SkeletonPoint: () =>
, + SkeletonRectangle: () =>
, + SkeletonRow: ({ children }: { children?: React.ReactNode }) =>
{children}
, +})) + +// Mock Divider +vi.mock('@/app/components/base/divider', () => ({ + default: () =>
, +})) + +describe('EmbeddingSkeleton', () => { + it('should render correct number of skeletons', () => { + render() + + // It renders 5 CardSkeletons. Each CardSkelton has multiple SkeletonContainers. + // Let's count the number of main wrapper divs (loop is 5) + + // Each iteration renders a CardSkeleton and potentially a Divider. + // The component structure is: + // div.relative... + // div.absolute... (mask) + // map(5) -> div.w-full.px-11 -> CardSkelton + Divider (except last?) + + // Actually the code says `index !== 9`, but the loop is length 5. + // So `index` goes 0..4. All are !== 9. So 5 dividers should be rendered. + + expect(screen.getAllByTestId('divider')).toHaveLength(5) + + // Just ensure it renders without crashing and contains skeleton elements + expect(screen.getAllByTestId('skeleton-container').length).toBeGreaterThan(0) + expect(screen.getAllByTestId('skeleton-rectangle').length).toBeGreaterThan(0) + }) + + it('should render the mask overlay', () => { + const { container } = render() + // Check for the absolute positioned mask + const mask = container.querySelector('.bg-dataset-chunk-list-mask-bg') + expect(mask).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/list/dataset-footer/index.spec.tsx b/web/app/components/datasets/list/dataset-footer/index.spec.tsx new file mode 100644 index 0000000000..b59990c682 --- /dev/null +++ b/web/app/components/datasets/list/dataset-footer/index.spec.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react' +import DatasetFooter from './index' + +describe('DatasetFooter', () => { + it('should render correctly', () => { + render() + + // Check main title (mocked i18n returns ns:key or key) + // The code uses t('didYouKnow', { ns: 'dataset' }) + // With default mock it likely returns 'dataset.didYouKnow' + expect(screen.getByText('dataset.didYouKnow')).toBeInTheDocument() + + // Check paragraph content + expect(screen.getByText(/dataset.intro1/)).toBeInTheDocument() + expect(screen.getByText(/dataset.intro2/)).toBeInTheDocument() + expect(screen.getByText(/dataset.intro3/)).toBeInTheDocument() + expect(screen.getByText(/dataset.intro4/)).toBeInTheDocument() + expect(screen.getByText(/dataset.intro5/)).toBeInTheDocument() + expect(screen.getByText(/dataset.intro6/)).toBeInTheDocument() + }) + + it('should have correct styling', () => { + const { container } = render() + const footer = container.querySelector('footer') + expect(footer).toHaveClass('shrink-0', 'px-12', 'py-6') + + const h3 = container.querySelector('h3') + expect(h3).toHaveClass('text-gradient') + }) +}) diff --git a/web/app/components/datasets/list/new-dataset-card/index.spec.tsx b/web/app/components/datasets/list/new-dataset-card/index.spec.tsx new file mode 100644 index 0000000000..b361beb9f1 --- /dev/null +++ b/web/app/components/datasets/list/new-dataset-card/index.spec.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react' +import NewDatasetCard from './index' + +type MockOptionProps = { + text: string + href: string +} + +// Mock dependencies +vi.mock('./option', () => ({ + default: ({ text, href }: MockOptionProps) => ( + + {text} + + ), +})) + +vi.mock('@remixicon/react', () => ({ + RiAddLine: () => , + RiFunctionAddLine: () => , +})) + +vi.mock('@/app/components/base/icons/src/vender/solid/development', () => ({ + ApiConnectionMod: () => , +})) + +describe('NewDatasetCard', () => { + it('should render all options', () => { + render() + + const options = screen.getAllByTestId('option-link') + expect(options).toHaveLength(3) + + // Check first option (Create Dataset) + const createDataset = options[0] + expect(createDataset).toHaveAttribute('href', '/datasets/create') + expect(createDataset).toHaveTextContent('dataset.createDataset') + + // Check second option (Create from Pipeline) + const createFromPipeline = options[1] + expect(createFromPipeline).toHaveAttribute('href', '/datasets/create-from-pipeline') + expect(createFromPipeline).toHaveTextContent('dataset.createFromPipeline') + + // Check third option (Connect Dataset) + const connectDataset = options[2] + expect(connectDataset).toHaveAttribute('href', '/datasets/connect') + expect(connectDataset).toHaveTextContent('dataset.connectDataset') + }) +}) diff --git a/web/app/components/datasets/settings/chunk-structure/index.spec.tsx b/web/app/components/datasets/settings/chunk-structure/index.spec.tsx new file mode 100644 index 0000000000..878018408d --- /dev/null +++ b/web/app/components/datasets/settings/chunk-structure/index.spec.tsx @@ -0,0 +1,85 @@ +import { render, screen } from '@testing-library/react' +import { ChunkingMode } from '@/models/datasets' +import ChunkStructure from './index' + +type MockOptionCardProps = { + id: string + title: string + isActive?: boolean + disabled?: boolean +} + +// Mock dependencies +vi.mock('../option-card', () => ({ + default: ({ id, title, isActive, disabled }: MockOptionCardProps) => ( +
+ {title} +
+ ), +})) + +// Mock hook +vi.mock('./hooks', () => ({ + useChunkStructure: () => ({ + options: [ + { + id: ChunkingMode.text, + title: 'General', + description: 'General description', + icon: , + effectColor: 'indigo', + iconActiveColor: 'indigo', + }, + { + id: ChunkingMode.parentChild, + title: 'Parent-Child', + description: 'PC description', + icon: , + effectColor: 'blue', + iconActiveColor: 'blue', + }, + ], + }), +})) + +describe('ChunkStructure', () => { + it('should render all options', () => { + render() + + const options = screen.getAllByTestId('option-card') + expect(options).toHaveLength(2) + expect(options[0]).toHaveTextContent('General') + expect(options[1]).toHaveTextContent('Parent-Child') + }) + + it('should set active state correctly', () => { + // Render with 'text' active + const { unmount } = render() + + const options = screen.getAllByTestId('option-card') + expect(options[0]).toHaveAttribute('data-active', 'true') + expect(options[1]).toHaveAttribute('data-active', 'false') + + unmount() + + // Render with 'parentChild' active + render() + const newOptions = screen.getAllByTestId('option-card') + expect(newOptions[0]).toHaveAttribute('data-active', 'false') + expect(newOptions[1]).toHaveAttribute('data-active', 'true') + }) + + it('should be always disabled', () => { + render() + + const options = screen.getAllByTestId('option-card') + options.forEach((option) => { + expect(option).toHaveAttribute('data-disabled', 'true') + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/action-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/action-list.spec.tsx new file mode 100644 index 0000000000..14ed18eb9a --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/action-list.spec.tsx @@ -0,0 +1,130 @@ +import type { PluginDetail } from '@/app/components/plugins/types' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ActionList from './action-list' + +// Mock dependencies +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (options?.num !== undefined) + return `${options.num} ${options.action || 'actions'}` + return key + }, + }), +})) + +const mockToolData = [ + { name: 'tool-1', label: { en_US: 'Tool 1' } }, + { name: 'tool-2', label: { en_US: 'Tool 2' } }, +] + +const mockProvider = { + name: 'test-plugin/test-tool', + type: 'builtin', +} + +vi.mock('@/service/use-tools', () => ({ + useAllToolProviders: () => ({ data: [mockProvider] }), + useBuiltinTools: (key: string) => ({ + data: key ? mockToolData : undefined, + }), +})) + +vi.mock('@/app/components/tools/provider/tool-item', () => ({ + default: ({ tool }: { tool: { name: string } }) => ( +
{tool.name}
+ ), +})) + +const createPluginDetail = (overrides: Partial = {}): PluginDetail => ({ + id: 'test-id', + created_at: '2024-01-01', + updated_at: '2024-01-02', + name: 'Test Plugin', + plugin_id: 'test-plugin', + plugin_unique_identifier: 'test-uid', + declaration: { + tool: { + identity: { + author: 'test-author', + name: 'test-tool', + description: { en_US: 'Test' }, + icon: 'icon.png', + label: { en_US: 'Test Tool' }, + tags: [], + }, + credentials_schema: [], + }, + } as unknown as PluginDetail['declaration'], + installation_id: 'install-1', + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '1.0.0', + latest_unique_identifier: 'test-uid', + source: 'marketplace' as PluginDetail['source'], + meta: undefined, + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', + ...overrides, +}) + +describe('ActionList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render tool items when data is available', () => { + const detail = createPluginDetail() + render() + + expect(screen.getByText('2 actions')).toBeInTheDocument() + expect(screen.getAllByTestId('tool-item')).toHaveLength(2) + }) + + it('should render tool names', () => { + const detail = createPluginDetail() + render() + + expect(screen.getByText('tool-1')).toBeInTheDocument() + expect(screen.getByText('tool-2')).toBeInTheDocument() + }) + + it('should return null when no tool declaration', () => { + const detail = createPluginDetail({ + declaration: {} as PluginDetail['declaration'], + }) + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + + it('should return null when providerKey is empty', () => { + const detail = createPluginDetail({ + declaration: { + tool: { + identity: undefined, + }, + } as unknown as PluginDetail['declaration'], + }) + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + }) + + describe('Props', () => { + it('should use plugin_id in provider key construction', () => { + const detail = createPluginDetail() + render() + + // The provider key is constructed from plugin_id and tool identity name + // When they match the mock, it renders + expect(screen.getByText('2 actions')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/agent-strategy-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/agent-strategy-list.spec.tsx new file mode 100644 index 0000000000..b9b737c51b --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/agent-strategy-list.spec.tsx @@ -0,0 +1,131 @@ +import type { PluginDetail, StrategyDetail } from '@/app/components/plugins/types' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AgentStrategyList from './agent-strategy-list' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (options?.num !== undefined) + return `${options.num} ${options.strategy || 'strategies'}` + return key + }, + }), +})) + +const mockStrategies = [ + { + identity: { + author: 'author-1', + name: 'strategy-1', + icon: 'icon.png', + label: { en_US: 'Strategy 1' }, + provider: 'provider-1', + }, + parameters: [], + description: { en_US: 'Strategy 1 desc' }, + output_schema: {}, + features: [], + }, +] as unknown as StrategyDetail[] + +let mockStrategyProviderDetail: { declaration: { identity: unknown, strategies: StrategyDetail[] } } | undefined + +vi.mock('@/service/use-strategy', () => ({ + useStrategyProviderDetail: () => ({ + data: mockStrategyProviderDetail, + }), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/strategy-item', () => ({ + default: ({ detail }: { detail: StrategyDetail }) => ( +
{detail.identity.name}
+ ), +})) + +const createPluginDetail = (): PluginDetail => ({ + id: 'test-id', + created_at: '2024-01-01', + updated_at: '2024-01-02', + name: 'Test Plugin', + plugin_id: 'test-plugin', + plugin_unique_identifier: 'test-uid', + declaration: { + agent_strategy: { + identity: { + author: 'test-author', + name: 'test-strategy', + label: { en_US: 'Test Strategy' }, + description: { en_US: 'Test' }, + icon: 'icon.png', + tags: [], + }, + }, + } as PluginDetail['declaration'], + installation_id: 'install-1', + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '1.0.0', + latest_unique_identifier: 'test-uid', + source: 'marketplace' as PluginDetail['source'], + meta: undefined, + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', +}) + +describe('AgentStrategyList', () => { + beforeEach(() => { + vi.clearAllMocks() + mockStrategyProviderDetail = { + declaration: { + identity: { author: 'test', name: 'test' }, + strategies: mockStrategies, + }, + } + }) + + describe('Rendering', () => { + it('should render strategy items when data is available', () => { + render() + + expect(screen.getByText('1 strategy')).toBeInTheDocument() + expect(screen.getByTestId('strategy-item')).toBeInTheDocument() + }) + + it('should return null when no strategy provider detail', () => { + mockStrategyProviderDetail = undefined + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + + it('should render multiple strategies', () => { + mockStrategyProviderDetail = { + declaration: { + identity: { author: 'test', name: 'test' }, + strategies: [ + ...mockStrategies, + { ...mockStrategies[0], identity: { ...mockStrategies[0].identity, name: 'strategy-2' } }, + ], + }, + } + render() + + expect(screen.getByText('2 strategies')).toBeInTheDocument() + expect(screen.getAllByTestId('strategy-item')).toHaveLength(2) + }) + }) + + describe('Props', () => { + it('should pass tenant_id to provider detail', () => { + const detail = createPluginDetail() + detail.tenant_id = 'custom-tenant' + render() + + expect(screen.getByTestId('strategy-item')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/datasource-action-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/datasource-action-list.spec.tsx new file mode 100644 index 0000000000..e315bbf62b --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/datasource-action-list.spec.tsx @@ -0,0 +1,104 @@ +import type { PluginDetail } from '@/app/components/plugins/types' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import DatasourceActionList from './datasource-action-list' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (options?.num !== undefined) + return `${options.num} ${options.action || 'actions'}` + return key + }, + }), +})) + +const mockDataSourceList = [ + { plugin_id: 'test-plugin', name: 'Data Source 1' }, +] + +let mockDataSourceListData: typeof mockDataSourceList | undefined + +vi.mock('@/service/use-pipeline', () => ({ + useDataSourceList: () => ({ data: mockDataSourceListData }), +})) + +vi.mock('@/app/components/workflow/block-selector/utils', () => ({ + transformDataSourceToTool: (ds: unknown) => ds, +})) + +const createPluginDetail = (): PluginDetail => ({ + id: 'test-id', + created_at: '2024-01-01', + updated_at: '2024-01-02', + name: 'Test Plugin', + plugin_id: 'test-plugin', + plugin_unique_identifier: 'test-uid', + declaration: { + datasource: { + identity: { + author: 'test-author', + name: 'test-datasource', + description: { en_US: 'Test' }, + icon: 'icon.png', + label: { en_US: 'Test Datasource' }, + tags: [], + }, + credentials_schema: [], + }, + } as unknown as PluginDetail['declaration'], + installation_id: 'install-1', + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '1.0.0', + latest_unique_identifier: 'test-uid', + source: 'marketplace' as PluginDetail['source'], + meta: undefined, + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', +}) + +describe('DatasourceActionList', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDataSourceListData = mockDataSourceList + }) + + describe('Rendering', () => { + it('should render action count when data and provider exist', () => { + render() + + // The component always shows "0 action" because data is hardcoded as empty array + expect(screen.getByText('0 action')).toBeInTheDocument() + }) + + it('should return null when no provider found', () => { + mockDataSourceListData = [] + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + + it('should return null when dataSourceList is undefined', () => { + mockDataSourceListData = undefined + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + }) + + describe('Props', () => { + it('should use plugin_id to find matching datasource', () => { + const detail = createPluginDetail() + detail.plugin_id = 'different-plugin' + mockDataSourceListData = [{ plugin_id: 'different-plugin', name: 'Different DS' }] + + render() + + expect(screen.getByText('0 action')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header.spec.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header.spec.tsx new file mode 100644 index 0000000000..49c3ef1058 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/detail-header.spec.tsx @@ -0,0 +1,1002 @@ +import type { PluginDetail } from '../types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as amplitude from '@/app/components/base/amplitude' +import Toast from '@/app/components/base/toast' +import { PluginSource } from '../types' +import DetailHeader from './detail-header' + +// Use vi.hoisted for mock functions used in vi.mock factories +const { + mockSetShowUpdatePluginModal, + mockRefreshModelProviders, + mockInvalidateAllToolProviders, + mockUninstallPlugin, + mockFetchReleases, + mockCheckForUpdates, +} = vi.hoisted(() => { + return { + mockSetShowUpdatePluginModal: vi.fn(), + mockRefreshModelProviders: vi.fn(), + mockInvalidateAllToolProviders: vi.fn(), + mockUninstallPlugin: vi.fn(() => Promise.resolve({ success: true })), + mockFetchReleases: vi.fn(() => Promise.resolve([{ tag_name: 'v2.0.0' }])), + mockCheckForUpdates: vi.fn(() => ({ needUpdate: true, toastProps: { type: 'success', message: 'Update available' } })), + } +}) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('ahooks', async () => { + const React = await import('react') + return { + useBoolean: (initial: boolean) => { + const [value, setValue] = React.useState(initial) + return [ + value, + { + setTrue: () => setValue(true), + setFalse: () => setValue(false), + }, + ] + }, + } +}) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + userProfile: { timezone: 'UTC' }, + }), +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'light' }), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en_US', + useLocale: () => 'en-US', +})) + +// Global mock state for enable_marketplace +let mockEnableMarketplace = true + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => + selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace } }), +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowUpdatePluginModal: mockSetShowUpdatePluginModal, + }), +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + refreshModelProviders: mockRefreshModelProviders, + }), +})) + +vi.mock('@/service/plugins', () => ({ + uninstallPlugin: mockUninstallPlugin, +})) + +vi.mock('@/service/use-tools', () => ({ + useAllToolProviders: () => ({ data: [] }), + useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders, +})) + +vi.mock('../install-plugin/hooks', () => ({ + useGitHubReleases: () => ({ + checkForUpdates: mockCheckForUpdates, + fetchReleases: mockFetchReleases, + }), +})) + +// Auto upgrade settings mock +let mockAutoUpgradeInfo: { + strategy_setting: string + upgrade_mode: string + include_plugins: string[] + exclude_plugins: string[] + upgrade_time_of_day: number +} | null = null + +vi.mock('../plugin-page/use-reference-setting', () => ({ + default: () => ({ + referenceSetting: mockAutoUpgradeInfo ? { auto_upgrade: mockAutoUpgradeInfo } : null, + }), +})) + +vi.mock('../reference-setting-modal/auto-update-setting/types', () => ({ + AUTO_UPDATE_MODE: { + update_all: 'update_all', + partial: 'partial', + exclude: 'exclude', + }, +})) + +vi.mock('../reference-setting-modal/auto-update-setting/utils', () => ({ + convertUTCDaySecondsToLocalSeconds: (seconds: number) => seconds, + timeOfDayToDayjs: () => ({ format: () => '10:00 AM' }), +})) + +vi.mock('@/hooks/use-i18n', () => ({ + useRenderI18nObject: () => (obj: Record) => obj?.en_US || '', +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '), +})) + +vi.mock('@/utils/var', () => ({ + getMarketplaceUrl: (path: string) => `https://marketplace.example.com${path}`, +})) + +vi.mock('../card/base/card-icon', () => ({ + default: ({ src }: { src: string }) =>
, +})) + +vi.mock('../card/base/description', () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})) + +vi.mock('../card/base/org-info', () => ({ + default: ({ orgName }: { orgName: string }) =>
{orgName}
, +})) + +vi.mock('../card/base/title', () => ({ + default: ({ title }: { title: string }) =>
{title}
, +})) + +vi.mock('../base/badges/verified', () => ({ + default: () => , +})) + +vi.mock('../base/deprecation-notice', () => ({ + default: () =>
, +})) + +// Enhanced operation-dropdown mock +vi.mock('./operation-dropdown', () => ({ + default: ({ onInfo, onCheckVersion, onRemove }: { onInfo: () => void, onCheckVersion: () => void, onRemove: () => void }) => ( +
+ + + +
+ ), +})) + +// Enhanced update modal mock +vi.mock('../update-plugin/from-market-place', () => ({ + default: ({ onSave, onCancel }: { onSave: () => void, onCancel: () => void }) => { + return ( +
+ + +
+ ) + }, +})) + +// Enhanced version picker mock +vi.mock('../update-plugin/plugin-version-picker', () => ({ + default: ({ trigger, onSelect, onShowChange }: { trigger: React.ReactNode, onSelect: (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => void, onShowChange: (show: boolean) => void }) => ( +
+ {trigger} + + +
+ ), +})) + +vi.mock('../plugin-page/plugin-info', () => ({ + default: ({ onHide }: { onHide: () => void }) => ( +
+ +
+ ), +})) + +vi.mock('../plugin-auth', () => ({ + AuthCategory: { tool: 'tool' }, + PluginAuth: () =>
, +})) + +// Mock Confirm component +vi.mock('@/app/components/base/confirm', () => ({ + default: ({ isShow, onCancel, onConfirm, isLoading }: { + isShow: boolean + onCancel: () => void + onConfirm: () => void + isLoading: boolean + }) => isShow + ? ( +
+ + +
+ ) + : null, +})) + +const createPluginDetail = (overrides: Partial = {}): PluginDetail => ({ + id: 'test-id', + created_at: '2024-01-01', + updated_at: '2024-01-02', + name: 'Test Plugin', + plugin_id: 'test-plugin', + plugin_unique_identifier: 'test-uid', + declaration: { + author: 'test-author', + name: 'test-plugin-name', + category: 'tool', + label: { en_US: 'Test Plugin Label' }, + description: { en_US: 'Test description' }, + icon: 'icon.png', + verified: true, + tool: { + identity: { + name: 'test-tool', + author: 'author', + description: { en_US: 'Tool desc' }, + icon: 'icon.png', + label: { en_US: 'Tool' }, + tags: [], + }, + credentials_schema: [], + }, + } as unknown as PluginDetail['declaration'], + installation_id: 'install-1', + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '1.0.0', + latest_unique_identifier: 'test-uid', + source: PluginSource.marketplace, + meta: undefined, + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', + ...overrides, +}) + +describe('DetailHeader', () => { + const mockOnUpdate = vi.fn() + const mockOnHide = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockAutoUpgradeInfo = null + mockEnableMarketplace = true + vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) + vi.spyOn(amplitude, 'trackEvent').mockImplementation(() => {}) + }) + + describe('Rendering', () => { + it('should render plugin title', () => { + render() + + expect(screen.getByTestId('title')).toBeInTheDocument() + }) + + it('should render plugin icon with correct src', () => { + render() + + expect(screen.getByTestId('card-icon')).toBeInTheDocument() + }) + + it('should render icon with http url directly', () => { + const detail = createPluginDetail({ + declaration: { + ...createPluginDetail().declaration, + icon: 'https://example.com/icon.png', + } as unknown as PluginDetail['declaration'], + }) + render() + + expect(screen.getByTestId('card-icon')).toHaveAttribute('data-src', 'https://example.com/icon.png') + }) + + it('should render description when not in readme view', () => { + render() + + expect(screen.getByTestId('description')).toBeInTheDocument() + }) + + it('should not render description in readme view', () => { + render() + + expect(screen.queryByTestId('description')).not.toBeInTheDocument() + }) + + it('should render verified badge when verified', () => { + render() + + expect(screen.getByTestId('verified-badge')).toBeInTheDocument() + }) + }) + + describe('Version Display', () => { + it('should show new version indicator when hasNewVersion is true', () => { + const detail = createPluginDetail({ + version: '1.0.0', + latest_version: '2.0.0', + }) + render() + + // Badge component should render with the version + expect(screen.getByText('1.0.0')).toBeInTheDocument() + }) + + it('should not show new version indicator when versions match', () => { + const detail = createPluginDetail({ + version: '1.0.0', + latest_version: '1.0.0', + }) + render() + + // Badge component should render with the version + expect(screen.getByText('1.0.0')).toBeInTheDocument() + }) + + it('should show update button when new version is available', () => { + const detail = createPluginDetail({ + version: '1.0.0', + latest_version: '2.0.0', + }) + render() + + expect(screen.getByText('detailPanel.operation.update')).toBeInTheDocument() + }) + + it('should show update button for GitHub source', () => { + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' }, + }) + render() + + expect(screen.getByText('detailPanel.operation.update')).toBeInTheDocument() + }) + }) + + describe('Auto Upgrade Feature', () => { + it('should render component when marketplace is disabled', () => { + mockAutoUpgradeInfo = { + strategy_setting: 'enabled', + upgrade_mode: 'update_all', + include_plugins: [], + exclude_plugins: [], + upgrade_time_of_day: 36000, + } + + render() + + expect(screen.getByTestId('title')).toBeInTheDocument() + }) + + it('should render component when strategy is disabled', () => { + mockAutoUpgradeInfo = { + strategy_setting: 'disabled', + upgrade_mode: 'update_all', + include_plugins: [], + exclude_plugins: [], + upgrade_time_of_day: 36000, + } + + render() + + expect(screen.getByTestId('title')).toBeInTheDocument() + }) + + it('should enable auto upgrade for update_all mode', () => { + mockAutoUpgradeInfo = { + strategy_setting: 'enabled', + upgrade_mode: 'update_all', + include_plugins: [], + exclude_plugins: [], + upgrade_time_of_day: 36000, + } + + render() + + // Auto upgrade badge should be rendered + expect(screen.getByTestId('title')).toBeInTheDocument() + }) + + it('should enable auto upgrade for partial mode when plugin is included', () => { + mockAutoUpgradeInfo = { + strategy_setting: 'enabled', + upgrade_mode: 'partial', + include_plugins: ['test-plugin'], + exclude_plugins: [], + upgrade_time_of_day: 36000, + } + + render() + + expect(screen.getByTestId('title')).toBeInTheDocument() + }) + + it('should not enable auto upgrade for partial mode when plugin is not included', () => { + mockAutoUpgradeInfo = { + strategy_setting: 'enabled', + upgrade_mode: 'partial', + include_plugins: ['other-plugin'], + exclude_plugins: [], + upgrade_time_of_day: 36000, + } + + render() + + expect(screen.getByTestId('title')).toBeInTheDocument() + }) + + it('should enable auto upgrade for exclude mode when plugin is not excluded', () => { + mockAutoUpgradeInfo = { + strategy_setting: 'enabled', + upgrade_mode: 'exclude', + include_plugins: [], + exclude_plugins: ['other-plugin'], + upgrade_time_of_day: 36000, + } + + render() + + expect(screen.getByTestId('title')).toBeInTheDocument() + }) + + it('should not enable auto upgrade for exclude mode when plugin is excluded', () => { + mockAutoUpgradeInfo = { + strategy_setting: 'enabled', + upgrade_mode: 'exclude', + include_plugins: [], + exclude_plugins: ['test-plugin'], + upgrade_time_of_day: 36000, + } + + render() + + expect(screen.getByTestId('title')).toBeInTheDocument() + }) + + it('should not enable auto upgrade for non-marketplace plugins', () => { + mockAutoUpgradeInfo = { + strategy_setting: 'enabled', + upgrade_mode: 'update_all', + include_plugins: [], + exclude_plugins: [], + upgrade_time_of_day: 36000, + } + + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' }, + }) + render() + + expect(screen.getByTestId('title')).toBeInTheDocument() + }) + + it('should not enable auto upgrade when marketplace feature is disabled', () => { + mockEnableMarketplace = false + mockAutoUpgradeInfo = { + strategy_setting: 'enabled', + upgrade_mode: 'update_all', + include_plugins: [], + exclude_plugins: [], + upgrade_time_of_day: 36000, + } + + render() + + // Component should still render but auto upgrade should be disabled + expect(screen.getByTestId('title')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onHide when close button clicked', () => { + render() + + // Find the close button (ActionButton with action-btn class) + const actionButtons = screen.getAllByRole('button').filter(btn => btn.classList.contains('action-btn')) + fireEvent.click(actionButtons[actionButtons.length - 1]) + + expect(mockOnHide).toHaveBeenCalled() + }) + + it('should have info button available', () => { + render() + + const infoBtn = screen.getByTestId('info-btn') + fireEvent.click(infoBtn) + + expect(infoBtn).toBeInTheDocument() + }) + + it('should have check version button available', () => { + render() + + const checkBtn = screen.getByTestId('check-version-btn') + fireEvent.click(checkBtn) + + expect(checkBtn).toBeInTheDocument() + }) + }) + + describe('Update Flow - Marketplace', () => { + it('should have update button for new version', () => { + const detail = createPluginDetail({ + version: '1.0.0', + latest_version: '2.0.0', + }) + render() + + const updateBtn = screen.getByText('detailPanel.operation.update') + fireEvent.click(updateBtn) + + expect(updateBtn).toBeInTheDocument() + }) + + it('should have version picker select button', () => { + render() + + const selectBtn = screen.getByTestId('select-version-btn') + fireEvent.click(selectBtn) + + expect(selectBtn).toBeInTheDocument() + }) + + it('should have downgrade button', () => { + render() + + const downgradeBtn = screen.getByTestId('select-downgrade-btn') + fireEvent.click(downgradeBtn) + + expect(downgradeBtn).toBeInTheDocument() + }) + }) + + describe('Update Flow - GitHub', () => { + it('should check for updates from GitHub when update clicked', async () => { + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' }, + }) + render() + + fireEvent.click(screen.getByText('detailPanel.operation.update')) + + await waitFor(() => { + expect(mockFetchReleases).toHaveBeenCalledWith('owner', 'repo') + }) + }) + + it('should show toast when no releases found', async () => { + mockFetchReleases.mockResolvedValueOnce([]) + + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' }, + }) + render() + + fireEvent.click(screen.getByText('detailPanel.operation.update')) + + await waitFor(() => { + expect(mockFetchReleases).toHaveBeenCalled() + }) + }) + + it('should show update plugin modal when update is needed', async () => { + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' }, + }) + render() + + fireEvent.click(screen.getByText('detailPanel.operation.update')) + + await waitFor(() => { + expect(mockSetShowUpdatePluginModal).toHaveBeenCalled() + }) + }) + + it('should call onUpdate via onSaveCallback when GitHub update completes', async () => { + mockSetShowUpdatePluginModal.mockImplementation(({ onSaveCallback }) => { + // Simulate the modal completing and calling onSaveCallback + onSaveCallback() + }) + + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' }, + }) + render() + + fireEvent.click(screen.getByText('detailPanel.operation.update')) + + await waitFor(() => { + expect(mockOnUpdate).toHaveBeenCalled() + }) + }) + }) + + describe('Delete Flow', () => { + it('should have remove button available', () => { + render() + + const removeBtn = screen.getByTestId('remove-btn') + fireEvent.click(removeBtn) + + expect(removeBtn).toBeInTheDocument() + }) + + it('should have uninstallPlugin mock defined', () => { + render() + + fireEvent.click(screen.getByTestId('remove-btn')) + + expect(mockUninstallPlugin).toBeDefined() + }) + + it('should render correctly for model plugin delete', () => { + const detail = createPluginDetail({ + declaration: { + ...createPluginDetail().declaration, + category: 'model', + } as unknown as PluginDetail['declaration'], + }) + render() + + expect(screen.getByTestId('remove-btn')).toBeInTheDocument() + }) + + it('should render correctly for tool plugin delete', () => { + render() + + expect(screen.getByTestId('remove-btn')).toBeInTheDocument() + }) + }) + + describe('Plugin Sources', () => { + it('should render github source icon', () => { + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' }, + }) + render() + + expect(screen.getByTestId('title')).toBeInTheDocument() + }) + + it('should render local source icon', () => { + const detail = createPluginDetail({ source: PluginSource.local }) + render() + + expect(screen.getByTestId('title')).toBeInTheDocument() + }) + + it('should render debugging source icon', () => { + const detail = createPluginDetail({ source: PluginSource.debugging }) + render() + + expect(screen.getByTestId('title')).toBeInTheDocument() + }) + + it('should not render deprecation notice for non-marketplace source', () => { + const detail = createPluginDetail({ source: PluginSource.github, meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' } }) + render() + + expect(screen.queryByTestId('deprecation-notice')).not.toBeInTheDocument() + }) + }) + + describe('Detail URL Generation', () => { + it('should render GitHub source correctly', () => { + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' }, + }) + render() + + expect(screen.getByTestId('operation-dropdown')).toBeInTheDocument() + }) + + it('should render marketplace source correctly', () => { + render() + + expect(screen.getByTestId('operation-dropdown')).toBeInTheDocument() + }) + + it('should render local source correctly', () => { + const detail = createPluginDetail({ source: PluginSource.local }) + render() + + expect(screen.getByTestId('operation-dropdown')).toBeInTheDocument() + }) + }) + + describe('Plugin Auth', () => { + it('should render plugin auth for tool category', () => { + render() + + expect(screen.getByTestId('plugin-auth')).toBeInTheDocument() + }) + + it('should not render plugin auth for non-tool category', () => { + const detail = createPluginDetail({ + declaration: { + ...createPluginDetail().declaration, + category: 'model', + } as unknown as PluginDetail['declaration'], + }) + render() + + expect(screen.queryByTestId('plugin-auth')).not.toBeInTheDocument() + }) + + it('should not render plugin auth in readme view', () => { + render() + + expect(screen.queryByTestId('plugin-auth')).not.toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle plugin without version', () => { + const detail = createPluginDetail({ version: '' }) + render() + + expect(screen.getByTestId('title')).toBeInTheDocument() + }) + + it('should handle plugin with name containing slash', () => { + const detail = createPluginDetail({ + declaration: { + ...createPluginDetail().declaration, + name: 'org/plugin-name', + } as unknown as PluginDetail['declaration'], + }) + render() + + expect(screen.getByTestId('org-info')).toBeInTheDocument() + }) + + it('should handle empty icon', () => { + const detail = createPluginDetail({ + declaration: { + ...createPluginDetail().declaration, + icon: '', + } as unknown as PluginDetail['declaration'], + }) + render() + + expect(screen.getByTestId('card-icon')).toHaveAttribute('data-src', '') + }) + }) + + describe('Delete Confirmation Flow', () => { + it('should show delete confirm when remove button is clicked', async () => { + render() + + fireEvent.click(screen.getByTestId('remove-btn')) + + await waitFor(() => { + expect(screen.getByTestId('delete-confirm')).toBeInTheDocument() + }) + }) + + it('should hide delete confirm when cancel is clicked', async () => { + render() + + fireEvent.click(screen.getByTestId('remove-btn')) + await waitFor(() => { + expect(screen.getByTestId('delete-confirm')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('confirm-cancel')) + + await waitFor(() => { + expect(screen.queryByTestId('delete-confirm')).not.toBeInTheDocument() + }) + }) + + it('should call uninstallPlugin when confirm delete is clicked', async () => { + render() + + fireEvent.click(screen.getByTestId('remove-btn')) + await waitFor(() => { + expect(screen.getByTestId('delete-confirm')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('confirm-ok')) + + await waitFor(() => { + expect(mockUninstallPlugin).toHaveBeenCalledWith('test-id') + }) + }) + + it('should call onUpdate with true after successful delete', async () => { + render() + + fireEvent.click(screen.getByTestId('remove-btn')) + await waitFor(() => { + expect(screen.getByTestId('delete-confirm')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('confirm-ok')) + + await waitFor(() => { + expect(mockOnUpdate).toHaveBeenCalledWith(true) + }) + }) + + it('should refresh model providers when deleting model plugin', async () => { + const detail = createPluginDetail({ + declaration: { + ...createPluginDetail().declaration, + category: 'model', + } as unknown as PluginDetail['declaration'], + }) + render() + + fireEvent.click(screen.getByTestId('remove-btn')) + await waitFor(() => { + expect(screen.getByTestId('delete-confirm')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('confirm-ok')) + + await waitFor(() => { + expect(mockRefreshModelProviders).toHaveBeenCalled() + }) + }) + + it('should invalidate tool providers when deleting tool plugin', async () => { + render() + + fireEvent.click(screen.getByTestId('remove-btn')) + await waitFor(() => { + expect(screen.getByTestId('delete-confirm')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('confirm-ok')) + + await waitFor(() => { + expect(mockInvalidateAllToolProviders).toHaveBeenCalled() + }) + }) + + it('should track plugin uninstalled event after successful delete', async () => { + render() + + fireEvent.click(screen.getByTestId('remove-btn')) + await waitFor(() => { + expect(screen.getByTestId('delete-confirm')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('confirm-ok')) + + await waitFor(() => { + expect(amplitude.trackEvent).toHaveBeenCalledWith('plugin_uninstalled', expect.any(Object)) + }) + }) + }) + + describe('Update Modal Flow', () => { + it('should show update modal when update button clicked for marketplace plugin', async () => { + const detail = createPluginDetail({ + version: '1.0.0', + latest_version: '2.0.0', + }) + render() + + fireEvent.click(screen.getByText('detailPanel.operation.update')) + + await waitFor(() => { + expect(screen.getByTestId('update-modal')).toBeInTheDocument() + }) + }) + + it('should call onUpdate when save is clicked in update modal', async () => { + const detail = createPluginDetail({ + version: '1.0.0', + latest_version: '2.0.0', + }) + render() + + fireEvent.click(screen.getByText('detailPanel.operation.update')) + await waitFor(() => { + expect(screen.getByTestId('update-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('update-modal-save')) + + await waitFor(() => { + expect(mockOnUpdate).toHaveBeenCalled() + }) + }) + + it('should hide update modal when cancel is clicked', async () => { + const detail = createPluginDetail({ + version: '1.0.0', + latest_version: '2.0.0', + }) + render() + + fireEvent.click(screen.getByText('detailPanel.operation.update')) + await waitFor(() => { + expect(screen.getByTestId('update-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('update-modal-cancel')) + + await waitFor(() => { + expect(screen.queryByTestId('update-modal')).not.toBeInTheDocument() + }) + }) + }) + + describe('Plugin Info Modal', () => { + it('should show plugin info modal when info button is clicked', async () => { + render() + + fireEvent.click(screen.getByTestId('info-btn')) + + await waitFor(() => { + expect(screen.getByTestId('plugin-info')).toBeInTheDocument() + }) + }) + + it('should hide plugin info modal when close button is clicked', async () => { + render() + + fireEvent.click(screen.getByTestId('info-btn')) + await waitFor(() => { + expect(screen.getByTestId('plugin-info')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('plugin-info-close')) + + await waitFor(() => { + expect(screen.queryByTestId('plugin-info')).not.toBeInTheDocument() + }) + }) + + it('should render plugin info with GitHub meta data', () => { + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'test-pkg' }, + }) + render() + + expect(screen.getByTestId('info-btn')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-card.spec.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-card.spec.tsx new file mode 100644 index 0000000000..203bd6a02a --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-card.spec.tsx @@ -0,0 +1,386 @@ +import type { EndpointListItem, PluginDetail } from '../types' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Toast from '@/app/components/base/toast' +import EndpointCard from './endpoint-card' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('copy-to-clipboard', () => ({ + default: vi.fn(), +})) + +const mockHandleChange = vi.fn() +const mockEnableEndpoint = vi.fn() +const mockDisableEndpoint = vi.fn() +const mockDeleteEndpoint = vi.fn() +const mockUpdateEndpoint = vi.fn() + +// Flags to control whether operations should fail +const failureFlags = { + enable: false, + disable: false, + delete: false, + update: false, +} + +vi.mock('@/service/use-endpoints', () => ({ + useEnableEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({ + mutate: (id: string) => { + mockEnableEndpoint(id) + if (failureFlags.enable) + onError() + else + onSuccess() + }, + }), + useDisableEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({ + mutate: (id: string) => { + mockDisableEndpoint(id) + if (failureFlags.disable) + onError() + else + onSuccess() + }, + }), + useDeleteEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({ + mutate: (id: string) => { + mockDeleteEndpoint(id) + if (failureFlags.delete) + onError() + else + onSuccess() + }, + }), + useUpdateEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({ + mutate: (data: unknown) => { + mockUpdateEndpoint(data) + if (failureFlags.update) + onError() + else + onSuccess() + }, + }), +})) + +vi.mock('@/app/components/header/indicator', () => ({ + default: ({ color }: { color: string }) => , +})) + +vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ + toolCredentialToFormSchemas: (schemas: unknown[]) => schemas, + addDefaultValue: (value: unknown) => value, +})) + +vi.mock('./endpoint-modal', () => ({ + default: ({ onCancel, onSaved }: { onCancel: () => void, onSaved: (state: unknown) => void }) => ( +
+ + +
+ ), +})) + +const mockEndpointData: EndpointListItem = { + id: 'ep-1', + name: 'Test Endpoint', + url: 'https://api.example.com', + enabled: true, + created_at: '2024-01-01', + updated_at: '2024-01-02', + settings: {}, + tenant_id: 'tenant-1', + plugin_id: 'plugin-1', + expired_at: '', + hook_id: 'hook-1', + declaration: { + settings: [], + endpoints: [ + { path: '/api/test', method: 'GET' }, + { path: '/api/hidden', method: 'POST', hidden: true }, + ], + }, +} + +const mockPluginDetail: PluginDetail = { + id: 'test-id', + created_at: '2024-01-01', + updated_at: '2024-01-02', + name: 'Test Plugin', + plugin_id: 'test-plugin', + plugin_unique_identifier: 'test-uid', + declaration: {} as PluginDetail['declaration'], + installation_id: 'install-1', + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '1.0.0', + latest_unique_identifier: 'test-uid', + source: 'marketplace' as PluginDetail['source'], + meta: undefined, + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', +} + +describe('EndpointCard', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + // Reset failure flags + failureFlags.enable = false + failureFlags.disable = false + failureFlags.delete = false + failureFlags.update = false + // Mock Toast.notify to prevent toast elements from accumulating in DOM + vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('Rendering', () => { + it('should render endpoint name', () => { + render() + + expect(screen.getByText('Test Endpoint')).toBeInTheDocument() + }) + + it('should render visible endpoints only', () => { + render() + + expect(screen.getByText('GET')).toBeInTheDocument() + expect(screen.getByText('https://api.example.com/api/test')).toBeInTheDocument() + expect(screen.queryByText('POST')).not.toBeInTheDocument() + }) + + it('should show active status when enabled', () => { + render() + + expect(screen.getByText('detailPanel.serviceOk')).toBeInTheDocument() + expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green') + }) + + it('should show disabled status when not enabled', () => { + const disabledData = { ...mockEndpointData, enabled: false } + render() + + expect(screen.getByText('detailPanel.disabled')).toBeInTheDocument() + expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'gray') + }) + }) + + describe('User Interactions', () => { + it('should show disable confirm when switching off', () => { + render() + + fireEvent.click(screen.getByRole('switch')) + + expect(screen.getByText('detailPanel.endpointDisableTip')).toBeInTheDocument() + }) + + it('should call disableEndpoint when confirm disable', () => { + render() + + fireEvent.click(screen.getByRole('switch')) + // Click confirm button in the Confirm dialog + fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' })) + + expect(mockDisableEndpoint).toHaveBeenCalledWith('ep-1') + }) + + it('should show delete confirm when delete clicked', () => { + render() + + // Find delete button by its destructive class + const allButtons = screen.getAllByRole('button') + const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary')) + expect(deleteButton).toBeDefined() + if (deleteButton) + fireEvent.click(deleteButton) + + expect(screen.getByText('detailPanel.endpointDeleteTip')).toBeInTheDocument() + }) + + it('should call deleteEndpoint when confirm delete', () => { + render() + + const allButtons = screen.getAllByRole('button') + const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary')) + expect(deleteButton).toBeDefined() + if (deleteButton) + fireEvent.click(deleteButton) + fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' })) + + expect(mockDeleteEndpoint).toHaveBeenCalledWith('ep-1') + }) + + it('should show edit modal when edit clicked', () => { + render() + + const actionButtons = screen.getAllByRole('button', { name: '' }) + const editButton = actionButtons[0] + if (editButton) + fireEvent.click(editButton) + + expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument() + }) + + it('should call updateEndpoint when save in modal', () => { + render() + + const actionButtons = screen.getAllByRole('button', { name: '' }) + const editButton = actionButtons[0] + if (editButton) + fireEvent.click(editButton) + fireEvent.click(screen.getByTestId('modal-save')) + + expect(mockUpdateEndpoint).toHaveBeenCalled() + }) + }) + + describe('Copy Functionality', () => { + it('should reset copy state after timeout', async () => { + render() + + // Find copy button by its class + const allButtons = screen.getAllByRole('button') + const copyButton = allButtons.find(btn => btn.classList.contains('ml-2')) + expect(copyButton).toBeDefined() + if (copyButton) { + fireEvent.click(copyButton) + + act(() => { + vi.advanceTimersByTime(2000) + }) + + // After timeout, the component should still be rendered correctly + expect(screen.getByText('Test Endpoint')).toBeInTheDocument() + } + }) + }) + + describe('Edge Cases', () => { + it('should handle empty endpoints', () => { + const dataWithNoEndpoints = { + ...mockEndpointData, + declaration: { settings: [], endpoints: [] }, + } + render() + + expect(screen.getByText('Test Endpoint')).toBeInTheDocument() + }) + + it('should call handleChange after enable', () => { + const disabledData = { ...mockEndpointData, enabled: false } + render() + + fireEvent.click(screen.getByRole('switch')) + + expect(mockHandleChange).toHaveBeenCalled() + }) + + it('should hide disable confirm and revert state when cancel clicked', () => { + render() + + fireEvent.click(screen.getByRole('switch')) + expect(screen.getByText('detailPanel.endpointDisableTip')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' })) + + // Confirm should be hidden + expect(screen.queryByText('detailPanel.endpointDisableTip')).not.toBeInTheDocument() + }) + + it('should hide delete confirm when cancel clicked', () => { + render() + + const allButtons = screen.getAllByRole('button') + const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary')) + expect(deleteButton).toBeDefined() + if (deleteButton) + fireEvent.click(deleteButton) + expect(screen.getByText('detailPanel.endpointDeleteTip')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' })) + + expect(screen.queryByText('detailPanel.endpointDeleteTip')).not.toBeInTheDocument() + }) + + it('should hide edit modal when cancel clicked', () => { + render() + + const actionButtons = screen.getAllByRole('button', { name: '' }) + const editButton = actionButtons[0] + if (editButton) + fireEvent.click(editButton) + expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('modal-cancel')) + + expect(screen.queryByTestId('endpoint-modal')).not.toBeInTheDocument() + }) + }) + + describe('Error Handling', () => { + it('should show error toast when enable fails', () => { + failureFlags.enable = true + const disabledData = { ...mockEndpointData, enabled: false } + render() + + fireEvent.click(screen.getByRole('switch')) + + expect(mockEnableEndpoint).toHaveBeenCalled() + }) + + it('should show error toast when disable fails', () => { + failureFlags.disable = true + render() + + fireEvent.click(screen.getByRole('switch')) + fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' })) + + expect(mockDisableEndpoint).toHaveBeenCalled() + }) + + it('should show error toast when delete fails', () => { + failureFlags.delete = true + render() + + const allButtons = screen.getAllByRole('button') + const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary')) + if (deleteButton) + fireEvent.click(deleteButton) + fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' })) + + expect(mockDeleteEndpoint).toHaveBeenCalled() + }) + + it('should show error toast when update fails', () => { + render() + + const actionButtons = screen.getAllByRole('button', { name: '' }) + const editButton = actionButtons[0] + expect(editButton).toBeDefined() + if (editButton) + fireEvent.click(editButton) + + // Verify modal is open + expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument() + + // Set failure flag before save is clicked + failureFlags.update = true + fireEvent.click(screen.getByTestId('modal-save')) + + expect(mockUpdateEndpoint).toHaveBeenCalled() + // On error, handleChange is not called + expect(mockHandleChange).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-list.spec.tsx new file mode 100644 index 0000000000..0c9865153a --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-list.spec.tsx @@ -0,0 +1,222 @@ +import type { PluginDetail } from '@/app/components/plugins/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import EndpointList from './endpoint-list' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.example.com${path}`, +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '), +})) + +const mockEndpoints = [ + { id: 'ep-1', name: 'Endpoint 1', url: 'https://api.example.com', declaration: { settings: [], endpoints: [] } }, +] + +let mockEndpointListData: { endpoints: typeof mockEndpoints } | undefined + +const mockInvalidateEndpointList = vi.fn() +const mockCreateEndpoint = vi.fn() + +vi.mock('@/service/use-endpoints', () => ({ + useEndpointList: () => ({ data: mockEndpointListData }), + useInvalidateEndpointList: () => mockInvalidateEndpointList, + useCreateEndpoint: ({ onSuccess }: { onSuccess: () => void }) => ({ + mutate: (data: unknown) => { + mockCreateEndpoint(data) + onSuccess() + }, + }), +})) + +vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ + toolCredentialToFormSchemas: (schemas: unknown[]) => schemas, +})) + +vi.mock('./endpoint-card', () => ({ + default: ({ data }: { data: { name: string } }) => ( +
{data.name}
+ ), +})) + +vi.mock('./endpoint-modal', () => ({ + default: ({ onCancel, onSaved }: { onCancel: () => void, onSaved: (state: unknown) => void }) => ( +
+ + +
+ ), +})) + +const createPluginDetail = (): PluginDetail => ({ + id: 'test-id', + created_at: '2024-01-01', + updated_at: '2024-01-02', + name: 'Test Plugin', + plugin_id: 'test-plugin', + plugin_unique_identifier: 'test-uid', + declaration: { + endpoint: { settings: [], endpoints: [] }, + tool: undefined, + } as unknown as PluginDetail['declaration'], + installation_id: 'install-1', + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '1.0.0', + latest_unique_identifier: 'test-uid', + source: 'marketplace' as PluginDetail['source'], + meta: undefined, + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', +}) + +describe('EndpointList', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEndpointListData = { endpoints: mockEndpoints } + }) + + describe('Rendering', () => { + it('should render endpoint list', () => { + render() + + expect(screen.getByText('detailPanel.endpoints')).toBeInTheDocument() + }) + + it('should render endpoint cards', () => { + render() + + expect(screen.getByTestId('endpoint-card')).toBeInTheDocument() + expect(screen.getByText('Endpoint 1')).toBeInTheDocument() + }) + + it('should return null when no data', () => { + mockEndpointListData = undefined + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + + it('should show empty message when no endpoints', () => { + mockEndpointListData = { endpoints: [] } + render() + + expect(screen.getByText('detailPanel.endpointsEmpty')).toBeInTheDocument() + }) + + it('should render add button', () => { + render() + + const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn')) + expect(addButton).toBeDefined() + }) + }) + + describe('User Interactions', () => { + it('should show modal when add button clicked', () => { + render() + + const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn')) + if (addButton) + fireEvent.click(addButton) + + expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument() + }) + + it('should hide modal when cancel clicked', () => { + render() + + const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn')) + if (addButton) + fireEvent.click(addButton) + expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('modal-cancel')) + expect(screen.queryByTestId('endpoint-modal')).not.toBeInTheDocument() + }) + + it('should call createEndpoint when save clicked', () => { + render() + + const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn')) + if (addButton) + fireEvent.click(addButton) + fireEvent.click(screen.getByTestId('modal-save')) + + expect(mockCreateEndpoint).toHaveBeenCalled() + }) + }) + + describe('Border Style', () => { + it('should render with border style based on tool existence', () => { + const detail = createPluginDetail() + detail.declaration.tool = {} as PluginDetail['declaration']['tool'] + render() + + // Verify the component renders correctly + expect(screen.getByText('detailPanel.endpoints')).toBeInTheDocument() + }) + }) + + describe('Multiple Endpoints', () => { + it('should render multiple endpoint cards', () => { + mockEndpointListData = { + endpoints: [ + { id: 'ep-1', name: 'Endpoint 1', url: 'https://api1.example.com', declaration: { settings: [], endpoints: [] } }, + { id: 'ep-2', name: 'Endpoint 2', url: 'https://api2.example.com', declaration: { settings: [], endpoints: [] } }, + ], + } + render() + + expect(screen.getAllByTestId('endpoint-card')).toHaveLength(2) + }) + }) + + describe('Tooltip', () => { + it('should render with tooltip content', () => { + render() + + // Tooltip is rendered - the add button should be visible + const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn')) + expect(addButton).toBeDefined() + }) + }) + + describe('Create Endpoint Flow', () => { + it('should invalidate endpoint list after successful create', () => { + render() + + const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn')) + if (addButton) + fireEvent.click(addButton) + fireEvent.click(screen.getByTestId('modal-save')) + + expect(mockInvalidateEndpointList).toHaveBeenCalledWith('test-plugin') + }) + + it('should pass correct params to createEndpoint', () => { + render() + + const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn')) + if (addButton) + fireEvent.click(addButton) + fireEvent.click(screen.getByTestId('modal-save')) + + expect(mockCreateEndpoint).toHaveBeenCalledWith({ + pluginUniqueID: 'test-uid', + state: { name: 'New Endpoint' }, + }) + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-modal.spec.tsx new file mode 100644 index 0000000000..96fa647e91 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-modal.spec.tsx @@ -0,0 +1,519 @@ +import type { FormSchema } from '../../base/form/types' +import type { PluginDetail } from '../types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Toast from '@/app/components/base/toast' +import EndpointModal from './endpoint-modal' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + if (opts?.field) + return `${key}: ${opts.field}` + return key + }, + }), +})) + +vi.mock('@/hooks/use-i18n', () => ({ + useRenderI18nObject: () => (obj: Record | string) => + typeof obj === 'string' ? obj : obj?.en_US || '', +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({ + default: ({ value, onChange, fieldMoreInfo }: { + value: Record + onChange: (v: Record) => void + fieldMoreInfo?: (item: { url?: string }) => React.ReactNode + }) => { + return ( +
+ onChange({ ...value, name: e.target.value })} + /> + {/* Render fieldMoreInfo to test url link */} + {fieldMoreInfo && ( +
+ {fieldMoreInfo({ url: 'https://example.com' })} + {fieldMoreInfo({})} +
+ )} +
+ ) + }, +})) + +vi.mock('../readme-panel/entrance', () => ({ + ReadmeEntrance: () =>
, +})) + +const mockFormSchemas = [ + { name: 'name', label: { en_US: 'Name' }, type: 'text-input', required: true, default: '' }, + { name: 'apiKey', label: { en_US: 'API Key' }, type: 'secret-input', required: false, default: '' }, +] as unknown as FormSchema[] + +const mockPluginDetail: PluginDetail = { + id: 'test-id', + created_at: '2024-01-01', + updated_at: '2024-01-02', + name: 'Test Plugin', + plugin_id: 'test-plugin', + plugin_unique_identifier: 'test-uid', + declaration: {} as PluginDetail['declaration'], + installation_id: 'install-1', + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '1.0.0', + latest_unique_identifier: 'test-uid', + source: 'marketplace' as PluginDetail['source'], + meta: undefined, + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', +} + +describe('EndpointModal', () => { + const mockOnCancel = vi.fn() + const mockOnSaved = vi.fn() + let mockToastNotify: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + mockToastNotify = vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) + }) + + describe('Rendering', () => { + it('should render drawer', () => { + render( + , + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should render title and description', () => { + render( + , + ) + + expect(screen.getByText('detailPanel.endpointModalTitle')).toBeInTheDocument() + expect(screen.getByText('detailPanel.endpointModalDesc')).toBeInTheDocument() + }) + + it('should render form with fieldMoreInfo url link', () => { + render( + , + ) + + expect(screen.getByTestId('field-more-info')).toBeInTheDocument() + // Should render the "howToGet" link when url exists + expect(screen.getByText('howToGet')).toBeInTheDocument() + }) + + it('should render readme entrance', () => { + render( + , + ) + + expect(screen.getByTestId('readme-entrance')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onCancel when cancel clicked', () => { + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' })) + + expect(mockOnCancel).toHaveBeenCalledTimes(1) + }) + + it('should call onCancel when close button clicked', () => { + render( + , + ) + + // Find the close button (ActionButton with RiCloseLine icon) + const allButtons = screen.getAllByRole('button') + const closeButton = allButtons.find(btn => btn.classList.contains('action-btn')) + if (closeButton) + fireEvent.click(closeButton) + + expect(mockOnCancel).toHaveBeenCalledTimes(1) + }) + + it('should update form value when input changes', () => { + render( + , + ) + + const input = screen.getByTestId('form-input') + fireEvent.change(input, { target: { value: 'Test Name' } }) + + expect(input).toHaveValue('Test Name') + }) + }) + + describe('Default Values', () => { + it('should use defaultValues when provided', () => { + render( + , + ) + + expect(screen.getByTestId('form-input')).toHaveValue('Default Name') + }) + + it('should extract default values from schemas when no defaultValues', () => { + const schemasWithDefaults = [ + { name: 'name', label: 'Name', type: 'text-input', required: true, default: 'Schema Default' }, + ] as unknown as FormSchema[] + + render( + , + ) + + expect(screen.getByTestId('form-input')).toHaveValue('Schema Default') + }) + + it('should handle schemas without default values', () => { + const schemasNoDefault = [ + { name: 'name', label: 'Name', type: 'text-input', required: false }, + ] as unknown as FormSchema[] + + render( + , + ) + + expect(screen.getByTestId('form')).toBeInTheDocument() + }) + }) + + describe('Validation - handleSave', () => { + it('should show toast error when required field is empty', () => { + const schemasWithRequired = [ + { name: 'name', label: { en_US: 'Name Field' }, type: 'text-input', required: true, default: '' }, + ] as unknown as FormSchema[] + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'error', + message: expect.stringContaining('errorMsg.fieldRequired'), + }) + expect(mockOnSaved).not.toHaveBeenCalled() + }) + + it('should show toast error with string label when required field is empty', () => { + const schemasWithStringLabel = [ + { name: 'name', label: 'String Label', type: 'text-input', required: true, default: '' }, + ] as unknown as FormSchema[] + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'error', + message: expect.stringContaining('String Label'), + }) + }) + + it('should call onSaved when all required fields are filled', () => { + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + + expect(mockOnSaved).toHaveBeenCalledWith({ name: 'Valid Name' }) + }) + + it('should not validate non-required empty fields', () => { + const schemasOptional = [ + { name: 'optional', label: 'Optional', type: 'text-input', required: false, default: '' }, + ] as unknown as FormSchema[] + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + + expect(mockToastNotify).not.toHaveBeenCalled() + expect(mockOnSaved).toHaveBeenCalled() + }) + }) + + describe('Boolean Field Processing', () => { + it('should convert string "true" to boolean true', () => { + const schemasWithBoolean = [ + { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' }, + ] as unknown as FormSchema[] + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + + expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) + }) + + it('should convert string "1" to boolean true', () => { + const schemasWithBoolean = [ + { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' }, + ] as unknown as FormSchema[] + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + + expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) + }) + + it('should convert string "True" to boolean true', () => { + const schemasWithBoolean = [ + { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' }, + ] as unknown as FormSchema[] + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + + expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) + }) + + it('should convert string "false" to boolean false', () => { + const schemasWithBoolean = [ + { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' }, + ] as unknown as FormSchema[] + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + + expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false }) + }) + + it('should convert number 1 to boolean true', () => { + const schemasWithBoolean = [ + { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' }, + ] as unknown as FormSchema[] + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + + expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) + }) + + it('should convert number 0 to boolean false', () => { + const schemasWithBoolean = [ + { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' }, + ] as unknown as FormSchema[] + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + + expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false }) + }) + + it('should preserve boolean true value', () => { + const schemasWithBoolean = [ + { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' }, + ] as unknown as FormSchema[] + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + + expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true }) + }) + + it('should preserve boolean false value', () => { + const schemasWithBoolean = [ + { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' }, + ] as unknown as FormSchema[] + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + + expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false }) + }) + + it('should not process non-boolean fields', () => { + const schemasWithText = [ + { name: 'text', label: 'Text', type: 'text-input', required: false, default: '' }, + ] as unknown as FormSchema[] + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.save' })) + + expect(mockOnSaved).toHaveBeenCalledWith({ text: 'hello' }) + }) + }) + + describe('Memoization', () => { + it('should be wrapped with React.memo', () => { + expect(EndpointModal).toBeDefined() + expect((EndpointModal as { $$typeof?: symbol }).$$typeof).toBeDefined() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/index.spec.tsx new file mode 100644 index 0000000000..0cc9671e1b --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/index.spec.tsx @@ -0,0 +1,1144 @@ +import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types' +import PluginDetailPanel from './index' + +// Mock store +const mockSetDetail = vi.fn() +vi.mock('./store', () => ({ + usePluginStore: () => ({ + setDetail: mockSetDetail, + }), +})) + +// Mock DetailHeader +const mockDetailHeaderOnUpdate = vi.fn() +vi.mock('./detail-header', () => ({ + default: ({ detail, onUpdate, onHide }: { + detail: PluginDetail + onUpdate: (isDelete?: boolean) => void + onHide: () => void + }) => { + // Capture the onUpdate callback for testing + mockDetailHeaderOnUpdate.mockImplementation(onUpdate) + return ( +
+ {detail.name} + + + +
+ ) + }, +})) + +// Mock ActionList +vi.mock('./action-list', () => ({ + default: ({ detail }: { detail: PluginDetail }) => ( +
+ {detail.plugin_id} +
+ ), +})) + +// Mock AgentStrategyList +vi.mock('./agent-strategy-list', () => ({ + default: ({ detail }: { detail: PluginDetail }) => ( +
+ {detail.plugin_id} +
+ ), +})) + +// Mock EndpointList +vi.mock('./endpoint-list', () => ({ + default: ({ detail }: { detail: PluginDetail }) => ( +
+ {detail.plugin_id} +
+ ), +})) + +// Mock ModelList +vi.mock('./model-list', () => ({ + default: ({ detail }: { detail: PluginDetail }) => ( +
+ {detail.plugin_id} +
+ ), +})) + +// Mock DatasourceActionList +vi.mock('./datasource-action-list', () => ({ + default: ({ detail }: { detail: PluginDetail }) => ( +
+ {detail.plugin_id} +
+ ), +})) + +// Mock SubscriptionList +vi.mock('./subscription-list', () => ({ + SubscriptionList: ({ pluginDetail }: { pluginDetail: PluginDetail }) => ( +
+ {pluginDetail.plugin_id} +
+ ), +})) + +// Mock TriggerEventsList +vi.mock('./trigger/event-list', () => ({ + TriggerEventsList: () => ( +
Events List
+ ), +})) + +// Mock ReadmeEntrance +vi.mock('../readme-panel/entrance', () => ({ + ReadmeEntrance: ({ pluginDetail, className }: { pluginDetail: PluginDetail, className?: string }) => ( +
+ {pluginDetail.plugin_id} +
+ ), +})) + +// Mock classnames utility +vi.mock('@/utils/classnames', () => ({ + cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '), +})) + +// Factory function to create mock PluginDetail +const createPluginDetail = (overrides: Partial = {}): PluginDetail => { + const baseDeclaration = { + plugin_unique_identifier: 'test-plugin-uid', + version: '1.0.0', + author: 'test-author', + icon: 'test-icon.png', + name: 'test-plugin', + category: PluginCategoryEnum.tool, + label: { en_US: 'Test Plugin' }, + description: { en_US: 'Test plugin description' }, + created_at: '2024-01-01T00:00:00Z', + resource: null, + plugins: null, + verified: true, + endpoint: undefined, + tool: { + identity: { + author: 'test-author', + name: 'test-tool', + description: { en_US: 'Test tool' }, + icon: 'tool-icon.png', + label: { en_US: 'Test Tool' }, + tags: [], + }, + credentials_schema: [], + }, + model: null, + tags: [], + agent_strategy: null, + meta: { version: '1.0.0' }, + trigger: null, + datasource: null, + } as unknown as PluginDeclaration + + return { + id: 'test-plugin-id', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + name: 'Test Plugin', + plugin_id: 'test-plugin-id', + plugin_unique_identifier: 'test-plugin-uid', + declaration: baseDeclaration, + installation_id: 'install-1', + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '1.0.0', + latest_unique_identifier: 'test-plugin-uid', + source: PluginSource.marketplace, + meta: undefined, + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', + ...overrides, + } +} + +// Factory for trigger plugin +const createTriggerPluginDetail = (overrides: Partial = {}): PluginDetail => { + const triggerDeclaration = { + ...createPluginDetail().declaration, + category: PluginCategoryEnum.trigger, + tool: undefined, + trigger: { + events: [], + identity: { + author: 'test-author', + name: 'test-trigger', + label: { en_US: 'Test Trigger' }, + description: { en_US: 'Test trigger desc' }, + icon: 'trigger-icon.png', + tags: [], + }, + subscription_constructor: { + credentials_schema: [], + oauth_schema: { client_schema: [], credentials_schema: [] }, + parameters: [], + }, + subscription_schema: [], + }, + } as unknown as PluginDeclaration + + return createPluginDetail({ + declaration: triggerDeclaration, + ...overrides, + }) +} + +// Factory for model plugin +const createModelPluginDetail = (overrides: Partial = {}): PluginDetail => { + return createPluginDetail({ + declaration: { + ...createPluginDetail().declaration, + category: PluginCategoryEnum.model, + tool: undefined, + model: { provider: 'test-provider' }, + }, + ...overrides, + }) +} + +// Factory for agent strategy plugin +const createAgentStrategyPluginDetail = (overrides: Partial = {}): PluginDetail => { + const strategyDeclaration = { + ...createPluginDetail().declaration, + category: PluginCategoryEnum.agent, + tool: undefined, + agent_strategy: { + identity: { + author: 'test-author', + name: 'test-strategy', + label: { en_US: 'Test Strategy' }, + description: { en_US: 'Test strategy desc' }, + icon: 'strategy-icon.png', + tags: [], + }, + }, + } as unknown as PluginDeclaration + + return createPluginDetail({ + declaration: strategyDeclaration, + ...overrides, + }) +} + +// Factory for endpoint plugin +const createEndpointPluginDetail = (overrides: Partial = {}): PluginDetail => { + return createPluginDetail({ + declaration: { + ...createPluginDetail().declaration, + category: PluginCategoryEnum.extension, + tool: undefined, + endpoint: { + settings: [], + endpoints: [{ path: '/test', method: 'GET' }], + }, + }, + ...overrides, + }) +} + +// Factory for datasource plugin +const createDatasourcePluginDetail = (overrides: Partial = {}): PluginDetail => { + const datasourceDeclaration = { + ...createPluginDetail().declaration, + category: PluginCategoryEnum.datasource, + tool: undefined, + datasource: { + identity: { + author: 'test-author', + name: 'test-datasource', + description: { en_US: 'Test datasource' }, + icon: 'datasource-icon.png', + label: { en_US: 'Test Datasource' }, + tags: [], + }, + credentials_schema: [], + }, + } as unknown as PluginDeclaration + + return createPluginDetail({ + declaration: datasourceDeclaration, + ...overrides, + }) +} + +describe('PluginDetailPanel', () => { + const mockOnUpdate = vi.fn() + const mockOnHide = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockSetDetail.mockClear() + mockOnUpdate.mockClear() + mockOnHide.mockClear() + mockDetailHeaderOnUpdate.mockClear() + }) + + describe('Rendering', () => { + it('should render nothing when detail is undefined', () => { + const { container } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('should render drawer when detail is provided', () => { + const detail = createPluginDetail() + + render( + , + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByTestId('detail-header')).toBeInTheDocument() + }) + + it('should render detail header with plugin name', () => { + const detail = createPluginDetail({ name: 'My Custom Plugin' }) + + render( + , + ) + + expect(screen.getByTestId('header-title')).toHaveTextContent('My Custom Plugin') + }) + + it('should render readme entrance with plugin detail', () => { + const detail = createPluginDetail() + + render( + , + ) + + expect(screen.getByTestId('readme-entrance')).toBeInTheDocument() + expect(screen.getByTestId('readme-plugin-id')).toHaveTextContent('test-plugin-id') + }) + + it('should render drawer with correct styles', () => { + const detail = createPluginDetail() + + render( + , + ) + + const drawer = screen.getByRole('dialog') + expect(drawer).toBeInTheDocument() + }) + }) + + describe('Conditional Rendering by Plugin Category', () => { + it('should render ActionList for tool plugins', () => { + const detail = createPluginDetail() + + render( + , + ) + + expect(screen.getByTestId('action-list')).toBeInTheDocument() + expect(screen.queryByTestId('model-list')).not.toBeInTheDocument() + expect(screen.queryByTestId('endpoint-list')).not.toBeInTheDocument() + expect(screen.queryByTestId('agent-strategy-list')).not.toBeInTheDocument() + expect(screen.queryByTestId('subscription-list')).not.toBeInTheDocument() + }) + + it('should render ModelList for model plugins', () => { + const detail = createModelPluginDetail() + + render( + , + ) + + expect(screen.getByTestId('model-list')).toBeInTheDocument() + expect(screen.queryByTestId('action-list')).not.toBeInTheDocument() + }) + + it('should render AgentStrategyList for agent strategy plugins', () => { + const detail = createAgentStrategyPluginDetail() + + render( + , + ) + + expect(screen.getByTestId('agent-strategy-list')).toBeInTheDocument() + expect(screen.queryByTestId('action-list')).not.toBeInTheDocument() + }) + + it('should render EndpointList for endpoint plugins', () => { + const detail = createEndpointPluginDetail() + + render( + , + ) + + expect(screen.getByTestId('endpoint-list')).toBeInTheDocument() + expect(screen.queryByTestId('action-list')).not.toBeInTheDocument() + }) + + it('should render DatasourceActionList for datasource plugins', () => { + const detail = createDatasourcePluginDetail() + + render( + , + ) + + expect(screen.getByTestId('datasource-action-list')).toBeInTheDocument() + expect(screen.queryByTestId('action-list')).not.toBeInTheDocument() + }) + + it('should render SubscriptionList and TriggerEventsList for trigger plugins', () => { + const detail = createTriggerPluginDetail() + + render( + , + ) + + expect(screen.getByTestId('subscription-list')).toBeInTheDocument() + expect(screen.getByTestId('trigger-events-list')).toBeInTheDocument() + expect(screen.queryByTestId('action-list')).not.toBeInTheDocument() + }) + + it('should render multiple lists when plugin has multiple declarations', () => { + const detail = createPluginDetail({ + declaration: { + ...createPluginDetail().declaration, + tool: createPluginDetail().declaration.tool, + endpoint: { + settings: [], + endpoints: [{ path: '/api', method: 'POST' }], + }, + }, + }) + + render( + , + ) + + expect(screen.getByTestId('action-list')).toBeInTheDocument() + expect(screen.getByTestId('endpoint-list')).toBeInTheDocument() + }) + }) + + describe('Side Effects and Cleanup', () => { + it('should call setDetail with correct data when detail is provided', () => { + const detail = createPluginDetail({ + plugin_id: 'my-plugin-id', + plugin_unique_identifier: 'my-plugin-uid', + name: 'My Plugin', + id: 'detail-id', + }) + + render( + , + ) + + expect(mockSetDetail).toHaveBeenCalledTimes(1) + expect(mockSetDetail).toHaveBeenCalledWith(expect.objectContaining({ + plugin_id: 'my-plugin-id', + plugin_unique_identifier: 'my-plugin-uid', + name: 'My Plugin', + id: 'detail-id', + provider: 'my-plugin-id/test-plugin', + })) + }) + + it('should call setDetail with undefined when detail becomes undefined', () => { + const detail = createPluginDetail() + const { rerender } = render( + , + ) + + expect(mockSetDetail).toHaveBeenCalledTimes(1) + + rerender( + , + ) + + expect(mockSetDetail).toHaveBeenCalledTimes(2) + expect(mockSetDetail).toHaveBeenLastCalledWith(undefined) + }) + + it('should update store when detail changes', () => { + const detail1 = createPluginDetail({ plugin_id: 'plugin-1' }) + const detail2 = createPluginDetail({ plugin_id: 'plugin-2' }) + + const { rerender } = render( + , + ) + + expect(mockSetDetail).toHaveBeenCalledTimes(1) + expect(mockSetDetail).toHaveBeenLastCalledWith(expect.objectContaining({ + plugin_id: 'plugin-1', + })) + + rerender( + , + ) + + expect(mockSetDetail).toHaveBeenCalledTimes(2) + expect(mockSetDetail).toHaveBeenLastCalledWith(expect.objectContaining({ + plugin_id: 'plugin-2', + })) + }) + + it('should include declaration in setDetail call', () => { + const detail = createPluginDetail() + + render( + , + ) + + expect(mockSetDetail).toHaveBeenCalledWith(expect.objectContaining({ + declaration: expect.any(Object), + })) + }) + }) + + describe('Callback Stability and Memoization', () => { + it('should maintain stable callback reference via useCallback', () => { + const detail = createPluginDetail() + const onUpdate = vi.fn() + const onHide = vi.fn() + + // Test that the callback is created with useCallback by verifying + // it depends on onHide and onUpdate (tested in other tests) + // This test verifies the basic rendering doesn't change the functionality + const { rerender } = render( + , + ) + + // Initial click should work + fireEvent.click(screen.getByTestId('header-update-btn')) + expect(onUpdate).toHaveBeenCalledTimes(1) + + // Re-render with same props + rerender( + , + ) + + // Callback should still work after re-render + fireEvent.click(screen.getByTestId('header-update-btn')) + expect(onUpdate).toHaveBeenCalledTimes(2) + }) + + it('should update handleUpdate when onUpdate prop changes', () => { + const detail = createPluginDetail() + const onUpdate1 = vi.fn() + const onUpdate2 = vi.fn() + const onHide = vi.fn() + + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByTestId('header-update-btn')) + expect(onUpdate1).toHaveBeenCalledTimes(1) + + rerender( + , + ) + + fireEvent.click(screen.getByTestId('header-update-btn')) + expect(onUpdate2).toHaveBeenCalledTimes(1) + }) + + it('should update handleUpdate when onHide prop changes', () => { + const detail = createPluginDetail() + const onUpdate = vi.fn() + const onHide1 = vi.fn() + const onHide2 = vi.fn() + + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByTestId('header-delete-btn')) + expect(onHide1).toHaveBeenCalledTimes(1) + + rerender( + , + ) + + onUpdate.mockClear() + fireEvent.click(screen.getByTestId('header-delete-btn')) + expect(onHide2).toHaveBeenCalledTimes(1) + }) + }) + + describe('User Interactions and Event Handlers', () => { + it('should call onUpdate when update button is clicked', () => { + const detail = createPluginDetail() + + render( + , + ) + + fireEvent.click(screen.getByTestId('header-update-btn')) + + expect(mockOnUpdate).toHaveBeenCalledTimes(1) + expect(mockOnHide).not.toHaveBeenCalled() + }) + + it('should call onHide and onUpdate when delete is triggered', () => { + const detail = createPluginDetail() + + render( + , + ) + + fireEvent.click(screen.getByTestId('header-delete-btn')) + + expect(mockOnHide).toHaveBeenCalledTimes(1) + expect(mockOnUpdate).toHaveBeenCalledTimes(1) + }) + + it('should call onHide before onUpdate when isDelete is true', () => { + const callOrder: string[] = [] + const onUpdate = vi.fn(() => callOrder.push('update')) + const onHide = vi.fn(() => callOrder.push('hide')) + + const detail = createPluginDetail() + + render( + , + ) + + fireEvent.click(screen.getByTestId('header-delete-btn')) + + expect(callOrder).toEqual(['hide', 'update']) + }) + + it('should call only onUpdate when isDelete is false', () => { + const detail = createPluginDetail() + + render( + , + ) + + fireEvent.click(screen.getByTestId('header-update-btn')) + + expect(mockOnUpdate).toHaveBeenCalledTimes(1) + expect(mockOnHide).not.toHaveBeenCalled() + }) + + it('should call onHide when hide button is clicked', () => { + const detail = createPluginDetail() + + render( + , + ) + + fireEvent.click(screen.getByTestId('header-hide-btn')) + + expect(mockOnHide).toHaveBeenCalledTimes(1) + }) + + it('should call onHide when drawer close is triggered', () => { + const detail = createPluginDetail() + + render( + , + ) + + // Click the hide button in the header to close the drawer + fireEvent.click(screen.getByTestId('header-hide-btn')) + + expect(mockOnHide).toHaveBeenCalledTimes(1) + }) + }) + + describe('Edge Cases and Error Handling', () => { + it('should handle plugin with empty declaration name gracefully', () => { + const detail = createPluginDetail({ + declaration: { + ...createPluginDetail().declaration, + name: '', + }, + }) + + render( + , + ) + + expect(mockSetDetail).toHaveBeenCalledWith(expect.objectContaining({ + provider: expect.stringContaining('/'), + })) + }) + + it('should handle plugin with empty plugin_unique_identifier', () => { + const detail = createPluginDetail({ + plugin_unique_identifier: '', + }) + + render( + , + ) + + expect(mockSetDetail).toHaveBeenCalledWith(expect.objectContaining({ + plugin_unique_identifier: '', + })) + }) + + it('should handle plugin with undefined plugin_unique_identifier', () => { + const detail = createPluginDetail({ + plugin_unique_identifier: undefined as unknown as string, + }) + + render( + , + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should handle plugin without tool, model, endpoint, agent_strategy, or datasource', () => { + const emptyDeclaration = { + ...createPluginDetail().declaration, + tool: undefined, + model: undefined, + endpoint: undefined, + agent_strategy: undefined, + datasource: undefined, + category: PluginCategoryEnum.extension, + } as unknown as PluginDeclaration + + const detail = createPluginDetail({ + declaration: emptyDeclaration, + }) + + render( + , + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.queryByTestId('action-list')).not.toBeInTheDocument() + expect(screen.queryByTestId('model-list')).not.toBeInTheDocument() + expect(screen.queryByTestId('endpoint-list')).not.toBeInTheDocument() + expect(screen.queryByTestId('agent-strategy-list')).not.toBeInTheDocument() + expect(screen.queryByTestId('datasource-action-list')).not.toBeInTheDocument() + }) + + it('should handle rapid prop changes without errors', () => { + const detail1 = createPluginDetail({ plugin_id: 'plugin-1' }) + const detail2 = createPluginDetail({ plugin_id: 'plugin-2' }) + const detail3 = createPluginDetail({ plugin_id: 'plugin-3' }) + + const { rerender } = render( + , + ) + + act(() => { + rerender( + , + ) + }) + + act(() => { + rerender( + , + ) + }) + + expect(mockSetDetail).toHaveBeenCalledTimes(3) + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should handle toggle between defined and undefined detail', () => { + const detail = createPluginDetail() + + const { rerender, container } = render( + , + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + + rerender( + , + ) + + expect(container).toBeEmptyDOMElement() + + rerender( + , + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + }) + + describe('Props Variations', () => { + it('should pass correct props to DetailHeader', () => { + const detail = createPluginDetail({ name: 'Custom Plugin Name' }) + + render( + , + ) + + expect(screen.getByTestId('header-title')).toHaveTextContent('Custom Plugin Name') + }) + + it('should handle different plugin sources', () => { + const sources: PluginSource[] = [ + PluginSource.marketplace, + PluginSource.github, + PluginSource.local, + PluginSource.debugging, + ] + + sources.forEach((source) => { + const detail = createPluginDetail({ source }) + const { unmount } = render( + , + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + unmount() + }) + }) + + it('should handle different plugin statuses', () => { + const statuses: Array<'active' | 'deleted'> = ['active', 'deleted'] + + statuses.forEach((status) => { + const detail = createPluginDetail({ status }) + const { unmount } = render( + , + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + unmount() + }) + }) + + it('should handle plugin with deprecated_reason', () => { + const detail = createPluginDetail({ + deprecated_reason: 'This plugin is deprecated', + alternative_plugin_id: 'alternative-plugin', + }) + + render( + , + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should handle plugin with meta data for github source', () => { + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { + repo: 'owner/repo-name', + version: 'v1.2.3', + package: 'package.difypkg', + }, + }) + + render( + , + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should handle plugin with different versions', () => { + const detail = createPluginDetail({ + version: '1.0.0', + latest_version: '2.0.0', + latest_unique_identifier: 'new-uid', + }) + + render( + , + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should pass pluginDetail to SubscriptionList for trigger plugins', () => { + const detail = createTriggerPluginDetail({ plugin_id: 'trigger-plugin-123' }) + + render( + , + ) + + expect(screen.getByTestId('subscription-list-plugin-id')).toHaveTextContent('trigger-plugin-123') + }) + + it('should pass detail to ActionList for tool plugins', () => { + const detail = createPluginDetail({ plugin_id: 'tool-plugin-456' }) + + render( + , + ) + + expect(screen.getByTestId('action-list-plugin-id')).toHaveTextContent('tool-plugin-456') + }) + }) + + describe('Store Integration', () => { + it('should construct provider correctly from plugin_id and declaration.name', () => { + const detail = createPluginDetail({ + plugin_id: 'my-org/my-plugin', + declaration: { + ...createPluginDetail().declaration, + name: 'my-tool-name', + }, + }) + + render( + , + ) + + expect(mockSetDetail).toHaveBeenCalledWith(expect.objectContaining({ + provider: 'my-org/my-plugin/my-tool-name', + })) + }) + + it('should include all required fields in setDetail payload', () => { + const detail = createPluginDetail() + + render( + , + ) + + expect(mockSetDetail).toHaveBeenCalledWith({ + plugin_id: detail.plugin_id, + provider: expect.any(String), + plugin_unique_identifier: detail.plugin_unique_identifier, + declaration: detail.declaration, + name: detail.name, + id: detail.id, + }) + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/model-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-list.spec.tsx new file mode 100644 index 0000000000..2283ad0c43 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/model-list.spec.tsx @@ -0,0 +1,103 @@ +import type { PluginDetail } from '@/app/components/plugins/types' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ModelList from './model-list' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (options?.num !== undefined) + return `${options.num} models` + return key + }, + }), +})) + +const mockModels = [ + { model: 'gpt-4', provider: 'openai' }, + { model: 'gpt-3.5', provider: 'openai' }, +] + +let mockModelListResponse: { data: typeof mockModels } | undefined + +vi.mock('@/service/use-models', () => ({ + useModelProviderModelList: () => ({ + data: mockModelListResponse, + }), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-icon', () => ({ + default: ({ modelName }: { modelName: string }) => ( + {modelName} + ), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-name', () => ({ + default: ({ modelItem }: { modelItem: { model: string } }) => ( + {modelItem.model} + ), +})) + +const createPluginDetail = (): PluginDetail => ({ + id: 'test-id', + created_at: '2024-01-01', + updated_at: '2024-01-02', + name: 'Test Plugin', + plugin_id: 'test-plugin', + plugin_unique_identifier: 'test-uid', + declaration: { + model: { provider: 'openai' }, + } as PluginDetail['declaration'], + installation_id: 'install-1', + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '1.0.0', + latest_unique_identifier: 'test-uid', + source: 'marketplace' as PluginDetail['source'], + meta: undefined, + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', +}) + +describe('ModelList', () => { + beforeEach(() => { + vi.clearAllMocks() + mockModelListResponse = { data: mockModels } + }) + + describe('Rendering', () => { + it('should render model list when data is available', () => { + render() + + expect(screen.getByText('2 models')).toBeInTheDocument() + }) + + it('should render model icons and names', () => { + render() + + expect(screen.getAllByTestId('model-icon')).toHaveLength(2) + expect(screen.getAllByTestId('model-name')).toHaveLength(2) + // Both icon and name show the model name, so use getAllByText + expect(screen.getAllByText('gpt-4')).toHaveLength(2) + expect(screen.getAllByText('gpt-3.5')).toHaveLength(2) + }) + + it('should return null when no data', () => { + mockModelListResponse = undefined + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + + it('should handle empty model list', () => { + mockModelListResponse = { data: [] } + render() + + expect(screen.getByText('0 models')).toBeInTheDocument() + expect(screen.queryByTestId('model-icon')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/operation-dropdown.spec.tsx b/web/app/components/plugins/plugin-detail-panel/operation-dropdown.spec.tsx new file mode 100644 index 0000000000..5501526b12 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/operation-dropdown.spec.tsx @@ -0,0 +1,215 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginSource } from '../types' +import OperationDropdown from './operation-dropdown' + +// Mock dependencies +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => T): T => + selector({ systemFeatures: { enable_marketplace: true } }), +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '), +})) + +vi.mock('@/app/components/base/action-button', () => ({ + default: ({ children, className, onClick }: { children: React.ReactNode, className?: string, onClick?: () => void }) => ( + + ), +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( +
{children}
+ ), + PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( +
{children}
+ ), + PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => ( +
{children}
+ ), +})) + +describe('OperationDropdown', () => { + const mockOnInfo = vi.fn() + const mockOnCheckVersion = vi.fn() + const mockOnRemove = vi.fn() + const defaultProps = { + source: PluginSource.github, + detailUrl: 'https://github.com/test/repo', + onInfo: mockOnInfo, + onCheckVersion: mockOnCheckVersion, + onRemove: mockOnRemove, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render trigger button', () => { + render() + + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + expect(screen.getByTestId('action-button')).toBeInTheDocument() + }) + + it('should render dropdown content', () => { + render() + + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + + it('should render info option for github source', () => { + render() + + expect(screen.getByText('detailPanel.operation.info')).toBeInTheDocument() + }) + + it('should render check update option for github source', () => { + render() + + expect(screen.getByText('detailPanel.operation.checkUpdate')).toBeInTheDocument() + }) + + it('should render view detail option for github source with marketplace enabled', () => { + render() + + expect(screen.getByText('detailPanel.operation.viewDetail')).toBeInTheDocument() + }) + + it('should render view detail option for marketplace source', () => { + render() + + expect(screen.getByText('detailPanel.operation.viewDetail')).toBeInTheDocument() + }) + + it('should always render remove option', () => { + render() + + expect(screen.getByText('detailPanel.operation.remove')).toBeInTheDocument() + }) + + it('should not render info option for marketplace source', () => { + render() + + expect(screen.queryByText('detailPanel.operation.info')).not.toBeInTheDocument() + }) + + it('should not render check update option for marketplace source', () => { + render() + + expect(screen.queryByText('detailPanel.operation.checkUpdate')).not.toBeInTheDocument() + }) + + it('should not render view detail for local source', () => { + render() + + expect(screen.queryByText('detailPanel.operation.viewDetail')).not.toBeInTheDocument() + }) + + it('should not render view detail for debugging source', () => { + render() + + expect(screen.queryByText('detailPanel.operation.viewDetail')).not.toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should toggle dropdown when trigger is clicked', () => { + render() + + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + // The portal-elem should reflect the open state + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should call onInfo when info option is clicked', () => { + render() + + fireEvent.click(screen.getByText('detailPanel.operation.info')) + + expect(mockOnInfo).toHaveBeenCalledTimes(1) + }) + + it('should call onCheckVersion when check update option is clicked', () => { + render() + + fireEvent.click(screen.getByText('detailPanel.operation.checkUpdate')) + + expect(mockOnCheckVersion).toHaveBeenCalledTimes(1) + }) + + it('should call onRemove when remove option is clicked', () => { + render() + + fireEvent.click(screen.getByText('detailPanel.operation.remove')) + + expect(mockOnRemove).toHaveBeenCalledTimes(1) + }) + + it('should have correct href for view detail link', () => { + render() + + const link = screen.getByText('detailPanel.operation.viewDetail').closest('a') + expect(link).toHaveAttribute('href', 'https://github.com/test/repo') + expect(link).toHaveAttribute('target', '_blank') + }) + }) + + describe('Props Variations', () => { + it('should handle all plugin sources', () => { + const sources = [ + PluginSource.github, + PluginSource.marketplace, + PluginSource.local, + PluginSource.debugging, + ] + + sources.forEach((source) => { + const { unmount } = render( + , + ) + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByText('detailPanel.operation.remove')).toBeInTheDocument() + unmount() + }) + }) + + it('should handle different detail URLs', () => { + const urls = [ + 'https://github.com/owner/repo', + 'https://marketplace.example.com/plugin/123', + ] + + urls.forEach((url) => { + const { unmount } = render( + , + ) + const link = screen.getByText('detailPanel.operation.viewDetail').closest('a') + expect(link).toHaveAttribute('href', url) + unmount() + }) + }) + }) + + describe('Memoization', () => { + it('should be wrapped with React.memo', () => { + // Verify the component is exported as a memo component + expect(OperationDropdown).toBeDefined() + // React.memo wraps the component, so it should have $$typeof + expect((OperationDropdown as { $$typeof?: symbol }).$$typeof).toBeDefined() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/store.spec.ts b/web/app/components/plugins/plugin-detail-panel/store.spec.ts new file mode 100644 index 0000000000..4116bb9790 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/store.spec.ts @@ -0,0 +1,461 @@ +import type { SimpleDetail } from './store' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import { usePluginStore } from './store' + +// Factory function to create mock SimpleDetail +const createSimpleDetail = (overrides: Partial = {}): SimpleDetail => ({ + plugin_id: 'test-plugin-id', + name: 'Test Plugin', + plugin_unique_identifier: 'test-plugin-uid', + id: 'test-id', + provider: 'test-provider', + declaration: { + category: 'tool' as SimpleDetail['declaration']['category'], + name: 'test-declaration', + }, + ...overrides, +}) + +describe('usePluginStore', () => { + beforeEach(() => { + // Reset store state before each test + const { result } = renderHook(() => usePluginStore()) + act(() => { + result.current.setDetail(undefined) + }) + }) + + describe('Initial State', () => { + it('should have undefined detail initially', () => { + const { result } = renderHook(() => usePluginStore()) + + expect(result.current.detail).toBeUndefined() + }) + + it('should provide setDetail function', () => { + const { result } = renderHook(() => usePluginStore()) + + expect(typeof result.current.setDetail).toBe('function') + }) + }) + + describe('setDetail', () => { + it('should set detail with valid SimpleDetail', () => { + const { result } = renderHook(() => usePluginStore()) + const detail = createSimpleDetail() + + act(() => { + result.current.setDetail(detail) + }) + + expect(result.current.detail).toEqual(detail) + }) + + it('should set detail to undefined', () => { + const { result } = renderHook(() => usePluginStore()) + const detail = createSimpleDetail() + + // First set a value + act(() => { + result.current.setDetail(detail) + }) + expect(result.current.detail).toEqual(detail) + + // Then clear it + act(() => { + result.current.setDetail(undefined) + }) + expect(result.current.detail).toBeUndefined() + }) + + it('should update detail when called multiple times', () => { + const { result } = renderHook(() => usePluginStore()) + const detail1 = createSimpleDetail({ plugin_id: 'plugin-1' }) + const detail2 = createSimpleDetail({ plugin_id: 'plugin-2' }) + + act(() => { + result.current.setDetail(detail1) + }) + expect(result.current.detail?.plugin_id).toBe('plugin-1') + + act(() => { + result.current.setDetail(detail2) + }) + expect(result.current.detail?.plugin_id).toBe('plugin-2') + }) + + it('should handle detail with trigger declaration', () => { + const { result } = renderHook(() => usePluginStore()) + const detail = createSimpleDetail({ + declaration: { + trigger: { + subscription_schema: [], + subscription_constructor: null, + }, + }, + }) + + act(() => { + result.current.setDetail(detail) + }) + + expect(result.current.detail?.declaration.trigger).toEqual({ + subscription_schema: [], + subscription_constructor: null, + }) + }) + + it('should handle detail with partial declaration', () => { + const { result } = renderHook(() => usePluginStore()) + const detail = createSimpleDetail({ + declaration: { + name: 'partial-plugin', + }, + }) + + act(() => { + result.current.setDetail(detail) + }) + + expect(result.current.detail?.declaration.name).toBe('partial-plugin') + }) + }) + + describe('Store Sharing', () => { + it('should share state across multiple hook instances', () => { + const { result: result1 } = renderHook(() => usePluginStore()) + const { result: result2 } = renderHook(() => usePluginStore()) + const detail = createSimpleDetail() + + act(() => { + result1.current.setDetail(detail) + }) + + // Both hooks should see the same state + expect(result1.current.detail).toEqual(detail) + expect(result2.current.detail).toEqual(detail) + }) + + it('should update all hook instances when state changes', () => { + const { result: result1 } = renderHook(() => usePluginStore()) + const { result: result2 } = renderHook(() => usePluginStore()) + const detail1 = createSimpleDetail({ name: 'Plugin One' }) + const detail2 = createSimpleDetail({ name: 'Plugin Two' }) + + act(() => { + result1.current.setDetail(detail1) + }) + + expect(result1.current.detail?.name).toBe('Plugin One') + expect(result2.current.detail?.name).toBe('Plugin One') + + act(() => { + result2.current.setDetail(detail2) + }) + + expect(result1.current.detail?.name).toBe('Plugin Two') + expect(result2.current.detail?.name).toBe('Plugin Two') + }) + }) + + describe('Selector Pattern', () => { + // Extract selectors to reduce nesting depth + const selectDetail = (state: ReturnType) => state.detail + const selectSetDetail = (state: ReturnType) => state.setDetail + + it('should support selector to get specific field', () => { + const { result: setterResult } = renderHook(() => usePluginStore()) + const detail = createSimpleDetail({ plugin_id: 'selected-plugin' }) + + act(() => { + setterResult.current.setDetail(detail) + }) + + // Use selector to get only detail + const { result: selectorResult } = renderHook(() => usePluginStore(selectDetail)) + + expect(selectorResult.current?.plugin_id).toBe('selected-plugin') + }) + + it('should support selector to get setDetail function', () => { + const { result } = renderHook(() => usePluginStore(selectSetDetail)) + + expect(typeof result.current).toBe('function') + }) + }) + + describe('Edge Cases', () => { + it('should handle empty string values in detail', () => { + const { result } = renderHook(() => usePluginStore()) + const detail = createSimpleDetail({ + plugin_id: '', + name: '', + plugin_unique_identifier: '', + provider: '', + }) + + act(() => { + result.current.setDetail(detail) + }) + + expect(result.current.detail?.plugin_id).toBe('') + expect(result.current.detail?.name).toBe('') + }) + + it('should handle detail with empty declaration', () => { + const { result } = renderHook(() => usePluginStore()) + const detail = createSimpleDetail({ + declaration: {}, + }) + + act(() => { + result.current.setDetail(detail) + }) + + expect(result.current.detail?.declaration).toEqual({}) + }) + + it('should handle rapid state updates', () => { + const { result } = renderHook(() => usePluginStore()) + + act(() => { + for (let i = 0; i < 10; i++) + result.current.setDetail(createSimpleDetail({ plugin_id: `plugin-${i}` })) + }) + + expect(result.current.detail?.plugin_id).toBe('plugin-9') + }) + + it('should handle setDetail called without arguments', () => { + const { result } = renderHook(() => usePluginStore()) + const detail = createSimpleDetail() + + act(() => { + result.current.setDetail(detail) + }) + expect(result.current.detail).toBeDefined() + + act(() => { + result.current.setDetail() + }) + expect(result.current.detail).toBeUndefined() + }) + }) + + describe('Type Safety', () => { + it('should preserve all SimpleDetail fields correctly', () => { + const { result } = renderHook(() => usePluginStore()) + const detail: SimpleDetail = { + plugin_id: 'type-test-id', + name: 'Type Test Plugin', + plugin_unique_identifier: 'type-test-uid', + id: 'type-id', + provider: 'type-provider', + declaration: { + category: 'model' as SimpleDetail['declaration']['category'], + name: 'type-declaration', + version: '2.0.0', + author: 'test-author', + }, + } + + act(() => { + result.current.setDetail(detail) + }) + + expect(result.current.detail).toStrictEqual(detail) + expect(result.current.detail?.plugin_id).toBe('type-test-id') + expect(result.current.detail?.name).toBe('Type Test Plugin') + expect(result.current.detail?.plugin_unique_identifier).toBe('type-test-uid') + expect(result.current.detail?.id).toBe('type-id') + expect(result.current.detail?.provider).toBe('type-provider') + }) + + it('should handle declaration with subscription_constructor', () => { + const { result } = renderHook(() => usePluginStore()) + const mockConstructor = { + credentials_schema: [], + oauth_schema: { + client_schema: [], + credentials_schema: [], + }, + parameters: [], + } + + const detail = createSimpleDetail({ + declaration: { + trigger: { + subscription_schema: [], + subscription_constructor: mockConstructor as unknown as NonNullable['subscription_constructor'], + }, + }, + }) + + act(() => { + result.current.setDetail(detail) + }) + + expect(result.current.detail?.declaration.trigger?.subscription_constructor).toBeDefined() + }) + + it('should handle declaration with subscription_schema', () => { + const { result } = renderHook(() => usePluginStore()) + + const detail = createSimpleDetail({ + declaration: { + trigger: { + subscription_schema: [], + subscription_constructor: null, + }, + }, + }) + + act(() => { + result.current.setDetail(detail) + }) + + expect(result.current.detail?.declaration.trigger?.subscription_schema).toEqual([]) + }) + }) + + describe('State Persistence', () => { + it('should maintain state after multiple renders', () => { + const detail = createSimpleDetail({ name: 'Persistent Plugin' }) + + const { result, rerender } = renderHook(() => usePluginStore()) + + act(() => { + result.current.setDetail(detail) + }) + + // Rerender multiple times + rerender() + rerender() + rerender() + + expect(result.current.detail?.name).toBe('Persistent Plugin') + }) + + it('should maintain reference equality for unchanged state', () => { + const { result } = renderHook(() => usePluginStore()) + const detail = createSimpleDetail() + + act(() => { + result.current.setDetail(detail) + }) + + const firstDetailRef = result.current.detail + + // Get state again without changing + const { result: result2 } = renderHook(() => usePluginStore()) + + expect(result2.current.detail).toBe(firstDetailRef) + }) + }) + + describe('Concurrent Updates', () => { + it('should handle updates from multiple sources correctly', () => { + const { result: hook1 } = renderHook(() => usePluginStore()) + const { result: hook2 } = renderHook(() => usePluginStore()) + const { result: hook3 } = renderHook(() => usePluginStore()) + + act(() => { + hook1.current.setDetail(createSimpleDetail({ name: 'From Hook 1' })) + }) + + act(() => { + hook2.current.setDetail(createSimpleDetail({ name: 'From Hook 2' })) + }) + + act(() => { + hook3.current.setDetail(createSimpleDetail({ name: 'From Hook 3' })) + }) + + // All hooks should reflect the last update + expect(hook1.current.detail?.name).toBe('From Hook 3') + expect(hook2.current.detail?.name).toBe('From Hook 3') + expect(hook3.current.detail?.name).toBe('From Hook 3') + }) + + it('should handle interleaved read and write operations', () => { + const { result } = renderHook(() => usePluginStore()) + + act(() => { + result.current.setDetail(createSimpleDetail({ plugin_id: 'step-1' })) + }) + expect(result.current.detail?.plugin_id).toBe('step-1') + + act(() => { + result.current.setDetail(createSimpleDetail({ plugin_id: 'step-2' })) + }) + expect(result.current.detail?.plugin_id).toBe('step-2') + + act(() => { + result.current.setDetail(undefined) + }) + expect(result.current.detail).toBeUndefined() + + act(() => { + result.current.setDetail(createSimpleDetail({ plugin_id: 'step-3' })) + }) + expect(result.current.detail?.plugin_id).toBe('step-3') + }) + }) + + describe('Declaration Variations', () => { + it('should handle declaration with all optional fields', () => { + const { result } = renderHook(() => usePluginStore()) + const detail = createSimpleDetail({ + declaration: { + category: 'extension' as SimpleDetail['declaration']['category'], + name: 'full-declaration', + version: '1.0.0', + author: 'full-author', + icon: 'icon.png', + verified: true, + tags: ['tag1', 'tag2'], + }, + }) + + act(() => { + result.current.setDetail(detail) + }) + + const decl = result.current.detail?.declaration + expect(decl?.category).toBe('extension') + expect(decl?.name).toBe('full-declaration') + expect(decl?.version).toBe('1.0.0') + expect(decl?.author).toBe('full-author') + expect(decl?.icon).toBe('icon.png') + expect(decl?.verified).toBe(true) + expect(decl?.tags).toEqual(['tag1', 'tag2']) + }) + + it('should handle declaration with nested tool object', () => { + const { result } = renderHook(() => usePluginStore()) + const mockTool = { + identity: { + author: 'tool-author', + name: 'tool-name', + icon: 'tool-icon.png', + tags: ['api', 'utility'], + }, + credentials_schema: [], + } + + const detail = createSimpleDetail({ + declaration: { + tool: mockTool as unknown as SimpleDetail['declaration']['tool'], + }, + }) + + act(() => { + result.current.setDetail(detail) + }) + + expect(result.current.detail?.declaration.tool?.identity.name).toBe('tool-name') + expect(result.current.detail?.declaration.tool?.identity.tags).toEqual(['api', 'utility']) + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/strategy-detail.spec.tsx b/web/app/components/plugins/plugin-detail-panel/strategy-detail.spec.tsx new file mode 100644 index 0000000000..32ae6ff735 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/strategy-detail.spec.tsx @@ -0,0 +1,203 @@ +import type { StrategyDetail as StrategyDetailType } from '@/app/components/plugins/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import StrategyDetail from './strategy-detail' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/hooks/use-i18n', () => ({ + useRenderI18nObject: () => (obj: Record) => obj?.en_US || '', +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '), +})) + +vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ + default: () => , +})) + +vi.mock('@/app/components/plugins/card/base/description', () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})) + +type ProviderType = Parameters[0]['provider'] + +const mockProvider = { + author: 'test-author', + name: 'test-provider', + description: { en_US: 'Provider desc' }, + tenant_id: 'tenant-1', + icon: 'icon.png', + label: { en_US: 'Test Provider' }, + tags: [], +} as unknown as ProviderType + +const mockDetail = { + identity: { + author: 'author-1', + name: 'strategy-1', + icon: 'icon.png', + label: { en_US: 'Strategy Label' }, + provider: 'provider-1', + }, + parameters: [ + { + name: 'param1', + label: { en_US: 'Parameter 1' }, + type: 'text-input', + required: true, + human_description: { en_US: 'A text parameter' }, + }, + ], + description: { en_US: 'Strategy description' }, + output_schema: { + properties: { + result: { type: 'string', description: 'Result output' }, + items: { type: 'array', items: { type: 'string' }, description: 'Array items' }, + }, + }, + features: [], +} as unknown as StrategyDetailType + +describe('StrategyDetail', () => { + const mockOnHide = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render drawer', () => { + render() + + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should render provider label', () => { + render() + + expect(screen.getByText('Test Provider')).toBeInTheDocument() + }) + + it('should render strategy label', () => { + render() + + expect(screen.getByText('Strategy Label')).toBeInTheDocument() + }) + + it('should render parameters section', () => { + render() + + expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument() + expect(screen.getByText('Parameter 1')).toBeInTheDocument() + }) + + it('should render output schema section', () => { + render() + + expect(screen.getByText('OUTPUT')).toBeInTheDocument() + expect(screen.getByText('result')).toBeInTheDocument() + expect(screen.getByText('String')).toBeInTheDocument() + }) + + it('should render BACK button', () => { + render() + + expect(screen.getByText('BACK')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onHide when close button clicked', () => { + render() + + // Find the close button (ActionButton with action-btn class) + const closeButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn')) + if (closeButton) + fireEvent.click(closeButton) + + expect(mockOnHide).toHaveBeenCalledTimes(1) + }) + + it('should call onHide when BACK clicked', () => { + render() + + fireEvent.click(screen.getByText('BACK')) + + expect(mockOnHide).toHaveBeenCalledTimes(1) + }) + }) + + describe('Parameter Types', () => { + it('should display correct type for number-input', () => { + const detailWithNumber = { + ...mockDetail, + parameters: [{ ...mockDetail.parameters[0], type: 'number-input' }], + } + render() + + expect(screen.getByText('setBuiltInTools.number')).toBeInTheDocument() + }) + + it('should display correct type for checkbox', () => { + const detailWithCheckbox = { + ...mockDetail, + parameters: [{ ...mockDetail.parameters[0], type: 'checkbox' }], + } + render() + + expect(screen.getByText('boolean')).toBeInTheDocument() + }) + + it('should display correct type for file', () => { + const detailWithFile = { + ...mockDetail, + parameters: [{ ...mockDetail.parameters[0], type: 'file' }], + } + render() + + expect(screen.getByText('setBuiltInTools.file')).toBeInTheDocument() + }) + + it('should display correct type for array[tools]', () => { + const detailWithArrayTools = { + ...mockDetail, + parameters: [{ ...mockDetail.parameters[0], type: 'array[tools]' }], + } + render() + + expect(screen.getByText('multiple-tool-select')).toBeInTheDocument() + }) + + it('should display original type for unknown types', () => { + const detailWithUnknown = { + ...mockDetail, + parameters: [{ ...mockDetail.parameters[0], type: 'custom-type' }], + } + render() + + expect(screen.getByText('custom-type')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty parameters', () => { + const detailEmpty = { ...mockDetail, parameters: [] } + render() + + expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument() + }) + + it('should handle no output schema', () => { + const detailNoOutput = { ...mockDetail, output_schema: undefined as unknown as Record } + render() + + expect(screen.queryByText('OUTPUT')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/strategy-item.spec.tsx b/web/app/components/plugins/plugin-detail-panel/strategy-item.spec.tsx new file mode 100644 index 0000000000..fde2f82965 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/strategy-item.spec.tsx @@ -0,0 +1,102 @@ +import type { StrategyDetail } from '@/app/components/plugins/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import StrategyItem from './strategy-item' + +vi.mock('@/hooks/use-i18n', () => ({ + useRenderI18nObject: () => (obj: Record) => obj?.en_US || '', +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '), +})) + +vi.mock('./strategy-detail', () => ({ + default: ({ onHide }: { onHide: () => void }) => ( +
+ +
+ ), +})) + +const mockProvider = { + author: 'test-author', + name: 'test-provider', + description: { en_US: 'Provider desc' } as Record, + tenant_id: 'tenant-1', + icon: 'icon.png', + label: { en_US: 'Test Provider' } as Record, + tags: [] as string[], +} + +const mockDetail = { + identity: { + author: 'author-1', + name: 'strategy-1', + icon: 'icon.png', + label: { en_US: 'Strategy Label' } as Record, + provider: 'provider-1', + }, + parameters: [], + description: { en_US: 'Strategy description' } as Record, + output_schema: {}, + features: [], +} as StrategyDetail + +describe('StrategyItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render strategy label', () => { + render() + + expect(screen.getByText('Strategy Label')).toBeInTheDocument() + }) + + it('should render strategy description', () => { + render() + + expect(screen.getByText('Strategy description')).toBeInTheDocument() + }) + + it('should not show detail panel initially', () => { + render() + + expect(screen.queryByTestId('strategy-detail-panel')).not.toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should show detail panel when clicked', () => { + render() + + fireEvent.click(screen.getByText('Strategy Label')) + + expect(screen.getByTestId('strategy-detail-panel')).toBeInTheDocument() + }) + + it('should hide detail panel when hide is called', () => { + render() + + fireEvent.click(screen.getByText('Strategy Label')) + expect(screen.getByTestId('strategy-detail-panel')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('hide-btn')) + expect(screen.queryByTestId('strategy-detail-panel')).not.toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should handle empty description', () => { + const detailWithEmptyDesc = { + ...mockDetail, + description: { en_US: '' } as Record, + } as StrategyDetail + render() + + expect(screen.getByText('Strategy Label')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx index c87fc1e4da..543d3deebc 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx @@ -1874,4 +1874,187 @@ describe('CommonCreateModal', () => { expect(screen.getByTestId('modal')).toHaveAttribute('data-disabled', 'true') }) }) + + describe('normalizeFormType Additional Branches', () => { + it('should handle "text" type by returning textInput', () => { + const detailWithText = createMockPluginDetail({ + declaration: { + trigger: { + subscription_constructor: { + credentials_schema: [], + parameters: [ + { name: 'text_type_field', type: 'text' }, + ], + }, + }, + }, + }) + mockUsePluginStore.mockReturnValue(detailWithText) + + const builder = createMockSubscriptionBuilder() + render() + + expect(screen.getByTestId('form-field-text_type_field')).toBeInTheDocument() + }) + + it('should handle "secret" type by returning secretInput', () => { + const detailWithSecret = createMockPluginDetail({ + declaration: { + trigger: { + subscription_constructor: { + credentials_schema: [], + parameters: [ + { name: 'secret_type_field', type: 'secret' }, + ], + }, + }, + }, + }) + mockUsePluginStore.mockReturnValue(detailWithSecret) + + const builder = createMockSubscriptionBuilder() + render() + + expect(screen.getByTestId('form-field-secret_type_field')).toBeInTheDocument() + }) + }) + + describe('HandleManualPropertiesChange Provider Fallback', () => { + it('should not call updateBuilder when provider is empty', async () => { + const detailWithEmptyProvider = createMockPluginDetail({ + provider: '', + declaration: { + trigger: { + subscription_schema: [ + { name: 'webhook_url', type: 'text', required: true }, + ], + subscription_constructor: { + credentials_schema: [], + parameters: [], + }, + }, + }, + }) + mockUsePluginStore.mockReturnValue(detailWithEmptyProvider) + + render() + + const input = screen.getByTestId('form-field-webhook_url') + fireEvent.change(input, { target: { value: 'https://example.com/webhook' } }) + + // updateBuilder should not be called when provider is empty + expect(mockUpdateBuilder).not.toHaveBeenCalled() + }) + }) + + describe('Configuration Step Without Endpoint', () => { + it('should handle builder without endpoint', async () => { + const builderWithoutEndpoint = createMockSubscriptionBuilder({ + endpoint: '', + }) + + render() + + // Component should render without errors + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + }) + + describe('ApiKeyStep Flow Additional Coverage', () => { + it('should handle verify when no builder created yet', async () => { + const detailWithCredentials = createMockPluginDetail({ + declaration: { + trigger: { + subscription_constructor: { + credentials_schema: [ + { name: 'api_key', type: 'secret', required: true }, + ], + }, + }, + }, + }) + mockUsePluginStore.mockReturnValue(detailWithCredentials) + + // Make createBuilder slow + mockCreateBuilder.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000))) + + render() + + // Click verify before builder is created + fireEvent.click(screen.getByTestId('modal-confirm')) + + // Should still attempt to verify + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + }) + + describe('Auto Parameters Not For APIKEY in Configuration', () => { + it('should include parameters for APIKEY in configuration step', async () => { + const detailWithParams = createMockPluginDetail({ + declaration: { + trigger: { + subscription_constructor: { + credentials_schema: [ + { name: 'api_key', type: 'secret', required: true }, + ], + parameters: [ + { name: 'extra_param', type: 'string', required: true }, + ], + }, + }, + }, + }) + mockUsePluginStore.mockReturnValue(detailWithParams) + + // First verify credentials + mockVerifyCredentials.mockImplementation((params, { onSuccess }) => { + onSuccess() + }) + + const builder = createMockSubscriptionBuilder() + render() + + // Click verify + fireEvent.click(screen.getByTestId('modal-confirm')) + + await waitFor(() => { + expect(mockVerifyCredentials).toHaveBeenCalled() + }) + + // Now in configuration step, should see extra_param + expect(screen.getByTestId('form-field-extra_param')).toBeInTheDocument() + }) + }) + + describe('needCheckValidatedValues Option', () => { + it('should pass needCheckValidatedValues: false for manual properties', async () => { + const detailWithManualSchema = createMockPluginDetail({ + declaration: { + trigger: { + subscription_schema: [ + { name: 'webhook_url', type: 'text', required: true }, + ], + subscription_constructor: { + credentials_schema: [], + parameters: [], + }, + }, + }, + }) + mockUsePluginStore.mockReturnValue(detailWithManualSchema) + + render() + + await waitFor(() => { + expect(mockCreateBuilder).toHaveBeenCalled() + }) + + const input = screen.getByTestId('form-field-webhook_url') + fireEvent.change(input, { target: { value: 'test' } }) + + await waitFor(() => { + expect(mockUpdateBuilder).toHaveBeenCalled() + }) + }) + }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx index 0a23062717..0ad6bc364e 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx @@ -1475,4 +1475,213 @@ describe('CreateSubscriptionButton', () => { }) }) }) + + // ==================== OAuth Callback Edge Cases ==================== + describe('OAuth Callback - Falsy Data', () => { + it('should not open modal when OAuth callback returns falsy data', async () => { + // Arrange + const { openOAuthPopup } = await import('@/hooks/use-oauth') + vi.mocked(openOAuthPopup).mockImplementation((url: string, callback: (data?: unknown) => void) => { + callback(undefined) // falsy callback data + return null + }) + + const mockBuilder: TriggerSubscriptionBuilder = { + id: 'oauth-builder', + name: 'OAuth Builder', + provider: 'test-provider', + credential_type: TriggerCredentialTypeEnum.Oauth2, + credentials: {}, + endpoint: 'https://test.com', + parameters: {}, + properties: {}, + workflows_in_use: 0, + } + + mockInitiateOAuth.mockImplementation((_provider: string, callbacks: { onSuccess: (response: { authorization_url: string, subscription_builder: TriggerSubscriptionBuilder }) => void }) => { + callbacks.onSuccess({ + authorization_url: 'https://oauth.test.com/authorize', + subscription_builder: mockBuilder, + }) + }) + + setupMocks({ + storeDetail: createStoreDetail(), + providerInfo: createProviderInfo({ + supported_creation_methods: [SupportedCreationMethods.OAUTH, SupportedCreationMethods.MANUAL], + }), + oauthConfig: createOAuthConfig({ configured: true }), + }) + const props = createDefaultProps() + + // Act + render() + + // Click on OAuth option + const oauthOption = screen.getByTestId(`option-${SupportedCreationMethods.OAUTH}`) + fireEvent.click(oauthOption) + + // Assert - modal should NOT open because callback data was falsy + await waitFor(() => { + expect(screen.queryByTestId('common-create-modal')).not.toBeInTheDocument() + }) + }) + }) + + // ==================== TriggerProps ClassName Branches ==================== + describe('TriggerProps ClassName Branches', () => { + it('should apply pointer-events-none when non-default method with multiple supported methods', () => { + // Arrange - Single APIKEY method (methodType = APIKEY, not DEFAULT_METHOD) + // But we need multiple methods to test this branch + setupMocks({ + storeDetail: createStoreDetail(), + providerInfo: createProviderInfo({ + supported_creation_methods: [SupportedCreationMethods.APIKEY, SupportedCreationMethods.MANUAL], + }), + }) + const props = createDefaultProps() + + // Act + render() + + // The methodType will be DEFAULT_METHOD since multiple methods + // This verifies the render doesn't crash with multiple methods + expect(screen.getByTestId('custom-select')).toHaveAttribute('data-value', 'default') + }) + }) + + // ==================== Tooltip Disabled Branches ==================== + describe('Tooltip Disabled Branches', () => { + it('should enable tooltip when single method and not at max count', () => { + // Arrange + setupMocks({ + storeDetail: createStoreDetail(), + providerInfo: createProviderInfo({ + supported_creation_methods: [SupportedCreationMethods.MANUAL], + }), + subscriptions: [createSubscription()], // Not at max + }) + const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON }) + + // Act + render() + + // Assert - tooltip should be enabled (disabled prop = false for single method) + expect(screen.getByTestId('custom-trigger')).toBeInTheDocument() + }) + + it('should disable tooltip when multiple methods and not at max count', () => { + // Arrange + setupMocks({ + storeDetail: createStoreDetail(), + providerInfo: createProviderInfo({ + supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY], + }), + subscriptions: [createSubscription()], // Not at max + }) + const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON }) + + // Act + render() + + // Assert - tooltip should be disabled (neither single method nor at max) + expect(screen.getByTestId('custom-trigger')).toBeInTheDocument() + }) + }) + + // ==================== Tooltip PopupContent Branches ==================== + describe('Tooltip PopupContent Branches', () => { + it('should show max count message when at max subscriptions', () => { + // Arrange + const maxSubscriptions = createMaxSubscriptions() + setupMocks({ + storeDetail: createStoreDetail(), + providerInfo: createProviderInfo({ + supported_creation_methods: [SupportedCreationMethods.MANUAL], + }), + subscriptions: maxSubscriptions, + }) + const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON }) + + // Act + render() + + // Assert - component renders with max subscriptions + expect(screen.getByTestId('custom-trigger')).toBeInTheDocument() + }) + + it('should show method description when not at max', () => { + // Arrange + setupMocks({ + storeDetail: createStoreDetail(), + providerInfo: createProviderInfo({ + supported_creation_methods: [SupportedCreationMethods.MANUAL], + }), + subscriptions: [], // Not at max + }) + const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON }) + + // Act + render() + + // Assert - component renders without max subscriptions + expect(screen.getByTestId('custom-trigger')).toBeInTheDocument() + }) + }) + + // ==================== Provider Info Fallbacks ==================== + describe('Provider Info Fallbacks', () => { + it('should handle undefined supported_creation_methods', () => { + // Arrange - providerInfo with undefined supported_creation_methods + setupMocks({ + storeDetail: createStoreDetail(), + providerInfo: { + ...createProviderInfo(), + supported_creation_methods: undefined as unknown as SupportedCreationMethods[], + }, + }) + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert - should render null when supported methods fallback to empty + expect(container).toBeEmptyDOMElement() + }) + + it('should handle providerInfo with null supported_creation_methods', () => { + // Arrange + mockProviderInfo = { data: { ...createProviderInfo(), supported_creation_methods: null as unknown as SupportedCreationMethods[] } } + mockOAuthConfig = { data: undefined, refetch: vi.fn() } + mockStoreDetail = createStoreDetail() + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert - should render null + expect(container).toBeEmptyDOMElement() + }) + }) + + // ==================== Method Type Logic ==================== + describe('Method Type Logic', () => { + it('should use single method as methodType when only one supported', () => { + // Arrange + setupMocks({ + storeDetail: createStoreDetail(), + providerInfo: createProviderInfo({ + supported_creation_methods: [SupportedCreationMethods.APIKEY], + }), + }) + const props = createDefaultProps() + + // Act + render() + + // Assert + const customSelect = screen.getByTestId('custom-select') + expect(customSelect).toHaveAttribute('data-value', SupportedCreationMethods.APIKEY) + }) + }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx index f1cb7a65ae..a842c63cfd 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx @@ -1240,4 +1240,60 @@ describe('OAuthClientSettingsModal', () => { vi.useRealTimers() }) }) + + describe('OAuth Client Schema Params Fallback', () => { + it('should handle schema when params is truthy but schema name not in params', () => { + const configWithSchemaNotInParams = createMockOAuthConfig({ + system_configured: false, + custom_enabled: true, + params: { + client_id: 'test-id', + client_secret: 'test-secret', + }, + oauth_client_schema: [ + { name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown }, + { name: 'client_secret', type: 'secret-input' as unknown, required: true, label: { 'en-US': 'Client Secret' } as unknown }, + { name: 'extra_field', type: 'text-input' as unknown, required: false, label: { 'en-US': 'Extra' } as unknown }, + ] as TriggerOAuthConfig['oauth_client_schema'], + }) + + render() + + // extra_field should be rendered but without default value + const extraInput = screen.getByTestId('form-field-extra_field') as HTMLInputElement + expect(extraInput.defaultValue).toBe('') + }) + + it('should handle oauth_client_schema with undefined params', () => { + const configWithUndefinedParams = createMockOAuthConfig({ + system_configured: false, + custom_enabled: true, + params: undefined as unknown as TriggerOAuthConfig['params'], + oauth_client_schema: [ + { name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown }, + ] as TriggerOAuthConfig['oauth_client_schema'], + }) + + render() + + // Form should not render because params is undefined (schema condition fails) + expect(screen.queryByTestId('base-form')).not.toBeInTheDocument() + }) + + it('should handle oauth_client_schema with null params', () => { + const configWithNullParams = createMockOAuthConfig({ + system_configured: false, + custom_enabled: true, + params: null as unknown as TriggerOAuthConfig['params'], + oauth_client_schema: [ + { name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown }, + ] as TriggerOAuthConfig['oauth_client_schema'], + }) + + render() + + // Form should not render because params is null + expect(screen.queryByTestId('base-form')).not.toBeInTheDocument() + }) + }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.spec.tsx b/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.spec.tsx new file mode 100644 index 0000000000..5ae7b62f13 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.spec.tsx @@ -0,0 +1,287 @@ +import type { TriggerEvent } from '@/app/components/plugins/types' +import type { TriggerProviderApiEntity } from '@/app/components/workflow/block-selector/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { EventDetailDrawer } from './event-detail-drawer' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useLanguage: () => 'en_US', +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '), +})) + +vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ + default: () => , +})) + +vi.mock('@/app/components/plugins/card/base/description', () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})) + +vi.mock('@/app/components/plugins/card/base/org-info', () => ({ + default: ({ orgName }: { orgName: string }) =>
{orgName}
, +})) + +vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ + triggerEventParametersToFormSchemas: (params: Array>) => + params.map(p => ({ + label: (p.label as Record) || { en_US: p.name as string }, + type: (p.type as string) || 'text-input', + required: (p.required as boolean) || false, + description: p.description as Record | undefined, + })), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field', () => ({ + default: ({ name }: { name: string }) =>
{name}
, +})) + +const mockEventInfo = { + name: 'test-event', + identity: { + author: 'test-author', + name: 'test-event', + label: { en_US: 'Test Event' }, + }, + description: { en_US: 'Test event description' }, + parameters: [ + { + name: 'param1', + label: { en_US: 'Parameter 1' }, + type: 'text-input', + auto_generate: null, + template: null, + scope: null, + required: true, + multiple: false, + default: null, + min: null, + max: null, + precision: null, + description: { en_US: 'A test parameter' }, + }, + ], + output_schema: { + properties: { + result: { type: 'string', description: 'Result' }, + }, + required: ['result'], + }, +} as unknown as TriggerEvent + +const mockProviderInfo = { + provider: 'test-provider', + author: 'test-author', + name: 'test-provider/test-name', + icon: 'icon.png', + description: { en_US: 'Provider desc' }, + supported_creation_methods: [], +} as unknown as TriggerProviderApiEntity + +describe('EventDetailDrawer', () => { + const mockOnClose = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render drawer', () => { + render() + + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should render event title', () => { + render() + + expect(screen.getByText('Test Event')).toBeInTheDocument() + }) + + it('should render event description', () => { + render() + + expect(screen.getByTestId('description')).toHaveTextContent('Test event description') + }) + + it('should render org info', () => { + render() + + expect(screen.getByTestId('org-info')).toBeInTheDocument() + }) + + it('should render parameters section', () => { + render() + + expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument() + expect(screen.getByText('Parameter 1')).toBeInTheDocument() + }) + + it('should render output section', () => { + render() + + expect(screen.getByText('events.output')).toBeInTheDocument() + expect(screen.getByTestId('output-field')).toHaveTextContent('result') + }) + + it('should render back button', () => { + render() + + expect(screen.getByText('detailPanel.operation.back')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onClose when close button clicked', () => { + render() + + // Find the close button (ActionButton with action-btn class) + const closeButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn')) + if (closeButton) + fireEvent.click(closeButton) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should call onClose when back clicked', () => { + render() + + fireEvent.click(screen.getByText('detailPanel.operation.back')) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + }) + + describe('Edge Cases', () => { + it('should handle no parameters', () => { + const eventWithNoParams = { ...mockEventInfo, parameters: [] } + render() + + expect(screen.getByText('events.item.noParameters')).toBeInTheDocument() + }) + + it('should handle no output schema', () => { + const eventWithNoOutput = { ...mockEventInfo, output_schema: {} } + render() + + expect(screen.getByText('events.output')).toBeInTheDocument() + expect(screen.queryByTestId('output-field')).not.toBeInTheDocument() + }) + }) + + describe('Parameter Types', () => { + it('should display correct type for number-input', () => { + const eventWithNumber = { + ...mockEventInfo, + parameters: [{ ...mockEventInfo.parameters[0], type: 'number-input' }], + } + render() + + expect(screen.getByText('setBuiltInTools.number')).toBeInTheDocument() + }) + + it('should display correct type for checkbox', () => { + const eventWithCheckbox = { + ...mockEventInfo, + parameters: [{ ...mockEventInfo.parameters[0], type: 'checkbox' }], + } + render() + + expect(screen.getByText('boolean')).toBeInTheDocument() + }) + + it('should display correct type for file', () => { + const eventWithFile = { + ...mockEventInfo, + parameters: [{ ...mockEventInfo.parameters[0], type: 'file' }], + } + render() + + expect(screen.getByText('setBuiltInTools.file')).toBeInTheDocument() + }) + + it('should display original type for unknown types', () => { + const eventWithUnknown = { + ...mockEventInfo, + parameters: [{ ...mockEventInfo.parameters[0], type: 'custom-type' }], + } + render() + + expect(screen.getByText('custom-type')).toBeInTheDocument() + }) + }) + + describe('Output Schema Conversion', () => { + it('should handle array type in output schema', () => { + const eventWithArrayOutput = { + ...mockEventInfo, + output_schema: { + properties: { + items: { type: 'array', items: { type: 'string' }, description: 'Array items' }, + }, + required: [], + }, + } + render() + + expect(screen.getByText('events.output')).toBeInTheDocument() + }) + + it('should handle nested properties in output schema', () => { + const eventWithNestedOutput = { + ...mockEventInfo, + output_schema: { + properties: { + nested: { + type: 'object', + properties: { inner: { type: 'string' } }, + required: ['inner'], + }, + }, + required: [], + }, + } + render() + + expect(screen.getByText('events.output')).toBeInTheDocument() + }) + + it('should handle enum in output schema', () => { + const eventWithEnumOutput = { + ...mockEventInfo, + output_schema: { + properties: { + status: { type: 'string', enum: ['active', 'inactive'], description: 'Status' }, + }, + required: [], + }, + } + render() + + expect(screen.getByText('events.output')).toBeInTheDocument() + }) + + it('should handle array type schema', () => { + const eventWithArrayType = { + ...mockEventInfo, + output_schema: { + properties: { + multi: { type: ['string', 'null'], description: 'Multi type' }, + }, + required: [], + }, + } + render() + + expect(screen.getByText('events.output')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/trigger/event-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/trigger/event-list.spec.tsx new file mode 100644 index 0000000000..2687319fbc --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/trigger/event-list.spec.tsx @@ -0,0 +1,146 @@ +import type { TriggerEvent } from '@/app/components/plugins/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TriggerEventsList } from './event-list' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (options?.num !== undefined) + return `${options.num} ${options.event || 'events'}` + return key + }, + }), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useLanguage: () => 'en_US', +})) + +vi.mock('@/utils/classnames', () => ({ + cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '), +})) + +const mockTriggerEvents = [ + { + name: 'event-1', + identity: { + author: 'author-1', + name: 'event-1', + label: { en_US: 'Event One' }, + }, + description: { en_US: 'Event one description' }, + parameters: [], + output_schema: {}, + }, +] as unknown as TriggerEvent[] + +let mockDetail: { plugin_id: string, provider: string } | undefined +let mockProviderInfo: { events: TriggerEvent[] } | undefined + +vi.mock('../store', () => ({ + usePluginStore: (selector: (state: { detail: typeof mockDetail }) => typeof mockDetail) => + selector({ detail: mockDetail }), +})) + +vi.mock('@/service/use-triggers', () => ({ + useTriggerProviderInfo: () => ({ data: mockProviderInfo }), +})) + +vi.mock('./event-detail-drawer', () => ({ + EventDetailDrawer: ({ onClose }: { onClose: () => void }) => ( +
+ +
+ ), +})) + +describe('TriggerEventsList', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDetail = { plugin_id: 'test-plugin', provider: 'test-provider' } + mockProviderInfo = { events: mockTriggerEvents } + }) + + describe('Rendering', () => { + it('should render event count', () => { + render() + + expect(screen.getByText('1 events.event')).toBeInTheDocument() + }) + + it('should render event cards', () => { + render() + + expect(screen.getByText('Event One')).toBeInTheDocument() + expect(screen.getByText('Event one description')).toBeInTheDocument() + }) + + it('should return null when no provider info', () => { + mockProviderInfo = undefined + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + + it('should return null when no events', () => { + mockProviderInfo = { events: [] } + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + + it('should return null when no detail', () => { + mockDetail = undefined + mockProviderInfo = undefined + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + }) + + describe('User Interactions', () => { + it('should show detail drawer when event card clicked', () => { + render() + + fireEvent.click(screen.getByText('Event One')) + + expect(screen.getByTestId('event-detail-drawer')).toBeInTheDocument() + }) + + it('should hide detail drawer when close clicked', () => { + render() + + fireEvent.click(screen.getByText('Event One')) + expect(screen.getByTestId('event-detail-drawer')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('close-drawer')) + expect(screen.queryByTestId('event-detail-drawer')).not.toBeInTheDocument() + }) + }) + + describe('Multiple Events', () => { + it('should render multiple event cards', () => { + const secondEvent = { + name: 'event-2', + identity: { + author: 'author-2', + name: 'event-2', + label: { en_US: 'Event Two' }, + }, + description: { en_US: 'Event two description' }, + parameters: [], + output_schema: {}, + } as unknown as TriggerEvent + + mockProviderInfo = { + events: [...mockTriggerEvents, secondEvent], + } + render() + + expect(screen.getByText('Event One')).toBeInTheDocument() + expect(screen.getByText('Event Two')).toBeInTheDocument() + expect(screen.getByText('2 events.events')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/utils.spec.ts b/web/app/components/plugins/plugin-detail-panel/utils.spec.ts new file mode 100644 index 0000000000..6c911d5ebd --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/utils.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { NAME_FIELD } from './utils' + +describe('utils', () => { + describe('NAME_FIELD', () => { + it('should have correct type', () => { + expect(NAME_FIELD.type).toBe(FormTypeEnum.textInput) + }) + + it('should have correct name', () => { + expect(NAME_FIELD.name).toBe('name') + }) + + it('should have label translations', () => { + expect(NAME_FIELD.label).toBeDefined() + expect(NAME_FIELD.label.en_US).toBe('Endpoint Name') + expect(NAME_FIELD.label.zh_Hans).toBe('端点名称') + expect(NAME_FIELD.label.ja_JP).toBe('エンドポイント名') + expect(NAME_FIELD.label.pt_BR).toBe('Nome do ponto final') + }) + + it('should have placeholder translations', () => { + expect(NAME_FIELD.placeholder).toBeDefined() + expect(NAME_FIELD.placeholder.en_US).toBe('Endpoint Name') + expect(NAME_FIELD.placeholder.zh_Hans).toBe('端点名称') + expect(NAME_FIELD.placeholder.ja_JP).toBe('エンドポイント名') + expect(NAME_FIELD.placeholder.pt_BR).toBe('Nome do ponto final') + }) + + it('should be required', () => { + expect(NAME_FIELD.required).toBe(true) + }) + + it('should have empty default value', () => { + expect(NAME_FIELD.default).toBe('') + }) + + it('should have null help', () => { + expect(NAME_FIELD.help).toBeNull() + }) + + it('should have all required field properties', () => { + const requiredKeys = ['type', 'name', 'label', 'placeholder', 'required', 'default', 'help'] + requiredKeys.forEach((key) => { + expect(NAME_FIELD).toHaveProperty(key) + }) + }) + + it('should match expected structure', () => { + expect(NAME_FIELD).toEqual({ + type: FormTypeEnum.textInput, + name: 'name', + label: { + en_US: 'Endpoint Name', + zh_Hans: '端点名称', + ja_JP: 'エンドポイント名', + pt_BR: 'Nome do ponto final', + }, + placeholder: { + en_US: 'Endpoint Name', + zh_Hans: '端点名称', + ja_JP: 'エンドポイント名', + pt_BR: 'Nome do ponto final', + }, + required: true, + default: '', + help: null, + }) + }) + }) +}) From 0f1db88dcb5952a3f299ca8ace37c77708045531 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Mon, 19 Jan 2026 16:00:44 +0800 Subject: [PATCH 3/7] fix: fix dify-plugin-daemon error message (#31218) --- api/core/plugin/impl/base.py | 15 +++++++-------- .../unit_tests/core/plugin/test_plugin_runtime.py | 5 +++++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/api/core/plugin/impl/base.py b/api/core/plugin/impl/base.py index 0e49824ad0..7a6a598a2f 100644 --- a/api/core/plugin/impl/base.py +++ b/api/core/plugin/impl/base.py @@ -320,18 +320,17 @@ class BasePluginClient: case PluginInvokeError.__name__: error_object = json.loads(message) invoke_error_type = error_object.get("error_type") - args = error_object.get("args") match invoke_error_type: case InvokeRateLimitError.__name__: - raise InvokeRateLimitError(description=args.get("description")) + raise InvokeRateLimitError(description=error_object.get("message")) case InvokeAuthorizationError.__name__: - raise InvokeAuthorizationError(description=args.get("description")) + raise InvokeAuthorizationError(description=error_object.get("message")) case InvokeBadRequestError.__name__: - raise InvokeBadRequestError(description=args.get("description")) + raise InvokeBadRequestError(description=error_object.get("message")) case InvokeConnectionError.__name__: - raise InvokeConnectionError(description=args.get("description")) + raise InvokeConnectionError(description=error_object.get("message")) case InvokeServerUnavailableError.__name__: - raise InvokeServerUnavailableError(description=args.get("description")) + raise InvokeServerUnavailableError(description=error_object.get("message")) case CredentialsValidateFailedError.__name__: raise CredentialsValidateFailedError(error_object.get("message")) case EndpointSetupFailedError.__name__: @@ -339,11 +338,11 @@ class BasePluginClient: case TriggerProviderCredentialValidationError.__name__: raise TriggerProviderCredentialValidationError(error_object.get("message")) case TriggerPluginInvokeError.__name__: - raise TriggerPluginInvokeError(description=error_object.get("description")) + raise TriggerPluginInvokeError(description=error_object.get("message")) case TriggerInvokeError.__name__: raise TriggerInvokeError(error_object.get("message")) case EventIgnoreError.__name__: - raise EventIgnoreError(description=error_object.get("description")) + raise EventIgnoreError(description=error_object.get("message")) case _: raise PluginInvokeError(description=message) case PluginDaemonInternalServerError.__name__: diff --git a/api/tests/unit_tests/core/plugin/test_plugin_runtime.py b/api/tests/unit_tests/core/plugin/test_plugin_runtime.py index 2a0b293a39..9e911e1fce 100644 --- a/api/tests/unit_tests/core/plugin/test_plugin_runtime.py +++ b/api/tests/unit_tests/core/plugin/test_plugin_runtime.py @@ -346,6 +346,7 @@ class TestPluginRuntimeErrorHandling: mock_response.status_code = 200 invoke_error = { "error_type": "InvokeRateLimitError", + "message": "Rate limit exceeded", "args": {"description": "Rate limit exceeded"}, } error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)}) @@ -364,6 +365,7 @@ class TestPluginRuntimeErrorHandling: mock_response.status_code = 200 invoke_error = { "error_type": "InvokeAuthorizationError", + "message": "Invalid credentials", "args": {"description": "Invalid credentials"}, } error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)}) @@ -382,6 +384,7 @@ class TestPluginRuntimeErrorHandling: mock_response.status_code = 200 invoke_error = { "error_type": "InvokeBadRequestError", + "message": "Invalid parameters", "args": {"description": "Invalid parameters"}, } error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)}) @@ -400,6 +403,7 @@ class TestPluginRuntimeErrorHandling: mock_response.status_code = 200 invoke_error = { "error_type": "InvokeConnectionError", + "message": "Connection to external service failed", "args": {"description": "Connection to external service failed"}, } error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)}) @@ -418,6 +422,7 @@ class TestPluginRuntimeErrorHandling: mock_response.status_code = 200 invoke_error = { "error_type": "InvokeServerUnavailableError", + "message": "Service temporarily unavailable", "args": {"description": "Service temporarily unavailable"}, } error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)}) From 88780c7eb7554c3c3d631fa8fd44f34d0704bd92 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Mon, 19 Jan 2026 16:07:24 +0800 Subject: [PATCH 4/7] fix: Revert "fix: fix create app xss issue" (#31219) --- api/controllers/console/app/app.py | 58 ---- .../console/app/test_xss_prevention.py | 254 ------------------ 2 files changed, 312 deletions(-) delete mode 100644 api/tests/unit_tests/controllers/console/app/test_xss_prevention.py diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index d66bb7063f..dad184c54b 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -1,4 +1,3 @@ -import re import uuid from datetime import datetime from typing import Any, Literal, TypeAlias @@ -68,48 +67,6 @@ class AppListQuery(BaseModel): raise ValueError("Invalid UUID format in tag_ids.") from exc -# XSS prevention: patterns that could lead to XSS attacks -# Includes: script tags, iframe tags, javascript: protocol, SVG with onload, etc. -_XSS_PATTERNS = [ - r"]*>.*?", # Script tags - r"]*?(?:/>|>.*?)", # Iframe tags (including self-closing) - r"javascript:", # JavaScript protocol - r"]*?\s+onload\s*=[^>]*>", # SVG with onload handler (attribute-aware, flexible whitespace) - r"<.*?on\s*\w+\s*=", # Event handlers like onclick, onerror, etc. - r"]*(?:\s*/>|>.*?)", # Object tags (opening tag) - r"]*>", # Embed tags (self-closing) - r"]*>", # Link tags with javascript -] - - -def _validate_xss_safe(value: str | None, field_name: str = "Field") -> str | None: - """ - Validate that a string value doesn't contain potential XSS payloads. - - Args: - value: The string value to validate - field_name: Name of the field for error messages - - Returns: - The original value if safe - - Raises: - ValueError: If the value contains XSS patterns - """ - if value is None: - return None - - value_lower = value.lower() - for pattern in _XSS_PATTERNS: - if re.search(pattern, value_lower, re.DOTALL | re.IGNORECASE): - raise ValueError( - f"{field_name} contains invalid characters or patterns. " - "HTML tags, JavaScript, and other potentially dangerous content are not allowed." - ) - - return value - - class CreateAppPayload(BaseModel): name: str = Field(..., min_length=1, description="App name") description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400) @@ -118,11 +75,6 @@ class CreateAppPayload(BaseModel): icon: str | None = Field(default=None, description="Icon") icon_background: str | None = Field(default=None, description="Icon background color") - @field_validator("name", "description", mode="before") - @classmethod - def validate_xss_safe(cls, value: str | None, info) -> str | None: - return _validate_xss_safe(value, info.field_name) - class UpdateAppPayload(BaseModel): name: str = Field(..., min_length=1, description="App name") @@ -133,11 +85,6 @@ class UpdateAppPayload(BaseModel): use_icon_as_answer_icon: bool | None = Field(default=None, description="Use icon as answer icon") max_active_requests: int | None = Field(default=None, description="Maximum active requests") - @field_validator("name", "description", mode="before") - @classmethod - def validate_xss_safe(cls, value: str | None, info) -> str | None: - return _validate_xss_safe(value, info.field_name) - class CopyAppPayload(BaseModel): name: str | None = Field(default=None, description="Name for the copied app") @@ -146,11 +93,6 @@ class CopyAppPayload(BaseModel): icon: str | None = Field(default=None, description="Icon") icon_background: str | None = Field(default=None, description="Icon background color") - @field_validator("name", "description", mode="before") - @classmethod - def validate_xss_safe(cls, value: str | None, info) -> str | None: - return _validate_xss_safe(value, info.field_name) - class AppExportQuery(BaseModel): include_secret: bool = Field(default=False, description="Include secrets in export") diff --git a/api/tests/unit_tests/controllers/console/app/test_xss_prevention.py b/api/tests/unit_tests/controllers/console/app/test_xss_prevention.py deleted file mode 100644 index 313818547b..0000000000 --- a/api/tests/unit_tests/controllers/console/app/test_xss_prevention.py +++ /dev/null @@ -1,254 +0,0 @@ -""" -Unit tests for XSS prevention in App payloads. - -This test module validates that HTML tags, JavaScript, and other potentially -dangerous content are rejected in App names and descriptions. -""" - -import pytest - -from controllers.console.app.app import CopyAppPayload, CreateAppPayload, UpdateAppPayload - - -class TestXSSPreventionUnit: - """Unit tests for XSS prevention in App payloads.""" - - def test_create_app_valid_names(self): - """Test CreateAppPayload with valid app names.""" - # Normal app names should be valid - valid_names = [ - "My App", - "Test App 123", - "App with - dash", - "App with _ underscore", - "App with + plus", - "App with () parentheses", - "App with [] brackets", - "App with {} braces", - "App with ! exclamation", - "App with @ at", - "App with # hash", - "App with $ dollar", - "App with % percent", - "App with ^ caret", - "App with & ampersand", - "App with * asterisk", - "Unicode: 测试应用", - "Emoji: 🤖", - "Mixed: Test 测试 123", - ] - - for name in valid_names: - payload = CreateAppPayload( - name=name, - mode="chat", - ) - assert payload.name == name - - def test_create_app_xss_script_tags(self): - """Test CreateAppPayload rejects script tags.""" - xss_payloads = [ - "", - "", - "", - "", - "", - "", - ] - - for name in xss_payloads: - with pytest.raises(ValueError) as exc_info: - CreateAppPayload(name=name, mode="chat") - assert "invalid characters or patterns" in str(exc_info.value).lower() - - def test_create_app_xss_iframe_tags(self): - """Test CreateAppPayload rejects iframe tags.""" - xss_payloads = [ - "", - "", - "", - ] - - for name in xss_payloads: - with pytest.raises(ValueError) as exc_info: - CreateAppPayload(name=name, mode="chat") - assert "invalid characters or patterns" in str(exc_info.value).lower() - - def test_create_app_xss_javascript_protocol(self): - """Test CreateAppPayload rejects javascript: protocol.""" - xss_payloads = [ - "javascript:alert(1)", - "JAVASCRIPT:alert(1)", - "JavaScript:alert(document.cookie)", - "javascript:void(0)", - "javascript://comment%0Aalert(1)", - ] - - for name in xss_payloads: - with pytest.raises(ValueError) as exc_info: - CreateAppPayload(name=name, mode="chat") - assert "invalid characters or patterns" in str(exc_info.value).lower() - - def test_create_app_xss_svg_onload(self): - """Test CreateAppPayload rejects SVG with onload.""" - xss_payloads = [ - "", - "", - "", - ] - - for name in xss_payloads: - with pytest.raises(ValueError) as exc_info: - CreateAppPayload(name=name, mode="chat") - assert "invalid characters or patterns" in str(exc_info.value).lower() - - def test_create_app_xss_event_handlers(self): - """Test CreateAppPayload rejects HTML event handlers.""" - xss_payloads = [ - "
", - "", - "", - "", - "", - "
", - "", - ] - - for name in xss_payloads: - with pytest.raises(ValueError) as exc_info: - CreateAppPayload(name=name, mode="chat") - assert "invalid characters or patterns" in str(exc_info.value).lower() - - def test_create_app_xss_object_embed(self): - """Test CreateAppPayload rejects object and embed tags.""" - xss_payloads = [ - "", - "", - "", - ] - - for name in xss_payloads: - with pytest.raises(ValueError) as exc_info: - CreateAppPayload(name=name, mode="chat") - assert "invalid characters or patterns" in str(exc_info.value).lower() - - def test_create_app_xss_link_javascript(self): - """Test CreateAppPayload rejects link tags with javascript.""" - xss_payloads = [ - "", - "", - ] - - for name in xss_payloads: - with pytest.raises(ValueError) as exc_info: - CreateAppPayload(name=name, mode="chat") - assert "invalid characters or patterns" in str(exc_info.value).lower() - - def test_create_app_xss_in_description(self): - """Test CreateAppPayload rejects XSS in description.""" - xss_descriptions = [ - "", - "javascript:alert(1)", - "", - ] - - for description in xss_descriptions: - with pytest.raises(ValueError) as exc_info: - CreateAppPayload( - name="Valid Name", - mode="chat", - description=description, - ) - assert "invalid characters or patterns" in str(exc_info.value).lower() - - def test_create_app_valid_descriptions(self): - """Test CreateAppPayload with valid descriptions.""" - valid_descriptions = [ - "A simple description", - "Description with < and > symbols", - "Description with & ampersand", - "Description with 'quotes' and \"double quotes\"", - "Description with / slashes", - "Description with \\ backslashes", - "Description with ; semicolons", - "Unicode: 这是一个描述", - "Emoji: 🎉🚀", - ] - - for description in valid_descriptions: - payload = CreateAppPayload( - name="Valid App Name", - mode="chat", - description=description, - ) - assert payload.description == description - - def test_create_app_none_description(self): - """Test CreateAppPayload with None description.""" - payload = CreateAppPayload( - name="Valid App Name", - mode="chat", - description=None, - ) - assert payload.description is None - - def test_update_app_xss_prevention(self): - """Test UpdateAppPayload also prevents XSS.""" - xss_names = [ - "", - "javascript:alert(1)", - "", - ] - - for name in xss_names: - with pytest.raises(ValueError) as exc_info: - UpdateAppPayload(name=name) - assert "invalid characters or patterns" in str(exc_info.value).lower() - - def test_update_app_valid_names(self): - """Test UpdateAppPayload with valid names.""" - payload = UpdateAppPayload(name="Valid Updated Name") - assert payload.name == "Valid Updated Name" - - def test_copy_app_xss_prevention(self): - """Test CopyAppPayload also prevents XSS.""" - xss_names = [ - "", - "javascript:alert(1)", - "", - ] - - for name in xss_names: - with pytest.raises(ValueError) as exc_info: - CopyAppPayload(name=name) - assert "invalid characters or patterns" in str(exc_info.value).lower() - - def test_copy_app_valid_names(self): - """Test CopyAppPayload with valid names.""" - payload = CopyAppPayload(name="Valid Copy Name") - assert payload.name == "Valid Copy Name" - - def test_copy_app_none_name(self): - """Test CopyAppPayload with None name (should be allowed).""" - payload = CopyAppPayload(name=None) - assert payload.name is None - - def test_edge_case_angle_brackets_content(self): - """Test that angle brackets with actual content are rejected.""" - # Angle brackets without valid HTML-like patterns should be checked - # The regex pattern <.*?on\w+\s*= should catch event handlers - # But let's verify other patterns too - - # Valid: angle brackets used as symbols (not matched by our patterns) - # Our patterns specifically look for dangerous constructs - - # Invalid: actual HTML tags with event handlers - invalid_names = [ - "
", - "", - ] - - for name in invalid_names: - with pytest.raises(ValueError) as exc_info: - CreateAppPayload(name=name, mode="chat") - assert "invalid characters or patterns" in str(exc_info.value).lower() From 2d4289a92588390f92f192bb130bfb23c79392eb Mon Sep 17 00:00:00 2001 From: zxhlyh Date: Mon, 19 Jan 2026 16:15:51 +0800 Subject: [PATCH 5/7] chore: relocate datasets api form (#31224) --- .../datasets/extra-info/api-access/card.tsx | 92 +++++++++++++++++++ .../datasets/extra-info/api-access/index.tsx | 65 +++++++++++++ .../components/datasets/extra-info/index.tsx | 7 +- .../datasets/extra-info/service-api/card.tsx | 41 +-------- .../datasets/extra-info/service-api/index.tsx | 22 ++--- web/app/components/datasets/list/index.tsx | 14 ++- web/eslint-suppressions.json | 5 - web/i18n/en-US/common.json | 3 +- web/i18n/en-US/dataset.json | 2 +- web/i18n/zh-Hans/common.json | 1 + web/i18n/zh-Hans/dataset.json | 2 +- 11 files changed, 188 insertions(+), 66 deletions(-) create mode 100644 web/app/components/datasets/extra-info/api-access/card.tsx create mode 100644 web/app/components/datasets/extra-info/api-access/index.tsx diff --git a/web/app/components/datasets/extra-info/api-access/card.tsx b/web/app/components/datasets/extra-info/api-access/card.tsx new file mode 100644 index 0000000000..77c44795f4 --- /dev/null +++ b/web/app/components/datasets/extra-info/api-access/card.tsx @@ -0,0 +1,92 @@ +import { RiArrowRightUpLine, RiBookOpenLine } from '@remixicon/react' +import Link from 'next/link' +import * as React from 'react' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import Switch from '@/app/components/base/switch' +import Indicator from '@/app/components/header/indicator' +import { useSelector as useAppContextSelector } from '@/context/app-context' +import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' +import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url' +import { useDisableDatasetServiceApi, useEnableDatasetServiceApi } from '@/service/knowledge/use-dataset' +import { cn } from '@/utils/classnames' + +type CardProps = { + apiEnabled: boolean +} + +const Card = ({ + apiEnabled, +}: CardProps) => { + const { t } = useTranslation() + const datasetId = useDatasetDetailContextWithSelector(state => state.dataset?.id) + const mutateDatasetRes = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes) + const { mutateAsync: enableDatasetServiceApi } = useEnableDatasetServiceApi() + const { mutateAsync: disableDatasetServiceApi } = useDisableDatasetServiceApi() + + const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager) + + const apiReferenceUrl = useDatasetApiAccessUrl() + + const onToggle = useCallback(async (state: boolean) => { + let result: 'success' | 'fail' + if (state) + result = (await enableDatasetServiceApi(datasetId ?? '')).result + else + result = (await disableDatasetServiceApi(datasetId ?? '')).result + if (result === 'success') + mutateDatasetRes?.() + }, [datasetId, enableDatasetServiceApi, mutateDatasetRes, disableDatasetServiceApi]) + + return ( +
+
+
+
+
+ +
+ {apiEnabled + ? t('serviceApi.enabled', { ns: 'dataset' }) + : t('serviceApi.disabled', { ns: 'dataset' })} +
+
+ +
+
+ {t('appMenus.apiAccessTip', { ns: 'common' })} +
+
+
+
+
+ + +
+ {t('overview.apiInfo.doc', { ns: 'appOverview' })} +
+ + +
+
+ ) +} + +export default React.memo(Card) diff --git a/web/app/components/datasets/extra-info/api-access/index.tsx b/web/app/components/datasets/extra-info/api-access/index.tsx new file mode 100644 index 0000000000..5ef4166493 --- /dev/null +++ b/web/app/components/datasets/extra-info/api-access/index.tsx @@ -0,0 +1,65 @@ +import * as React from 'react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge' +import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' +import Indicator from '@/app/components/header/indicator' +import { cn } from '@/utils/classnames' +import Card from './card' + +type ApiAccessProps = { + expand: boolean + apiEnabled: boolean +} + +const ApiAccess = ({ + expand, + apiEnabled, +}: ApiAccessProps) => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + + const handleToggle = () => { + setOpen(!open) + } + + return ( +
+ + +
+ + {expand &&
{t('appMenus.apiAccess', { ns: 'common' })}
} + +
+
+ + + +
+
+ ) +} + +export default React.memo(ApiAccess) diff --git a/web/app/components/datasets/extra-info/index.tsx b/web/app/components/datasets/extra-info/index.tsx index d0f74fd288..d892e7ae8b 100644 --- a/web/app/components/datasets/extra-info/index.tsx +++ b/web/app/components/datasets/extra-info/index.tsx @@ -1,8 +1,7 @@ import type { RelatedAppResponse } from '@/models/datasets' import * as React from 'react' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' -import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset' -import ServiceApi from './service-api' +import ApiAccess from './api-access' import Statistics from './statistics' type IExtraInfoProps = { @@ -17,7 +16,6 @@ const ExtraInfo = ({ expand, }: IExtraInfoProps) => { const apiEnabled = useDatasetDetailContextWithSelector(state => state.dataset?.enable_api) - const { data: apiBaseInfo } = useDatasetApiBaseUrl() return ( <> @@ -28,9 +26,8 @@ const ExtraInfo = ({ relatedApps={relatedApps} /> )} - diff --git a/web/app/components/datasets/extra-info/service-api/card.tsx b/web/app/components/datasets/extra-info/service-api/card.tsx index 3ed0fceb7a..31076d12fc 100644 --- a/web/app/components/datasets/extra-info/service-api/card.tsx +++ b/web/app/components/datasets/extra-info/service-api/card.tsx @@ -6,45 +6,22 @@ import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import CopyFeedback from '@/app/components/base/copy-feedback' import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge' -import Switch from '@/app/components/base/switch' import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal' import Indicator from '@/app/components/header/indicator' -import { useSelector as useAppContextSelector } from '@/context/app-context' -import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url' -import { useDisableDatasetServiceApi, useEnableDatasetServiceApi } from '@/service/knowledge/use-dataset' -import { cn } from '@/utils/classnames' type CardProps = { - apiEnabled: boolean apiBaseUrl: string } const Card = ({ - apiEnabled, apiBaseUrl, }: CardProps) => { const { t } = useTranslation() - const datasetId = useDatasetDetailContextWithSelector(state => state.dataset?.id) - const mutateDatasetRes = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes) - const { mutateAsync: enableDatasetServiceApi } = useEnableDatasetServiceApi() - const { mutateAsync: disableDatasetServiceApi } = useDisableDatasetServiceApi() const [isSecretKeyModalVisible, setIsSecretKeyModalVisible] = useState(false) - const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager) - const apiReferenceUrl = useDatasetApiAccessUrl() - const onToggle = useCallback(async (state: boolean) => { - let result: 'success' | 'fail' - if (state) - result = (await enableDatasetServiceApi(datasetId ?? '')).result - else - result = (await disableDatasetServiceApi(datasetId ?? '')).result - if (result === 'success') - mutateDatasetRes?.() - }, [datasetId, enableDatasetServiceApi, disableDatasetServiceApi]) - const handleOpenSecretKeyModal = useCallback(() => { setIsSecretKeyModalVisible(true) }, []) @@ -68,24 +45,16 @@ const Card = ({
- {apiEnabled - ? t('serviceApi.enabled', { ns: 'dataset' }) - : t('serviceApi.disabled', { ns: 'dataset' })} + {t('serviceApi.enabled', { ns: 'dataset' })}
-
diff --git a/web/app/components/datasets/extra-info/service-api/index.tsx b/web/app/components/datasets/extra-info/service-api/index.tsx index c809aee062..e8f126b41c 100644 --- a/web/app/components/datasets/extra-info/service-api/index.tsx +++ b/web/app/components/datasets/extra-info/service-api/index.tsx @@ -1,22 +1,17 @@ import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' import Indicator from '@/app/components/header/indicator' import { cn } from '@/utils/classnames' import Card from './card' type ServiceApiProps = { - expand: boolean apiBaseUrl: string - apiEnabled: boolean } const ServiceApi = ({ - expand, apiBaseUrl, - apiEnabled, }: ServiceApiProps) => { const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -26,7 +21,7 @@ const ServiceApi = ({ } return ( -
+
- - {expand &&
{t('serviceApi.title', { ns: 'dataset' })}
} +
{t('serviceApi.title', { ns: 'dataset' })}
diff --git a/web/app/components/datasets/list/index.tsx b/web/app/components/datasets/list/index.tsx index e447b95b93..fdbe33986a 100644 --- a/web/app/components/datasets/list/index.tsx +++ b/web/app/components/datasets/list/index.tsx @@ -14,13 +14,14 @@ import TagFilter from '@/app/components/base/tag-management/filter' // Hooks import { useStore as useTagStore } from '@/app/components/base/tag-management/store' import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label' -import { useAppContext } from '@/context/app-context' +import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context' import { useExternalApiPanel } from '@/context/external-api-panel-context' - import { useGlobalPublicStore } from '@/context/global-public-context' import useDocumentTitle from '@/hooks/use-document-title' +import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset' // Components import ExternalAPIPanel from '../external-api/external-api-panel' +import ServiceApi from '../extra-info/service-api' import DatasetFooter from './dataset-footer' import Datasets from './datasets' @@ -58,6 +59,9 @@ const List = () => { return router.replace('/apps') }, [currentWorkspace, router]) + const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager) + const { data: apiBaseInfo } = useDatasetApiBaseUrl() + return (
@@ -81,6 +85,11 @@ const List = () => { onChange={e => handleKeywordsChange(e.target.value)} onClear={() => handleKeywordsChange('')} /> + { + isCurrentWorkspaceManager && ( + + ) + }
) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 125e94ece9..755ea07f56 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -4223,11 +4223,6 @@ "count": 1 } }, - "i18n/en-US/common.json": { - "no-irregular-whitespace": { - "count": 3 - } - }, "i18n/fr-FR/app-debug.json": { "no-irregular-whitespace": { "count": 1 diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index 64ac47d804..ed05fd71c6 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -91,6 +91,7 @@ "apiBasedExtension.title": "API extensions provide centralized API management, simplifying configuration for easy use across Dify's applications.", "apiBasedExtension.type": "Type", "appMenus.apiAccess": "API Access", + "appMenus.apiAccessTip": "This knowledge base is accessible via the Service API", "appMenus.logAndAnn": "Logs & Annotations", "appMenus.logs": "Logs", "appMenus.overview": "Monitoring", @@ -281,7 +282,7 @@ "model.params.setToCurrentModelMaxTokenTip": "Max token is updated to the 80% maximum token of the current model {{maxToken}}.", "model.params.stop_sequences": "Stop sequences", "model.params.stop_sequencesPlaceholder": "Enter sequence and press Tab", - "model.params.stop_sequencesTip": "Up to four sequences where the API will stop generating further tokens. The returned text will not contain the stop sequence.", + "model.params.stop_sequencesTip": "Up to four sequences where the API will stop generating further tokens. The returned text will not contain the stop sequence.", "model.params.temperature": "Temperature", "model.params.temperatureTip": "Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.", "model.params.top_p": "Top P", diff --git a/web/i18n/en-US/dataset.json b/web/i18n/en-US/dataset.json index 36553cd578..cb64118a2e 100644 --- a/web/i18n/en-US/dataset.json +++ b/web/i18n/en-US/dataset.json @@ -170,7 +170,7 @@ "serviceApi.card.endpoint": "Service API Endpoint", "serviceApi.card.title": "Backend service api", "serviceApi.disabled": "Disabled", - "serviceApi.enabled": "In Service", + "serviceApi.enabled": "Enabled", "serviceApi.title": "Service API", "unavailable": "Unavailable", "updated": "Updated", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index be7d4690af..9d20ebf2a0 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -91,6 +91,7 @@ "apiBasedExtension.title": "API 扩展提供了一个集中式的 API 管理,在此统一添加 API 配置后,方便在 Dify 上的各类应用中直接使用。", "apiBasedExtension.type": "类型", "appMenus.apiAccess": "访问 API", + "appMenus.apiAccessTip": "此知识库可通过服务 API 访问", "appMenus.logAndAnn": "日志与标注", "appMenus.logs": "日志", "appMenus.overview": "监测", diff --git a/web/i18n/zh-Hans/dataset.json b/web/i18n/zh-Hans/dataset.json index ec5d09b5f4..e9f7d779e3 100644 --- a/web/i18n/zh-Hans/dataset.json +++ b/web/i18n/zh-Hans/dataset.json @@ -170,7 +170,7 @@ "serviceApi.card.endpoint": "API 端点", "serviceApi.card.title": "后端服务 API", "serviceApi.disabled": "已停用", - "serviceApi.enabled": "运行中", + "serviceApi.enabled": "已启用", "serviceApi.title": "服务 API", "unavailable": "不可用", "updated": "更新于", From 62ac02a5680af5276bf65cc5a7f64fa172994990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9B=90=E7=B2=92=20Yanli?= Date: Mon, 19 Jan 2026 16:48:13 +0800 Subject: [PATCH 6/7] feat: Download the uploaded files (#31068) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Cursor Agent Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../console/datasets/datasets_document.py.md | 52 +++ .../services/dataset_service.py.md | 18 + api/agent-notes/services/file_service.py.md | 35 ++ .../test_datasets_document_download.py.md | 28 ++ .../test_file_service_zip_and_lookup.py.md | 18 + .../console/datasets/datasets_document.py | 73 ++- api/services/dataset_service.py | 142 +++++- api/services/file_service.py | 106 +++++ .../test_datasets_document_download.py | 430 ++++++++++++++++++ .../test_file_service_zip_and_lookup.py | 99 ++++ .../base/chat/chat/citation/popup.tsx | 45 +- .../datasets/documents/components/list.tsx | 38 +- .../documents/components/operations.tsx | 56 ++- .../detail/completed/common/batch-action.tsx | 14 +- web/i18n/en-US/common.json | 1 + web/i18n/en-US/dataset-documents.json | 1 + web/i18n/en-US/dataset.json | 1 + web/service/datasets.ts | 21 + web/service/knowledge/use-document.ts | 24 +- web/utils/download.ts | 34 ++ 20 files changed, 1226 insertions(+), 10 deletions(-) create mode 100644 api/agent-notes/controllers/console/datasets/datasets_document.py.md create mode 100644 api/agent-notes/services/dataset_service.py.md create mode 100644 api/agent-notes/services/file_service.py.md create mode 100644 api/agent-notes/tests/unit_tests/controllers/console/datasets/test_datasets_document_download.py.md create mode 100644 api/agent-notes/tests/unit_tests/services/test_file_service_zip_and_lookup.py.md create mode 100644 api/tests/unit_tests/controllers/console/datasets/test_datasets_document_download.py create mode 100644 api/tests/unit_tests/services/test_file_service_zip_and_lookup.py create mode 100644 web/utils/download.ts diff --git a/api/agent-notes/controllers/console/datasets/datasets_document.py.md b/api/agent-notes/controllers/console/datasets/datasets_document.py.md new file mode 100644 index 0000000000..b100249981 --- /dev/null +++ b/api/agent-notes/controllers/console/datasets/datasets_document.py.md @@ -0,0 +1,52 @@ +## Purpose + +`api/controllers/console/datasets/datasets_document.py` contains the console (authenticated) APIs for managing dataset documents (list/create/update/delete, processing controls, estimates, etc.). + +## Storage model (uploaded files) + +- For local file uploads into a knowledge base, the binary is stored via `extensions.ext_storage.storage` under the key: + - `upload_files//.` +- File metadata is stored in the `upload_files` table (`UploadFile` model), keyed by `UploadFile.id`. +- Dataset `Document` records reference the uploaded file via: + - `Document.data_source_info.upload_file_id` + +## Download endpoint + +- `GET /datasets//documents//download` + + - Only supported when `Document.data_source_type == "upload_file"`. + - Performs dataset permission + tenant checks via `DocumentResource.get_document(...)`. + - Delegates `Document -> UploadFile` validation and signed URL generation to `DocumentService.get_document_download_url(...)`. + - Applies `cloud_edition_billing_rate_limit_check("knowledge")` to match other KB operations. + - Response body is **only**: `{ "url": "" }`. + +- `POST /datasets//documents/download-zip` + + - Accepts `{ "document_ids": ["..."] }` (upload-file only). + - Returns `application/zip` as a single attachment download. + - Rationale: browsers often block multiple automatic downloads; a ZIP avoids that limitation. + - Applies `cloud_edition_billing_rate_limit_check("knowledge")`. + - Delegates dataset permission checks, document/upload-file validation, and download-name generation to + `DocumentService.prepare_document_batch_download_zip(...)` before streaming the ZIP. + +## Verification plan + +- Upload a document from a local file into a dataset. +- Call the download endpoint and confirm it returns a signed URL. +- Open the URL and confirm: + - Response headers force download (`Content-Disposition`), and + - Downloaded bytes match the uploaded file. +- Select multiple uploaded-file documents and download as ZIP; confirm all selected files exist in the archive. + +## Shared helper + +- `DocumentService.get_document_download_url(document)` resolves the `UploadFile` and signs a download URL. +- `DocumentService.prepare_document_batch_download_zip(...)` performs dataset permission checks, batches + document + upload file lookups, preserves request order, and generates the client-visible ZIP filename. +- Internal helpers now live in `DocumentService` (`_get_upload_file_id_for_upload_file_document(...)`, + `_get_upload_file_for_upload_file_document(...)`, `_get_upload_files_by_document_id_for_zip_download(...)`). +- ZIP packing is handled by `FileService.build_upload_files_zip_tempfile(...)`, which also: + - sanitizes entry names to avoid path traversal, and + - deduplicates names while preserving extensions (e.g., `doc.txt` → `doc (1).txt`). + Streaming the response and deferring cleanup is handled by the route via `send_file(path, ...)` + `ExitStack` + + `response.call_on_close(...)` (the file is deleted when the response is closed). diff --git a/api/agent-notes/services/dataset_service.py.md b/api/agent-notes/services/dataset_service.py.md new file mode 100644 index 0000000000..b68ef345f5 --- /dev/null +++ b/api/agent-notes/services/dataset_service.py.md @@ -0,0 +1,18 @@ +## Purpose + +`api/services/dataset_service.py` hosts dataset/document service logic used by console and API controllers. + +## Batch document operations + +- Batch document workflows should avoid N+1 database queries by using set-based lookups. +- Tenant checks must be enforced consistently across dataset/document operations. +- `DocumentService.get_documents_by_ids(...)` fetches documents for a dataset using `id.in_(...)`. +- `FileService.get_upload_files_by_ids(...)` performs tenant-scoped batch lookup for `UploadFile` (dedupes ids with `set(...)`). +- `DocumentService.get_document_download_url(...)` and `prepare_document_batch_download_zip(...)` handle + dataset/document permission checks plus `Document -> UploadFile` validation for download endpoints. + +## Verification plan + +- Exercise document list and download endpoints that use the service helpers. +- Confirm batch download uses constant query count for documents + upload files. +- Request a ZIP with a missing document id and confirm a 404 is returned. diff --git a/api/agent-notes/services/file_service.py.md b/api/agent-notes/services/file_service.py.md new file mode 100644 index 0000000000..cf394a1c05 --- /dev/null +++ b/api/agent-notes/services/file_service.py.md @@ -0,0 +1,35 @@ +## Purpose + +`api/services/file_service.py` owns business logic around `UploadFile` objects: upload validation, storage persistence, +previews/generators, and deletion. + +## Key invariants + +- All storage I/O goes through `extensions.ext_storage.storage`. +- Uploaded file keys follow: `upload_files//.`. +- Upload validation is enforced in `FileService.upload_file(...)` (blocked extensions, size limits, dataset-only types). + +## Batch lookup helpers + +- `FileService.get_upload_files_by_ids(tenant_id, upload_file_ids)` is the canonical tenant-scoped batch loader for + `UploadFile`. + +## Dataset document download helpers + +The dataset document download/ZIP endpoints now delegate “Document → UploadFile” validation and permission checks to +`DocumentService` (`api/services/dataset_service.py`). `FileService` stays focused on generic `UploadFile` operations +(uploading, previews, deletion), plus generic ZIP serving. + +### ZIP serving + +- `FileService.build_upload_files_zip_tempfile(...)` builds a ZIP from `UploadFile` objects and yields a seeked + tempfile **path** so callers can stream it (e.g., `send_file(path, ...)`) without hitting "read of closed file" + issues from file-handle lifecycle during streamed responses. +- Flask `send_file(...)` and the `ExitStack`/`call_on_close(...)` cleanup pattern are handled in the route layer. + +## Verification plan + +- Unit: `api/tests/unit_tests/controllers/console/datasets/test_datasets_document_download.py` + - Verify signed URL generation for upload-file documents and ZIP download behavior for multiple documents. +- Unit: `api/tests/unit_tests/services/test_file_service_zip_and_lookup.py` + - Verify ZIP packing produces a valid, openable archive and preserves file content. diff --git a/api/agent-notes/tests/unit_tests/controllers/console/datasets/test_datasets_document_download.py.md b/api/agent-notes/tests/unit_tests/controllers/console/datasets/test_datasets_document_download.py.md new file mode 100644 index 0000000000..8f78dacde8 --- /dev/null +++ b/api/agent-notes/tests/unit_tests/controllers/console/datasets/test_datasets_document_download.py.md @@ -0,0 +1,28 @@ +## Purpose + +Unit tests for the console dataset document download endpoint: + +- `GET /datasets//documents//download` + +## Testing approach + +- Uses `Flask.test_request_context()` and calls the `Resource.get(...)` method directly. +- Monkeypatches console decorators (`login_required`, `setup_required`, rate limit) to no-ops to keep the test focused. +- Mocks: + - `DatasetService.get_dataset` / `check_dataset_permission` + - `DocumentService.get_document` for single-file download tests + - `DocumentService.get_documents_by_ids` + `FileService.get_upload_files_by_ids` for ZIP download tests + - `FileService.get_upload_files_by_ids` for `UploadFile` lookups in single-file tests + - `services.dataset_service.file_helpers.get_signed_file_url` to return a deterministic URL +- Document mocks include `id` fields so batch lookups can map documents by id. + +## Covered cases + +- Success returns `{ "url": "" }` for upload-file documents. +- 404 when document is not `upload_file`. +- 404 when `upload_file_id` is missing. +- 404 when referenced `UploadFile` row does not exist. +- 403 when document tenant does not match current tenant. +- Batch ZIP download returns `application/zip` for upload-file documents. +- Batch ZIP download rejects non-upload-file documents. +- Batch ZIP download uses a random `.zip` attachment name (`download_name`), so tests only assert the suffix. diff --git a/api/agent-notes/tests/unit_tests/services/test_file_service_zip_and_lookup.py.md b/api/agent-notes/tests/unit_tests/services/test_file_service_zip_and_lookup.py.md new file mode 100644 index 0000000000..dbcdf26f10 --- /dev/null +++ b/api/agent-notes/tests/unit_tests/services/test_file_service_zip_and_lookup.py.md @@ -0,0 +1,18 @@ +## Purpose + +Unit tests for `api/services/file_service.py` helper methods that are not covered by higher-level controller tests. + +## What’s covered + +- `FileService.build_upload_files_zip_tempfile(...)` + - ZIP entry name sanitization (no directory components / traversal) + - name deduplication while preserving extensions + - writing streamed bytes from `storage.load(...)` into ZIP entries + - yields a tempfile path so callers can open/stream the ZIP without holding a live file handle +- `FileService.get_upload_files_by_ids(...)` + - returns `{}` for empty id lists + - returns an id-keyed mapping for non-empty lists + +## Notes + +- These tests intentionally stub `storage.load` and `db.session.scalars(...).all()` to avoid needing a real DB/storage. diff --git a/api/controllers/console/datasets/datasets_document.py b/api/controllers/console/datasets/datasets_document.py index 707d90f044..2599e6293a 100644 --- a/api/controllers/console/datasets/datasets_document.py +++ b/api/controllers/console/datasets/datasets_document.py @@ -2,10 +2,12 @@ import json import logging from argparse import ArgumentTypeError from collections.abc import Sequence -from typing import Literal, cast +from contextlib import ExitStack +from typing import Any, Literal, cast +from uuid import UUID import sqlalchemy as sa -from flask import request +from flask import request, send_file from flask_restx import Resource, fields, marshal, marshal_with from pydantic import BaseModel, Field from sqlalchemy import asc, desc, select @@ -42,6 +44,7 @@ from models import DatasetProcessRule, Document, DocumentSegment, UploadFile from models.dataset import DocumentPipelineExecutionLog from services.dataset_service import DatasetService, DocumentService from services.entities.knowledge_entities.knowledge_entities import KnowledgeConfig, ProcessRule, RetrievalModel +from services.file_service import FileService from ..app.error import ( ProviderModelCurrentlyNotSupportError, @@ -65,6 +68,9 @@ from ..wraps import ( logger = logging.getLogger(__name__) +# NOTE: Keep constants near the top of the module for discoverability. +DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS = 100 + def _get_or_create_model(model_name: str, field_def): existing = console_ns.models.get(model_name) @@ -104,6 +110,12 @@ class DocumentRenamePayload(BaseModel): name: str +class DocumentBatchDownloadZipPayload(BaseModel): + """Request payload for bulk downloading documents as a zip archive.""" + + document_ids: list[UUID] = Field(..., min_length=1, max_length=DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS) + + class DocumentDatasetListParam(BaseModel): page: int = Field(1, title="Page", description="Page number.") limit: int = Field(20, title="Limit", description="Page size.") @@ -120,6 +132,7 @@ register_schema_models( RetrievalModel, DocumentRetryPayload, DocumentRenamePayload, + DocumentBatchDownloadZipPayload, ) @@ -853,6 +866,62 @@ class DocumentApi(DocumentResource): return {"result": "success"}, 204 +@console_ns.route("/datasets//documents//download") +class DocumentDownloadApi(DocumentResource): + """Return a signed download URL for a dataset document's original uploaded file.""" + + @console_ns.doc("get_dataset_document_download_url") + @console_ns.doc(description="Get a signed download URL for a dataset document's original uploaded file") + @setup_required + @login_required + @account_initialization_required + @cloud_edition_billing_rate_limit_check("knowledge") + def get(self, dataset_id: str, document_id: str) -> dict[str, Any]: + # Reuse the shared permission/tenant checks implemented in DocumentResource. + document = self.get_document(str(dataset_id), str(document_id)) + return {"url": DocumentService.get_document_download_url(document)} + + +@console_ns.route("/datasets//documents/download-zip") +class DocumentBatchDownloadZipApi(DocumentResource): + """Download multiple uploaded-file documents as a single ZIP (avoids browser multi-download limits).""" + + @console_ns.doc("download_dataset_documents_as_zip") + @console_ns.doc(description="Download selected dataset documents as a single ZIP archive (upload-file only)") + @setup_required + @login_required + @account_initialization_required + @cloud_edition_billing_rate_limit_check("knowledge") + @console_ns.expect(console_ns.models[DocumentBatchDownloadZipPayload.__name__]) + def post(self, dataset_id: str): + """Stream a ZIP archive containing the requested uploaded documents.""" + # Parse and validate request payload. + payload = DocumentBatchDownloadZipPayload.model_validate(console_ns.payload or {}) + + current_user, current_tenant_id = current_account_with_tenant() + dataset_id = str(dataset_id) + document_ids: list[str] = [str(document_id) for document_id in payload.document_ids] + upload_files, download_name = DocumentService.prepare_document_batch_download_zip( + dataset_id=dataset_id, + document_ids=document_ids, + tenant_id=current_tenant_id, + current_user=current_user, + ) + + # Delegate ZIP packing to FileService, but keep Flask response+cleanup in the route. + with ExitStack() as stack: + zip_path = stack.enter_context(FileService.build_upload_files_zip_tempfile(upload_files=upload_files)) + response = send_file( + zip_path, + mimetype="application/zip", + as_attachment=True, + download_name=download_name, + ) + cleanup = stack.pop_all() + response.call_on_close(cleanup.close) + return response + + @console_ns.route("/datasets//documents//processing/") class DocumentProcessingApi(DocumentResource): @console_ns.doc("update_document_processing") diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index 18e5613438..be9a0e9279 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -13,10 +13,11 @@ import sqlalchemy as sa from redis.exceptions import LockNotOwnedError from sqlalchemy import exists, func, select from sqlalchemy.orm import Session -from werkzeug.exceptions import NotFound +from werkzeug.exceptions import Forbidden, NotFound from configs import dify_config from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError +from core.file import helpers as file_helpers from core.helper.name_generator import generate_incremental_name from core.model_manager import ModelManager from core.model_runtime.entities.model_entities import ModelFeature, ModelType @@ -73,6 +74,7 @@ from services.errors.document import DocumentIndexingError from services.errors.file import FileNotExistsError from services.external_knowledge_service import ExternalDatasetService from services.feature_service import FeatureModel, FeatureService +from services.file_service import FileService from services.rag_pipeline.rag_pipeline import RagPipelineService from services.tag_service import TagService from services.vector_service import VectorService @@ -1162,6 +1164,7 @@ class DocumentService: Document.archived.is_(True), ), } + DOCUMENT_BATCH_DOWNLOAD_ZIP_FILENAME_EXTENSION = ".zip" @classmethod def normalize_display_status(cls, status: str | None) -> str | None: @@ -1288,6 +1291,143 @@ class DocumentService: else: return None + @staticmethod + def get_documents_by_ids(dataset_id: str, document_ids: Sequence[str]) -> Sequence[Document]: + """Fetch documents for a dataset in a single batch query.""" + if not document_ids: + return [] + document_id_list: list[str] = [str(document_id) for document_id in document_ids] + # Fetch all requested documents in one query to avoid N+1 lookups. + documents: Sequence[Document] = db.session.scalars( + select(Document).where( + Document.dataset_id == dataset_id, + Document.id.in_(document_id_list), + ) + ).all() + return documents + + @staticmethod + def get_document_download_url(document: Document) -> str: + """ + Return a signed download URL for an upload-file document. + """ + upload_file = DocumentService._get_upload_file_for_upload_file_document(document) + return file_helpers.get_signed_file_url(upload_file_id=upload_file.id, as_attachment=True) + + @staticmethod + def prepare_document_batch_download_zip( + *, + dataset_id: str, + document_ids: Sequence[str], + tenant_id: str, + current_user: Account, + ) -> tuple[list[UploadFile], str]: + """ + Resolve upload files for batch ZIP downloads and generate a client-visible filename. + """ + dataset = DatasetService.get_dataset(dataset_id) + if not dataset: + raise NotFound("Dataset not found.") + try: + DatasetService.check_dataset_permission(dataset, current_user) + except NoPermissionError as e: + raise Forbidden(str(e)) + + upload_files_by_document_id = DocumentService._get_upload_files_by_document_id_for_zip_download( + dataset_id=dataset_id, + document_ids=document_ids, + tenant_id=tenant_id, + ) + upload_files = [upload_files_by_document_id[document_id] for document_id in document_ids] + download_name = DocumentService._generate_document_batch_download_zip_filename() + return upload_files, download_name + + @staticmethod + def _generate_document_batch_download_zip_filename() -> str: + """ + Generate a random attachment filename for the batch download ZIP. + """ + return f"{uuid.uuid4().hex}{DocumentService.DOCUMENT_BATCH_DOWNLOAD_ZIP_FILENAME_EXTENSION}" + + @staticmethod + def _get_upload_file_id_for_upload_file_document( + document: Document, + *, + invalid_source_message: str, + missing_file_message: str, + ) -> str: + """ + Normalize and validate `Document -> UploadFile` linkage for download flows. + """ + if document.data_source_type != "upload_file": + raise NotFound(invalid_source_message) + + data_source_info: dict[str, Any] = document.data_source_info_dict or {} + upload_file_id: str | None = data_source_info.get("upload_file_id") + if not upload_file_id: + raise NotFound(missing_file_message) + + return str(upload_file_id) + + @staticmethod + def _get_upload_file_for_upload_file_document(document: Document) -> UploadFile: + """ + Load the `UploadFile` row for an upload-file document. + """ + upload_file_id = DocumentService._get_upload_file_id_for_upload_file_document( + document, + invalid_source_message="Document does not have an uploaded file to download.", + missing_file_message="Uploaded file not found.", + ) + upload_files_by_id = FileService.get_upload_files_by_ids(document.tenant_id, [upload_file_id]) + upload_file = upload_files_by_id.get(upload_file_id) + if not upload_file: + raise NotFound("Uploaded file not found.") + return upload_file + + @staticmethod + def _get_upload_files_by_document_id_for_zip_download( + *, + dataset_id: str, + document_ids: Sequence[str], + tenant_id: str, + ) -> dict[str, UploadFile]: + """ + Batch load upload files keyed by document id for ZIP downloads. + """ + document_id_list: list[str] = [str(document_id) for document_id in document_ids] + + documents = DocumentService.get_documents_by_ids(dataset_id, document_id_list) + documents_by_id: dict[str, Document] = {str(document.id): document for document in documents} + + missing_document_ids: set[str] = set(document_id_list) - set(documents_by_id.keys()) + if missing_document_ids: + raise NotFound("Document not found.") + + upload_file_ids: list[str] = [] + upload_file_ids_by_document_id: dict[str, str] = {} + for document_id, document in documents_by_id.items(): + if document.tenant_id != tenant_id: + raise Forbidden("No permission.") + + upload_file_id = DocumentService._get_upload_file_id_for_upload_file_document( + document, + invalid_source_message="Only uploaded-file documents can be downloaded as ZIP.", + missing_file_message="Only uploaded-file documents can be downloaded as ZIP.", + ) + upload_file_ids.append(upload_file_id) + upload_file_ids_by_document_id[document_id] = upload_file_id + + upload_files_by_id = FileService.get_upload_files_by_ids(tenant_id, upload_file_ids) + missing_upload_file_ids: set[str] = set(upload_file_ids) - set(upload_files_by_id.keys()) + if missing_upload_file_ids: + raise NotFound("Only uploaded-file documents can be downloaded as ZIP.") + + return { + document_id: upload_files_by_id[upload_file_id] + for document_id, upload_file_id in upload_file_ids_by_document_id.items() + } + @staticmethod def get_document_by_id(document_id: str) -> Document | None: document = db.session.query(Document).where(Document.id == document_id).first() diff --git a/api/services/file_service.py b/api/services/file_service.py index 0911cf38c4..a0a99f3f82 100644 --- a/api/services/file_service.py +++ b/api/services/file_service.py @@ -2,7 +2,11 @@ import base64 import hashlib import os import uuid +from collections.abc import Iterator, Sequence +from contextlib import contextmanager, suppress +from tempfile import NamedTemporaryFile from typing import Literal, Union +from zipfile import ZIP_DEFLATED, ZipFile from sqlalchemy import Engine, select from sqlalchemy.orm import Session, sessionmaker @@ -17,6 +21,7 @@ from constants import ( ) from core.file import helpers as file_helpers from core.rag.extractor.extract_processor import ExtractProcessor +from extensions.ext_database import db from extensions.ext_storage import storage from libs.datetime_utils import naive_utc_now from libs.helper import extract_tenant_id @@ -167,6 +172,9 @@ class FileService: return upload_file def get_file_preview(self, file_id: str): + """ + Return a short text preview extracted from a document file. + """ with self._session_maker(expire_on_commit=False) as session: upload_file = session.query(UploadFile).where(UploadFile.id == file_id).first() @@ -253,3 +261,101 @@ class FileService: return storage.delete(upload_file.key) session.delete(upload_file) + + @staticmethod + def get_upload_files_by_ids(tenant_id: str, upload_file_ids: Sequence[str]) -> dict[str, UploadFile]: + """ + Fetch `UploadFile` rows for a tenant in a single batch query. + + This is a generic `UploadFile` lookup helper (not dataset/document specific), so it lives in `FileService`. + """ + if not upload_file_ids: + return {} + + # Normalize and deduplicate ids before using them in the IN clause. + upload_file_id_list: list[str] = [str(upload_file_id) for upload_file_id in upload_file_ids] + unique_upload_file_ids: list[str] = list(set(upload_file_id_list)) + + # Fetch upload files in one query for efficient batch access. + upload_files: Sequence[UploadFile] = db.session.scalars( + select(UploadFile).where( + UploadFile.tenant_id == tenant_id, + UploadFile.id.in_(unique_upload_file_ids), + ) + ).all() + return {str(upload_file.id): upload_file for upload_file in upload_files} + + @staticmethod + def _sanitize_zip_entry_name(name: str) -> str: + """ + Sanitize a ZIP entry name to avoid path traversal and weird separators. + + We keep this conservative: the upload flow already rejects `/` and `\\`, but older rows (or imported data) + could still contain unsafe names. + """ + # Drop any directory components and prevent empty names. + base = os.path.basename(name).strip() or "file" + + # ZIP uses forward slashes as separators; remove any residual separator characters. + return base.replace("/", "_").replace("\\", "_") + + @staticmethod + def _dedupe_zip_entry_name(original_name: str, used_names: set[str]) -> str: + """ + Return a unique ZIP entry name, inserting suffixes before the extension. + """ + # Keep the original name when it's not already used. + if original_name not in used_names: + return original_name + + # Insert suffixes before the extension (e.g., "doc.txt" -> "doc (1).txt"). + stem, extension = os.path.splitext(original_name) + suffix = 1 + while True: + candidate = f"{stem} ({suffix}){extension}" + if candidate not in used_names: + return candidate + suffix += 1 + + @staticmethod + @contextmanager + def build_upload_files_zip_tempfile( + *, + upload_files: Sequence[UploadFile], + ) -> Iterator[str]: + """ + Build a ZIP from `UploadFile`s and yield a tempfile path. + + We yield a path (rather than an open file handle) to avoid "read of closed file" issues when Flask/Werkzeug + streams responses. The caller is expected to keep this context open until the response is fully sent, then + close it (e.g., via `response.call_on_close(...)`) to delete the tempfile. + """ + used_names: set[str] = set() + + # Build a ZIP in a temp file and keep it on disk until the caller finishes streaming it. + tmp_path: str | None = None + try: + with NamedTemporaryFile(mode="w+b", suffix=".zip", delete=False) as tmp: + tmp_path = tmp.name + with ZipFile(tmp, mode="w", compression=ZIP_DEFLATED) as zf: + for upload_file in upload_files: + # Ensure the entry name is safe and unique. + safe_name = FileService._sanitize_zip_entry_name(upload_file.name) + arcname = FileService._dedupe_zip_entry_name(safe_name, used_names) + used_names.add(arcname) + + # Stream file bytes from storage into the ZIP entry. + with zf.open(arcname, "w") as entry: + for chunk in storage.load(upload_file.key, stream=True): + entry.write(chunk) + + # Flush so `send_file(path, ...)` can re-open it safely on all platforms. + tmp.flush() + + assert tmp_path is not None + yield tmp_path + finally: + # Remove the temp file when the context is closed (typically after the response finishes streaming). + if tmp_path is not None: + with suppress(FileNotFoundError): + os.remove(tmp_path) diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets_document_download.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets_document_download.py new file mode 100644 index 0000000000..d5d7ee95c5 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets_document_download.py @@ -0,0 +1,430 @@ +""" +Unit tests for the dataset document download endpoint. + +These tests validate that the controller returns a signed download URL for +upload-file documents, and rejects unsupported or missing file cases. +""" + +from __future__ import annotations + +import importlib +import sys +from collections import UserDict +from io import BytesIO +from types import SimpleNamespace +from typing import Any +from zipfile import ZipFile + +import pytest +from flask import Flask +from werkzeug.exceptions import Forbidden, NotFound + + +@pytest.fixture +def app() -> Flask: + """Create a minimal Flask app for request-context based controller tests.""" + app = Flask(__name__) + app.config["TESTING"] = True + return app + + +@pytest.fixture +def datasets_document_module(monkeypatch: pytest.MonkeyPatch): + """ + Reload `controllers.console.datasets.datasets_document` with lightweight decorators. + + We patch auth / setup / rate-limit decorators to no-ops so we can unit test the + controller logic without requiring the full console stack. + """ + + from controllers.console import console_ns, wraps + from libs import login + + def _noop(func): # type: ignore[no-untyped-def] + return func + + # Bypass login/setup/account checks in unit tests. + monkeypatch.setattr(login, "login_required", _noop) + monkeypatch.setattr(wraps, "setup_required", _noop) + monkeypatch.setattr(wraps, "account_initialization_required", _noop) + + # Bypass billing-related decorators used by other endpoints in this module. + monkeypatch.setattr(wraps, "cloud_edition_billing_resource_check", lambda *_args, **_kwargs: (lambda f: f)) + monkeypatch.setattr(wraps, "cloud_edition_billing_rate_limit_check", lambda *_args, **_kwargs: (lambda f: f)) + + # Avoid Flask-RESTX route registration side effects during import. + def _noop_route(*_args, **_kwargs): # type: ignore[override] + def _decorator(cls): + return cls + + return _decorator + + monkeypatch.setattr(console_ns, "route", _noop_route) + + module_name = "controllers.console.datasets.datasets_document" + sys.modules.pop(module_name, None) + return importlib.import_module(module_name) + + +def _mock_user(*, is_dataset_editor: bool = True) -> SimpleNamespace: + """Build a minimal user object compatible with dataset permission checks.""" + return SimpleNamespace(is_dataset_editor=is_dataset_editor, id="user-123") + + +def _mock_document( + *, + document_id: str, + tenant_id: str, + data_source_type: str, + upload_file_id: str | None, +) -> SimpleNamespace: + """Build a minimal document object used by the controller.""" + data_source_info_dict: dict[str, Any] | None = None + if upload_file_id is not None: + data_source_info_dict = {"upload_file_id": upload_file_id} + else: + data_source_info_dict = {} + + return SimpleNamespace( + id=document_id, + tenant_id=tenant_id, + data_source_type=data_source_type, + data_source_info_dict=data_source_info_dict, + ) + + +def _wire_common_success_mocks( + *, + module, + monkeypatch: pytest.MonkeyPatch, + current_tenant_id: str, + document_tenant_id: str, + data_source_type: str, + upload_file_id: str | None, + upload_file_exists: bool, + signed_url: str, +) -> None: + """Patch controller dependencies to create a deterministic test environment.""" + import services.dataset_service as dataset_service_module + + # Make `current_account_with_tenant()` return a known user + tenant id. + monkeypatch.setattr(module, "current_account_with_tenant", lambda: (_mock_user(), current_tenant_id)) + + # Return a dataset object and allow permission checks to pass. + monkeypatch.setattr(module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1")) + monkeypatch.setattr(module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None) + + # Return a document that will be validated inside DocumentResource.get_document. + document = _mock_document( + document_id="doc-1", + tenant_id=document_tenant_id, + data_source_type=data_source_type, + upload_file_id=upload_file_id, + ) + monkeypatch.setattr(module.DocumentService, "get_document", lambda *_args, **_kwargs: document) + + # Mock UploadFile lookup via FileService batch helper. + upload_files_by_id: dict[str, Any] = {} + if upload_file_exists and upload_file_id is not None: + upload_files_by_id[str(upload_file_id)] = SimpleNamespace(id=str(upload_file_id)) + monkeypatch.setattr(module.FileService, "get_upload_files_by_ids", lambda *_args, **_kwargs: upload_files_by_id) + + # Mock signing helper so the returned URL is deterministic. + monkeypatch.setattr(dataset_service_module.file_helpers, "get_signed_file_url", lambda **_kwargs: signed_url) + + +def _mock_send_file(obj, **kwargs): # type: ignore[no-untyped-def] + """Return a lightweight representation of `send_file(...)` for unit tests.""" + + class _ResponseMock(UserDict): + def __init__(self, sent_file: object, send_file_kwargs: dict[str, object]) -> None: + super().__init__({"_sent_file": sent_file, "_send_file_kwargs": send_file_kwargs}) + self._on_close: object | None = None + + def call_on_close(self, func): # type: ignore[no-untyped-def] + self._on_close = func + return func + + return _ResponseMock(obj, kwargs) + + +def test_batch_download_zip_returns_send_file( + app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch +) -> None: + """Ensure batch ZIP download returns a zip attachment via `send_file`.""" + + # Arrange common permission mocks. + monkeypatch.setattr(datasets_document_module, "current_account_with_tenant", lambda: (_mock_user(), "tenant-123")) + monkeypatch.setattr( + datasets_document_module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1") + ) + monkeypatch.setattr( + datasets_document_module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None + ) + + # Two upload-file documents, each referencing an UploadFile. + doc1 = _mock_document( + document_id="11111111-1111-1111-1111-111111111111", + tenant_id="tenant-123", + data_source_type="upload_file", + upload_file_id="file-1", + ) + doc2 = _mock_document( + document_id="22222222-2222-2222-2222-222222222222", + tenant_id="tenant-123", + data_source_type="upload_file", + upload_file_id="file-2", + ) + monkeypatch.setattr( + datasets_document_module.DocumentService, + "get_documents_by_ids", + lambda *_args, **_kwargs: [doc1, doc2], + ) + monkeypatch.setattr( + datasets_document_module.FileService, + "get_upload_files_by_ids", + lambda *_args, **_kwargs: { + "file-1": SimpleNamespace(id="file-1", name="a.txt", key="k1"), + "file-2": SimpleNamespace(id="file-2", name="b.txt", key="k2"), + }, + ) + + # Mock storage streaming content. + import services.file_service as file_service_module + + monkeypatch.setattr(file_service_module.storage, "load", lambda _key, stream=True: [b"hello"]) + + # Replace send_file used by the controller to avoid a real Flask response object. + monkeypatch.setattr(datasets_document_module, "send_file", _mock_send_file) + + # Act + with app.test_request_context( + "/datasets/ds-1/documents/download-zip", + method="POST", + json={"document_ids": ["11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"]}, + ): + api = datasets_document_module.DocumentBatchDownloadZipApi() + result = api.post(dataset_id="ds-1") + + # Assert: we returned via send_file with correct mime type and attachment. + assert result["_send_file_kwargs"]["mimetype"] == "application/zip" + assert result["_send_file_kwargs"]["as_attachment"] is True + assert isinstance(result["_send_file_kwargs"]["download_name"], str) + assert result["_send_file_kwargs"]["download_name"].endswith(".zip") + # Ensure our cleanup hook is registered and execute it to avoid temp file leaks in unit tests. + assert getattr(result, "_on_close", None) is not None + result._on_close() # type: ignore[attr-defined] + + +def test_batch_download_zip_response_is_openable_zip( + app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch +) -> None: + """Ensure the real Flask `send_file` response body is a valid ZIP that can be opened.""" + + # Arrange: same controller mocks as the lightweight send_file test, but we keep the real `send_file`. + monkeypatch.setattr(datasets_document_module, "current_account_with_tenant", lambda: (_mock_user(), "tenant-123")) + monkeypatch.setattr( + datasets_document_module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1") + ) + monkeypatch.setattr( + datasets_document_module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None + ) + + doc1 = _mock_document( + document_id="33333333-3333-3333-3333-333333333333", + tenant_id="tenant-123", + data_source_type="upload_file", + upload_file_id="file-1", + ) + doc2 = _mock_document( + document_id="44444444-4444-4444-4444-444444444444", + tenant_id="tenant-123", + data_source_type="upload_file", + upload_file_id="file-2", + ) + monkeypatch.setattr( + datasets_document_module.DocumentService, + "get_documents_by_ids", + lambda *_args, **_kwargs: [doc1, doc2], + ) + monkeypatch.setattr( + datasets_document_module.FileService, + "get_upload_files_by_ids", + lambda *_args, **_kwargs: { + "file-1": SimpleNamespace(id="file-1", name="a.txt", key="k1"), + "file-2": SimpleNamespace(id="file-2", name="b.txt", key="k2"), + }, + ) + + # Stream distinct bytes per key so we can verify both ZIP entries. + import services.file_service as file_service_module + + monkeypatch.setattr( + file_service_module.storage, "load", lambda key, stream=True: [b"one"] if key == "k1" else [b"two"] + ) + + # Act + with app.test_request_context( + "/datasets/ds-1/documents/download-zip", + method="POST", + json={"document_ids": ["33333333-3333-3333-3333-333333333333", "44444444-4444-4444-4444-444444444444"]}, + ): + api = datasets_document_module.DocumentBatchDownloadZipApi() + response = api.post(dataset_id="ds-1") + + # Assert: response body is a valid ZIP and contains the expected entries. + response.direct_passthrough = False + data = response.get_data() + response.close() + + with ZipFile(BytesIO(data), mode="r") as zf: + assert zf.namelist() == ["a.txt", "b.txt"] + assert zf.read("a.txt") == b"one" + assert zf.read("b.txt") == b"two" + + +def test_batch_download_zip_rejects_non_upload_file_document( + app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch +) -> None: + """Ensure batch ZIP download rejects non upload-file documents.""" + + monkeypatch.setattr(datasets_document_module, "current_account_with_tenant", lambda: (_mock_user(), "tenant-123")) + monkeypatch.setattr( + datasets_document_module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1") + ) + monkeypatch.setattr( + datasets_document_module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None + ) + + doc = _mock_document( + document_id="55555555-5555-5555-5555-555555555555", + tenant_id="tenant-123", + data_source_type="website_crawl", + upload_file_id="file-1", + ) + monkeypatch.setattr( + datasets_document_module.DocumentService, + "get_documents_by_ids", + lambda *_args, **_kwargs: [doc], + ) + + with app.test_request_context( + "/datasets/ds-1/documents/download-zip", + method="POST", + json={"document_ids": ["55555555-5555-5555-5555-555555555555"]}, + ): + api = datasets_document_module.DocumentBatchDownloadZipApi() + with pytest.raises(NotFound): + api.post(dataset_id="ds-1") + + +def test_document_download_returns_url_for_upload_file_document( + app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch +) -> None: + """Ensure upload-file documents return a `{url}` JSON payload.""" + + _wire_common_success_mocks( + module=datasets_document_module, + monkeypatch=monkeypatch, + current_tenant_id="tenant-123", + document_tenant_id="tenant-123", + data_source_type="upload_file", + upload_file_id="file-123", + upload_file_exists=True, + signed_url="https://example.com/signed", + ) + + # Build a request context then call the resource method directly. + with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"): + api = datasets_document_module.DocumentDownloadApi() + result = api.get(dataset_id="ds-1", document_id="doc-1") + + assert result == {"url": "https://example.com/signed"} + + +def test_document_download_rejects_non_upload_file_document( + app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch +) -> None: + """Ensure non-upload documents raise 404 (no file to download).""" + + _wire_common_success_mocks( + module=datasets_document_module, + monkeypatch=monkeypatch, + current_tenant_id="tenant-123", + document_tenant_id="tenant-123", + data_source_type="website_crawl", + upload_file_id="file-123", + upload_file_exists=True, + signed_url="https://example.com/signed", + ) + + with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"): + api = datasets_document_module.DocumentDownloadApi() + with pytest.raises(NotFound): + api.get(dataset_id="ds-1", document_id="doc-1") + + +def test_document_download_rejects_missing_upload_file_id( + app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch +) -> None: + """Ensure missing `upload_file_id` raises 404.""" + + _wire_common_success_mocks( + module=datasets_document_module, + monkeypatch=monkeypatch, + current_tenant_id="tenant-123", + document_tenant_id="tenant-123", + data_source_type="upload_file", + upload_file_id=None, + upload_file_exists=False, + signed_url="https://example.com/signed", + ) + + with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"): + api = datasets_document_module.DocumentDownloadApi() + with pytest.raises(NotFound): + api.get(dataset_id="ds-1", document_id="doc-1") + + +def test_document_download_rejects_when_upload_file_record_missing( + app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch +) -> None: + """Ensure missing UploadFile row raises 404.""" + + _wire_common_success_mocks( + module=datasets_document_module, + monkeypatch=monkeypatch, + current_tenant_id="tenant-123", + document_tenant_id="tenant-123", + data_source_type="upload_file", + upload_file_id="file-123", + upload_file_exists=False, + signed_url="https://example.com/signed", + ) + + with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"): + api = datasets_document_module.DocumentDownloadApi() + with pytest.raises(NotFound): + api.get(dataset_id="ds-1", document_id="doc-1") + + +def test_document_download_rejects_tenant_mismatch( + app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch +) -> None: + """Ensure tenant mismatch is rejected by the shared `get_document()` permission check.""" + + _wire_common_success_mocks( + module=datasets_document_module, + monkeypatch=monkeypatch, + current_tenant_id="tenant-123", + document_tenant_id="tenant-999", + data_source_type="upload_file", + upload_file_id="file-123", + upload_file_exists=True, + signed_url="https://example.com/signed", + ) + + with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"): + api = datasets_document_module.DocumentDownloadApi() + with pytest.raises(Forbidden): + api.get(dataset_id="ds-1", document_id="doc-1") diff --git a/api/tests/unit_tests/services/test_file_service_zip_and_lookup.py b/api/tests/unit_tests/services/test_file_service_zip_and_lookup.py new file mode 100644 index 0000000000..7b4d349e33 --- /dev/null +++ b/api/tests/unit_tests/services/test_file_service_zip_and_lookup.py @@ -0,0 +1,99 @@ +""" +Unit tests for `services.file_service.FileService` helpers. + +We keep these tests focused on: +- ZIP tempfile building (sanitization + deduplication + content writes) +- tenant-scoped batch lookup behavior (`get_upload_files_by_ids`) +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any +from zipfile import ZipFile + +import pytest + +import services.file_service as file_service_module +from services.file_service import FileService + + +def test_build_upload_files_zip_tempfile_sanitizes_and_dedupes_names(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure ZIP entry names are safe and unique while preserving extensions.""" + + # Arrange: three upload files that all sanitize down to the same basename ("b.txt"). + upload_files: list[Any] = [ + SimpleNamespace(name="a/b.txt", key="k1"), + SimpleNamespace(name="c/b.txt", key="k2"), + SimpleNamespace(name="../b.txt", key="k3"), + ] + + # Stream distinct bytes per key so we can verify content is written to the right entry. + data_by_key: dict[str, list[bytes]] = {"k1": [b"one"], "k2": [b"two"], "k3": [b"three"]} + + def _load(key: str, stream: bool = True) -> list[bytes]: + # Return the corresponding chunks for this key (the production code iterates chunks). + assert stream is True + return data_by_key[key] + + monkeypatch.setattr(file_service_module.storage, "load", _load) + + # Act: build zip in a tempfile. + with FileService.build_upload_files_zip_tempfile(upload_files=upload_files) as tmp: + with ZipFile(tmp, mode="r") as zf: + # Assert: names are sanitized (no directory components) and deduped with suffixes. + assert zf.namelist() == ["b.txt", "b (1).txt", "b (2).txt"] + + # Assert: each entry contains the correct bytes from storage. + assert zf.read("b.txt") == b"one" + assert zf.read("b (1).txt") == b"two" + assert zf.read("b (2).txt") == b"three" + + +def test_get_upload_files_by_ids_returns_empty_when_no_ids(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure empty input returns an empty mapping without hitting the database.""" + + class _Session: + def scalars(self, _stmt): # type: ignore[no-untyped-def] + raise AssertionError("db.session.scalars should not be called for empty id lists") + + monkeypatch.setattr(file_service_module, "db", SimpleNamespace(session=_Session())) + + assert FileService.get_upload_files_by_ids("tenant-1", []) == {} + + +def test_get_upload_files_by_ids_returns_id_keyed_mapping(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure batch lookup returns a dict keyed by stringified UploadFile ids.""" + + upload_files: list[Any] = [ + SimpleNamespace(id="file-1", tenant_id="tenant-1"), + SimpleNamespace(id="file-2", tenant_id="tenant-1"), + ] + + class _ScalarResult: + def __init__(self, items: list[Any]) -> None: + self._items = items + + def all(self) -> list[Any]: + return self._items + + class _Session: + def __init__(self, items: list[Any]) -> None: + self._items = items + self.calls: list[object] = [] + + def scalars(self, stmt): # type: ignore[no-untyped-def] + # Capture the statement so we can at least assert the query path is taken. + self.calls.append(stmt) + return _ScalarResult(self._items) + + session = _Session(upload_files) + monkeypatch.setattr(file_service_module, "db", SimpleNamespace(session=session)) + + # Provide duplicates to ensure callers can safely pass repeated ids. + result = FileService.get_upload_files_by_ids("tenant-1", ["file-1", "file-1", "file-2"]) + + assert set(result.keys()) == {"file-1", "file-2"} + assert result["file-1"].id == "file-1" + assert result["file-2"].id == "file-2" + assert len(session.calls) == 1 diff --git a/web/app/components/base/chat/chat/citation/popup.tsx b/web/app/components/base/chat/chat/citation/popup.tsx index c1633227d2..e033a9fa77 100644 --- a/web/app/components/base/chat/chat/citation/popup.tsx +++ b/web/app/components/base/chat/chat/citation/popup.tsx @@ -1,4 +1,4 @@ -import type { FC } from 'react' +import type { FC, MouseEvent } from 'react' import type { Resources } from './index' import Link from 'next/link' import { Fragment, useState } from 'react' @@ -18,6 +18,8 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' +import { useDocumentDownload } from '@/service/knowledge/use-document' +import { downloadUrl } from '@/utils/download' import ProgressTooltip from './progress-tooltip' import Tooltip from './tooltip' @@ -36,6 +38,30 @@ const Popup: FC = ({ ? (/\.([^.]*)$/.exec(data.documentName)?.[1] || '') : 'notion' + const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload() + + /** + * Download the original uploaded file for citations whose data source is upload-file. + * We request a signed URL from the dataset document download endpoint, then trigger browser download. + */ + const handleDownloadUploadFile = async (e: MouseEvent) => { + // Prevent toggling the citation popup when user clicks the download link. + e.preventDefault() + e.stopPropagation() + + // Only upload-file citations can be downloaded this way (needs dataset/document ids). + const isUploadFile = data.dataSourceType === 'upload_file' || data.dataSourceType === 'file' + const datasetId = data.sources?.[0]?.dataset_id + const documentId = data.documentId || data.sources?.[0]?.document_id + if (!isUploadFile || !datasetId || !documentId || isDownloading) + return + + // Fetch signed URL (usually points to `/files//file-preview?...&as_attachment=true`). + const res = await downloadDocument({ datasetId, documentId }) + if (res?.url) + downloadUrl({ url: res.url, fileName: data.documentName }) + } + return ( = ({ setOpen(v => !v)}>
+ {/* Keep the trigger purely for opening the popup (no download link here). */}
{data.documentName}
@@ -57,7 +84,21 @@ const Popup: FC = ({
-
{data.documentName}
+
+ {/* If it's an upload-file reference, the title becomes a download link. */} + {(data.dataSourceType === 'upload_file' || data.dataSourceType === 'file') && !!data.sources?.[0]?.dataset_id + ? ( + + ) + : data.documentName} +
diff --git a/web/app/components/datasets/documents/components/list.tsx b/web/app/components/datasets/documents/components/list.tsx index 2bf9c278c4..0c78da113b 100644 --- a/web/app/components/datasets/documents/components/list.tsx +++ b/web/app/components/datasets/documents/components/list.tsx @@ -30,9 +30,10 @@ import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from ' import useTimestamp from '@/hooks/use-timestamp' import { ChunkingMode, DataSourceType, DocumentActionType } from '@/models/datasets' import { DatasourceType } from '@/models/pipeline' -import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useDocumentDisable, useDocumentEnable } from '@/service/knowledge/use-document' +import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useDocumentDisable, useDocumentDownloadZip, useDocumentEnable } from '@/service/knowledge/use-document' import { asyncRunSafe } from '@/utils' import { cn } from '@/utils/classnames' +import { downloadBlob } from '@/utils/download' import { formatNumber } from '@/utils/format' import BatchAction from '../detail/completed/common/batch-action' import StatusItem from '../status-item' @@ -222,6 +223,7 @@ const DocumentList: FC = ({ const { mutateAsync: disableDocument } = useDocumentDisable() const { mutateAsync: deleteDocument } = useDocumentDelete() const { mutateAsync: retryIndexDocument } = useDocumentBatchRetryIndex() + const { mutateAsync: requestDocumentsZip, isPending: isDownloadingZip } = useDocumentDownloadZip() const handleAction = (actionName: DocumentActionType) => { return async () => { @@ -300,6 +302,39 @@ const DocumentList: FC = ({ return dataSourceType === DatasourceType.onlineDrive }, []) + const downloadableSelectedIds = useMemo(() => { + const selectedSet = new Set(selectedIds) + return localDocs + .filter(doc => selectedSet.has(doc.id) && doc.data_source_type === DataSourceType.FILE) + .map(doc => doc.id) + }, [localDocs, selectedIds]) + + /** + * Generate a random ZIP filename for bulk document downloads. + * We intentionally avoid leaking dataset info in the exported archive name. + */ + const generateDocsZipFileName = useCallback((): string => { + // Prefer UUID for uniqueness; fall back to time+random when unavailable. + const randomPart = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') + ? crypto.randomUUID() + : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}` + return `${randomPart}-docs.zip` + }, []) + + const handleBatchDownload = useCallback(async () => { + if (isDownloadingZip) + return + + // Download as a single ZIP to avoid browser caps on multiple automatic downloads. + const [e, blob] = await asyncRunSafe(requestDocumentsZip({ datasetId, documentIds: downloadableSelectedIds })) + if (e || !blob) { + Toast.notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) }) + return + } + + downloadBlob({ data: blob, fileName: generateDocsZipFileName() }) + }, [datasetId, downloadableSelectedIds, generateDocsZipFileName, isDownloadingZip, requestDocumentsZip, t]) + return (
@@ -463,6 +498,7 @@ const DocumentList: FC = ({ onArchive={handleAction(DocumentActionType.archive)} onBatchEnable={handleAction(DocumentActionType.enable)} onBatchDisable={handleAction(DocumentActionType.disable)} + onBatchDownload={downloadableSelectedIds.length > 0 ? handleBatchDownload : undefined} onBatchDelete={handleAction(DocumentActionType.delete)} onEditMetadata={showEditModal} onBatchReIndex={hasErrorDocumentsSelected ? handleBatchReIndex : undefined} diff --git a/web/app/components/datasets/documents/components/operations.tsx b/web/app/components/datasets/documents/components/operations.tsx index 0d3c40c053..ee638c5e12 100644 --- a/web/app/components/datasets/documents/components/operations.tsx +++ b/web/app/components/datasets/documents/components/operations.tsx @@ -1,8 +1,10 @@ import type { OperationName } from '../types' import type { CommonResponse } from '@/models/common' +import type { DocumentDownloadResponse } from '@/service/datasets' import { RiArchive2Line, RiDeleteBinLine, + RiDownload2Line, RiEditLine, RiEqualizer2Line, RiLoopLeftLine, @@ -28,6 +30,7 @@ import { useDocumentArchive, useDocumentDelete, useDocumentDisable, + useDocumentDownload, useDocumentEnable, useDocumentPause, useDocumentResume, @@ -37,6 +40,7 @@ import { } from '@/service/knowledge/use-document' import { asyncRunSafe } from '@/utils' import { cn } from '@/utils/classnames' +import { downloadUrl } from '@/utils/download' import s from '../style.module.css' import RenameModal from './rename-modal' @@ -69,7 +73,7 @@ const Operations = ({ scene = 'list', className = '', }: OperationsProps) => { - const { id, enabled = false, archived = false, data_source_type, display_status } = detail || {} + const { id, name, enabled = false, archived = false, data_source_type, display_status } = detail || {} const [showModal, setShowModal] = useState(false) const [deleting, setDeleting] = useState(false) const { notify } = useContext(ToastContext) @@ -80,6 +84,7 @@ const Operations = ({ const { mutateAsync: enableDocument } = useDocumentEnable() const { mutateAsync: disableDocument } = useDocumentDisable() const { mutateAsync: deleteDocument } = useDocumentDelete() + const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload() const { mutateAsync: syncDocument } = useSyncDocument() const { mutateAsync: syncWebsite } = useSyncWebsite() const { mutateAsync: pauseDocument } = useDocumentPause() @@ -158,6 +163,24 @@ const Operations = ({ onUpdate() }, [onUpdate]) + const handleDownload = useCallback(async () => { + // Avoid repeated clicks while the signed URL request is in-flight. + if (isDownloading) + return + + // Request a signed URL first (it points to `/files//file-preview?...&as_attachment=true`). + const [e, res] = await asyncRunSafe( + downloadDocument({ datasetId, documentId: id }) as Promise, + ) + if (e || !res?.url) { + notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) }) + return + } + + // Trigger download without navigating away (helps avoid duplicate downloads in some browsers). + downloadUrl({ url: res.url, fileName: name }) + }, [datasetId, downloadDocument, id, isDownloading, name, notify, t]) + return (
e.stopPropagation()}> {isListScene && !embeddingAvailable && ( @@ -214,6 +237,20 @@ const Operations = ({ {t('list.table.rename', { ns: 'datasetDocuments' })}
+ {data_source_type === DataSourceType.FILE && ( +
{ + evt.preventDefault() + evt.stopPropagation() + evt.nativeEvent.stopImmediatePropagation?.() + handleDownload() + }} + > + + {t('list.action.download', { ns: 'datasetDocuments' })} +
+ )} {['notion_import', DataSourceType.WEB].includes(data_source_type) && (
onOperate('sync')}> @@ -223,6 +260,23 @@ const Operations = ({ )} + {archived && data_source_type === DataSourceType.FILE && ( + <> +
{ + evt.preventDefault() + evt.stopPropagation() + evt.nativeEvent.stopImmediatePropagation?.() + handleDownload() + }} + > + + {t('list.action.download', { ns: 'datasetDocuments' })} +
+ + + )} {!archived && display_status?.toLowerCase() === 'indexing' && (
onOperate('pause')}> diff --git a/web/app/components/datasets/documents/detail/completed/common/batch-action.tsx b/web/app/components/datasets/documents/detail/completed/common/batch-action.tsx index db36ca471a..2de72d9ff6 100644 --- a/web/app/components/datasets/documents/detail/completed/common/batch-action.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/batch-action.tsx @@ -1,5 +1,5 @@ import type { FC } from 'react' -import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLine, RiDraftLine, RiRefreshLine } from '@remixicon/react' +import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLine, RiDownload2Line, RiDraftLine, RiRefreshLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import * as React from 'react' import { useTranslation } from 'react-i18next' @@ -14,6 +14,7 @@ type IBatchActionProps = { selectedIds: string[] onBatchEnable: () => void onBatchDisable: () => void + onBatchDownload?: () => void onBatchDelete: () => Promise onArchive?: () => void onEditMetadata?: () => void @@ -26,6 +27,7 @@ const BatchAction: FC = ({ selectedIds, onBatchEnable, onBatchDisable, + onBatchDownload, onArchive, onBatchDelete, onEditMetadata, @@ -103,6 +105,16 @@ const BatchAction: FC = ({ {t(`${i18nPrefix}.reIndex`, { ns: 'dataset' })} )} + {onBatchDownload && ( + + )}