Compare commits

..

12 Commits

Author SHA1 Message Date
CodingOnStar
f3768516b7 test: enhance unit tests for FieldList and FieldListContainer components with improved interaction handling and conversion checks 2026-02-12 15:34:41 +08:00
CodingOnStar
62b2523326 test: add unit tests for workflow header, onboarding modal, and start node selection components 2026-02-12 15:25:23 +08:00
CodingOnStar
046953a630 test: enhance unit tests for billing components with distinct styling checks and new coverage for custom page and image uploader 2026-02-12 15:08:06 +08:00
Coding On Star
3fd1eea4d7 feat(tests): add integration tests for explore app list, installed apps, and sidebar lifecycle flows (#32248)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-02-12 10:29:03 +08:00
Coding On Star
b65678bd4c test: add comprehensive unit and integration tests for RAG Pipeline components (#32237)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-02-12 10:28:55 +08:00
Coding On Star
bfdc39510b test: add unit and integration tests for share, develop, and goto-anything modules (#32246)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-02-12 10:05:43 +08:00
Coding On Star
80e6312807 test: add comprehensive unit and integration tests for billing components (#32227)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-02-12 10:05:06 +08:00
Coding On Star
d6b025e91e test(web): add comprehensive unit and integration tests for plugins and tools modules (#32220)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-02-12 10:04:56 +08:00
Coding On Star
10f85074e8 test: add comprehensive unit and integration tests for dataset module (#32187)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 10:00:32 +08:00
Saumya Talwani
f953331f91 test: add unit tests for some base components (#32201) 2026-02-12 09:51:18 +08:00
Runzhe
32350f7a04 feat(api): add scheduled cleanup task for specific workflow logs (#31843)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Co-authored-by: 章润喆 <zhangrunzhe@zhangrunzhedeMacBook-Air.local>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: hj24 <mambahj24@gmail.com>
2026-02-11 20:54:36 +08:00
QuantumGhost
c730fec1e4 chore: bump version to 1.13.0 (#32147)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
2026-02-11 17:08:49 +08:00
827 changed files with 53351 additions and 33659 deletions

View File

@@ -553,6 +553,8 @@ WORKFLOW_LOG_CLEANUP_ENABLED=false
WORKFLOW_LOG_RETENTION_DAYS=30
# Batch size for workflow log cleanup operations (default: 100)
WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100
# Comma-separated list of workflow IDs to clean logs for
WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS=
# App configuration
APP_MAX_EXECUTION_TIME=1200

View File

@@ -1314,6 +1314,9 @@ class WorkflowLogConfig(BaseSettings):
WORKFLOW_LOG_CLEANUP_BATCH_SIZE: int = Field(
default=100, description="Batch size for workflow run log cleanup operations"
)
WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS: str = Field(
default="", description="Comma-separated list of workflow IDs to clean logs for"
)
class SwaggerUIConfig(BaseSettings):

View File

@@ -1,6 +1,6 @@
[project]
name = "dify-api"
version = "1.12.1"
version = "1.13.0"
requires-python = ">=3.11,<3.13"
dependencies = [

View File

@@ -264,9 +264,15 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol):
batch_size: int,
run_types: Sequence[WorkflowType] | None = None,
tenant_ids: Sequence[str] | None = None,
workflow_ids: Sequence[str] | None = None,
) -> Sequence[WorkflowRun]:
"""
Fetch ended workflow runs in a time window for archival and clean batching.
Optional filters:
- run_types
- tenant_ids
- workflow_ids
"""
...

View File

@@ -386,6 +386,7 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
batch_size: int,
run_types: Sequence[WorkflowType] | None = None,
tenant_ids: Sequence[str] | None = None,
workflow_ids: Sequence[str] | None = None,
) -> Sequence[WorkflowRun]:
"""
Fetch ended workflow runs in a time window for archival and clean batching.
@@ -394,7 +395,7 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
- created_at in [start_from, end_before)
- type in run_types (when provided)
- status is an ended state
- optional tenant_id filter and cursor (last_seen) for pagination
- optional tenant_id, workflow_id filters and cursor (last_seen) for pagination
"""
with self._session_maker() as session:
stmt = (
@@ -417,6 +418,9 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
if tenant_ids:
stmt = stmt.where(WorkflowRun.tenant_id.in_(tenant_ids))
if workflow_ids:
stmt = stmt.where(WorkflowRun.workflow_id.in_(workflow_ids))
if last_seen:
stmt = stmt.where(
or_(

View File

@@ -4,7 +4,6 @@ import time
from collections.abc import Sequence
import click
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
import app
@@ -13,6 +12,7 @@ from extensions.ext_database import db
from models.model import (
AppAnnotationHitHistory,
Conversation,
DatasetRetrieverResource,
Message,
MessageAgentThought,
MessageAnnotation,
@@ -20,7 +20,10 @@ from models.model import (
MessageFeedback,
MessageFile,
)
from models.workflow import ConversationVariable, WorkflowAppLog, WorkflowNodeExecutionModel, WorkflowRun
from models.web import SavedMessage
from models.workflow import ConversationVariable, WorkflowRun
from repositories.factory import DifyAPIRepositoryFactory
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
logger = logging.getLogger(__name__)
@@ -29,8 +32,15 @@ MAX_RETRIES = 3
BATCH_SIZE = dify_config.WORKFLOW_LOG_CLEANUP_BATCH_SIZE
@app.celery.task(queue="dataset")
def clean_workflow_runlogs_precise():
def _get_specific_workflow_ids() -> list[str]:
workflow_ids_str = dify_config.WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS.strip()
if not workflow_ids_str:
return []
return [wid.strip() for wid in workflow_ids_str.split(",") if wid.strip()]
@app.celery.task(queue="retention")
def clean_workflow_runlogs_precise() -> None:
"""Clean expired workflow run logs with retry mechanism and complete message cascade"""
click.echo(click.style("Start clean workflow run logs (precise mode with complete cascade).", fg="green"))
@@ -39,48 +49,48 @@ def clean_workflow_runlogs_precise():
retention_days = dify_config.WORKFLOW_LOG_RETENTION_DAYS
cutoff_date = datetime.datetime.now() - datetime.timedelta(days=retention_days)
session_factory = sessionmaker(db.engine, expire_on_commit=False)
workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_factory)
workflow_ids = _get_specific_workflow_ids()
workflow_ids_filter = workflow_ids or None
try:
with session_factory.begin() as session:
total_workflow_runs = session.query(WorkflowRun).where(WorkflowRun.created_at < cutoff_date).count()
if total_workflow_runs == 0:
logger.info("No expired workflow run logs found")
return
logger.info("Found %s expired workflow run logs to clean", total_workflow_runs)
total_deleted = 0
failed_batches = 0
batch_count = 0
last_seen: tuple[datetime.datetime, str] | None = None
while True:
run_rows = workflow_run_repo.get_runs_batch_by_time_range(
start_from=None,
end_before=cutoff_date,
last_seen=last_seen,
batch_size=BATCH_SIZE,
workflow_ids=workflow_ids_filter,
)
if not run_rows:
if batch_count == 0:
logger.info("No expired workflow run logs found")
break
last_seen = (run_rows[-1].created_at, run_rows[-1].id)
batch_count += 1
with session_factory.begin() as session:
workflow_run_ids = session.scalars(
select(WorkflowRun.id)
.where(WorkflowRun.created_at < cutoff_date)
.order_by(WorkflowRun.created_at, WorkflowRun.id)
.limit(BATCH_SIZE)
).all()
success = _delete_batch(session, workflow_run_repo, run_rows, failed_batches)
if not workflow_run_ids:
if success:
total_deleted += len(run_rows)
failed_batches = 0
else:
failed_batches += 1
if failed_batches >= MAX_RETRIES:
logger.error("Failed to delete batch after %s retries, aborting cleanup for today", MAX_RETRIES)
break
batch_count += 1
success = _delete_batch(session, workflow_run_ids, failed_batches)
if success:
total_deleted += len(workflow_run_ids)
failed_batches = 0
else:
failed_batches += 1
if failed_batches >= MAX_RETRIES:
logger.error("Failed to delete batch after %s retries, aborting cleanup for today", MAX_RETRIES)
break
else:
# Calculate incremental delay times: 5, 10, 15 minutes
retry_delay_minutes = failed_batches * 5
logger.warning("Batch deletion failed, retrying in %s minutes...", retry_delay_minutes)
time.sleep(retry_delay_minutes * 60)
continue
# Calculate incremental delay times: 5, 10, 15 minutes
retry_delay_minutes = failed_batches * 5
logger.warning("Batch deletion failed, retrying in %s minutes...", retry_delay_minutes)
time.sleep(retry_delay_minutes * 60)
continue
logger.info("Cleanup completed: %s expired workflow run logs deleted", total_deleted)
@@ -93,10 +103,16 @@ def clean_workflow_runlogs_precise():
click.echo(click.style(f"Cleaned workflow run logs from db success latency: {execution_time:.2f}s", fg="green"))
def _delete_batch(session: Session, workflow_run_ids: Sequence[str], attempt_count: int) -> bool:
def _delete_batch(
session: Session,
workflow_run_repo,
workflow_runs: Sequence[WorkflowRun],
attempt_count: int,
) -> bool:
"""Delete a single batch of workflow runs and all related data within a nested transaction."""
try:
with session.begin_nested():
workflow_run_ids = [run.id for run in workflow_runs]
message_data = (
session.query(Message.id, Message.conversation_id)
.where(Message.workflow_run_id.in_(workflow_run_ids))
@@ -107,11 +123,13 @@ def _delete_batch(session: Session, workflow_run_ids: Sequence[str], attempt_cou
if message_id_list:
message_related_models = [
AppAnnotationHitHistory,
DatasetRetrieverResource,
MessageAgentThought,
MessageChain,
MessageFile,
MessageAnnotation,
MessageFeedback,
SavedMessage,
]
for model in message_related_models:
session.query(model).where(model.message_id.in_(message_id_list)).delete(synchronize_session=False) # type: ignore
@@ -122,14 +140,6 @@ def _delete_batch(session: Session, workflow_run_ids: Sequence[str], attempt_cou
synchronize_session=False
)
session.query(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(workflow_run_ids)).delete(
synchronize_session=False
)
session.query(WorkflowNodeExecutionModel).where(
WorkflowNodeExecutionModel.workflow_run_id.in_(workflow_run_ids)
).delete(synchronize_session=False)
if conversation_id_list:
session.query(ConversationVariable).where(
ConversationVariable.conversation_id.in_(conversation_id_list)
@@ -139,7 +149,22 @@ def _delete_batch(session: Session, workflow_run_ids: Sequence[str], attempt_cou
synchronize_session=False
)
session.query(WorkflowRun).where(WorkflowRun.id.in_(workflow_run_ids)).delete(synchronize_session=False)
def _delete_node_executions(active_session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]:
run_ids = [run.id for run in runs]
repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
session_maker=sessionmaker(bind=active_session.get_bind(), expire_on_commit=False)
)
return repo.delete_by_runs(active_session, run_ids)
def _delete_trigger_logs(active_session: Session, run_ids: Sequence[str]) -> int:
trigger_repo = SQLAlchemyWorkflowTriggerLogRepository(active_session)
return trigger_repo.delete_by_run_ids(run_ids)
workflow_run_repo.delete_runs_with_related(
workflow_runs,
delete_node_executions=_delete_node_executions,
delete_trigger_logs=_delete_trigger_logs,
)
return True

View File

@@ -62,6 +62,9 @@ class FakeRepo:
end_before: datetime.datetime,
last_seen: tuple[datetime.datetime, str] | None,
batch_size: int,
run_types=None,
tenant_ids=None,
workflow_ids=None,
) -> list[FakeRun]:
if self.call_idx >= len(self.batches):
return []

2
api/uv.lock generated
View File

@@ -1366,7 +1366,7 @@ wheels = [
[[package]]
name = "dify-api"
version = "1.12.1"
version = "1.13.0"
source = { virtual = "." }
dependencies = [
{ name = "aliyun-log-python-sdk" },

View File

@@ -1073,6 +1073,8 @@ WORKFLOW_LOG_CLEANUP_ENABLED=false
WORKFLOW_LOG_RETENTION_DAYS=30
# Batch size for workflow log cleanup operations (default: 100)
WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100
# Comma-separated list of workflow IDs to clean logs for
WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS=
# Aliyun SLS Logstore Configuration
# Aliyun Access Key ID

View File

@@ -21,7 +21,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.12.1
image: langgenius/dify-api:1.13.0
restart: always
environment:
# Use the shared environment variables.
@@ -63,7 +63,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.12.1
image: langgenius/dify-api:1.13.0
restart: always
environment:
# Use the shared environment variables.
@@ -102,7 +102,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.12.1
image: langgenius/dify-api:1.13.0
restart: always
environment:
# Use the shared environment variables.
@@ -132,7 +132,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.12.1
image: langgenius/dify-web:1.13.0
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}

View File

@@ -470,6 +470,7 @@ x-shared-env: &shared-api-worker-env
WORKFLOW_LOG_CLEANUP_ENABLED: ${WORKFLOW_LOG_CLEANUP_ENABLED:-false}
WORKFLOW_LOG_RETENTION_DAYS: ${WORKFLOW_LOG_RETENTION_DAYS:-30}
WORKFLOW_LOG_CLEANUP_BATCH_SIZE: ${WORKFLOW_LOG_CLEANUP_BATCH_SIZE:-100}
WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS: ${WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS:-}
ALIYUN_SLS_ACCESS_KEY_ID: ${ALIYUN_SLS_ACCESS_KEY_ID:-}
ALIYUN_SLS_ACCESS_KEY_SECRET: ${ALIYUN_SLS_ACCESS_KEY_SECRET:-}
ALIYUN_SLS_ENDPOINT: ${ALIYUN_SLS_ENDPOINT:-}
@@ -715,7 +716,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.12.1
image: langgenius/dify-api:1.13.0
restart: always
environment:
# Use the shared environment variables.
@@ -757,7 +758,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.12.1
image: langgenius/dify-api:1.13.0
restart: always
environment:
# Use the shared environment variables.
@@ -796,7 +797,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.12.1
image: langgenius/dify-api:1.13.0
restart: always
environment:
# Use the shared environment variables.
@@ -826,7 +827,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.12.1
image: langgenius/dify-web:1.13.0
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}

View File

@@ -0,0 +1,991 @@
import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import AnnotationFull from '@/app/components/billing/annotation-full'
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import Billing from '@/app/components/billing/billing-page'
import { defaultPlan, NUM_INFINITE } from '@/app/components/billing/config'
import HeaderBillingBtn from '@/app/components/billing/header-billing-btn'
import PlanComp from '@/app/components/billing/plan'
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
import PriorityLabel from '@/app/components/billing/priority-label'
import TriggerEventsLimitModal from '@/app/components/billing/trigger-events-limit-modal'
import { Plan } from '@/app/components/billing/type'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
let mockProviderCtx: Record<string, unknown> = {}
let mockAppCtx: Record<string, unknown> = {}
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockProviderCtx,
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => mockAppCtx,
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowPricingModal: mockSetShowPricingModal,
}),
useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) =>
selector({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en-US',
useGetPricingPageLanguage: () => 'en',
}))
// ─── Service mocks ──────────────────────────────────────────────────────────
const mockRefetch = vi.fn().mockResolvedValue({ data: 'https://billing.example.com' })
vi.mock('@/service/use-billing', () => ({
useBillingUrl: () => ({
data: 'https://billing.example.com',
isFetching: false,
refetch: mockRefetch,
}),
useBindPartnerStackInfo: () => ({ mutateAsync: vi.fn() }),
}))
vi.mock('@/service/use-education', () => ({
useEducationVerify: () => ({
mutateAsync: vi.fn().mockResolvedValue({ token: 'test-token' }),
isPending: false,
}),
}))
// ─── Navigation mocks ───────────────────────────────────────────────────────
const mockRouterPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockRouterPush }),
usePathname: () => '/billing',
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => vi.fn(),
}))
// ─── External component mocks ───────────────────────────────────────────────
vi.mock('@/app/education-apply/verify-state-modal', () => ({
default: ({ isShow }: { isShow: boolean }) =>
isShow ? <div data-testid="verify-state-modal" /> : null,
}))
vi.mock('@/app/components/header/utils/util', () => ({
mailToSupport: () => 'mailto:support@test.com',
}))
// ─── Test data factories ────────────────────────────────────────────────────
type PlanOverrides = {
type?: string
usage?: Partial<UsagePlanInfo>
total?: Partial<UsagePlanInfo>
reset?: Partial<UsageResetInfo>
}
const createPlanData = (overrides: PlanOverrides = {}) => ({
...defaultPlan,
...overrides,
type: overrides.type ?? defaultPlan.type,
usage: { ...defaultPlan.usage, ...overrides.usage },
total: { ...defaultPlan.total, ...overrides.total },
reset: { ...defaultPlan.reset, ...overrides.reset },
})
const setupProviderContext = (planOverrides: PlanOverrides = {}, extra: Record<string, unknown> = {}) => {
mockProviderCtx = {
plan: createPlanData(planOverrides),
enableBilling: true,
isFetchedPlan: true,
enableEducationPlan: false,
isEducationAccount: false,
allowRefreshEducationVerify: false,
...extra,
}
}
const setupAppContext = (overrides: Record<string, unknown> = {}) => {
mockAppCtx = {
isCurrentWorkspaceManager: true,
userProfile: { email: 'test@example.com' },
langGeniusVersionInfo: { current_version: '1.0.0' },
...overrides,
}
}
// Vitest hoists vi.mock() calls, so imports above will use mocked modules
// ═══════════════════════════════════════════════════════════════════════════
// 1. Billing Page + Plan Component Integration
// Tests the full data flow: BillingPage → PlanComp → UsageInfo → ProgressBar
// ═══════════════════════════════════════════════════════════════════════════
describe('Billing Page + Plan Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
setupAppContext()
})
// Verify that the billing page renders PlanComp with all 7 usage items
describe('Rendering complete plan information', () => {
it('should display all 7 usage metrics for sandbox plan', () => {
setupProviderContext({
type: Plan.sandbox,
usage: {
buildApps: 3,
teamMembers: 1,
documentsUploadQuota: 10,
vectorSpace: 20,
annotatedResponse: 5,
triggerEvents: 1000,
apiRateLimit: 2000,
},
total: {
buildApps: 5,
teamMembers: 1,
documentsUploadQuota: 50,
vectorSpace: 50,
annotatedResponse: 10,
triggerEvents: 3000,
apiRateLimit: 5000,
},
})
render(<Billing />)
// Plan name
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
// All 7 usage items should be visible
expect(screen.getByText(/usagePage\.buildApps/i)).toBeInTheDocument()
expect(screen.getByText(/usagePage\.teamMembers/i)).toBeInTheDocument()
expect(screen.getByText(/usagePage\.documentsUploadQuota/i)).toBeInTheDocument()
expect(screen.getByText(/usagePage\.vectorSpace/i)).toBeInTheDocument()
expect(screen.getByText(/usagePage\.annotationQuota/i)).toBeInTheDocument()
expect(screen.getByText(/usagePage\.triggerEvents/i)).toBeInTheDocument()
expect(screen.getByText(/plansCommon\.apiRateLimit/i)).toBeInTheDocument()
})
it('should display usage values as "usage / total" format', () => {
setupProviderContext({
type: Plan.sandbox,
usage: { buildApps: 3, teamMembers: 1 },
total: { buildApps: 5, teamMembers: 1 },
})
render(<PlanComp loc="test" />)
// Check that the buildApps usage fraction "3 / 5" is rendered
const usageContainers = screen.getAllByText('3')
expect(usageContainers.length).toBeGreaterThan(0)
const totalContainers = screen.getAllByText('5')
expect(totalContainers.length).toBeGreaterThan(0)
})
it('should show "unlimited" for infinite quotas (professional API rate limit)', () => {
setupProviderContext({
type: Plan.professional,
total: { apiRateLimit: NUM_INFINITE },
})
render(<PlanComp loc="test" />)
expect(screen.getByText(/plansCommon\.unlimited/i)).toBeInTheDocument()
})
it('should display reset days for trigger events when applicable', () => {
setupProviderContext({
type: Plan.professional,
total: { triggerEvents: 20000 },
reset: { triggerEvents: 7 },
})
render(<PlanComp loc="test" />)
// Reset text should be visible
expect(screen.getByText(/usagePage\.resetsIn/i)).toBeInTheDocument()
})
})
// Verify billing URL button visibility and behavior
describe('Billing URL button', () => {
it('should show billing button when enableBilling and isCurrentWorkspaceManager', () => {
setupProviderContext({ type: Plan.sandbox })
setupAppContext({ isCurrentWorkspaceManager: true })
render(<Billing />)
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
})
it('should hide billing button when user is not workspace manager', () => {
setupProviderContext({ type: Plan.sandbox })
setupAppContext({ isCurrentWorkspaceManager: false })
render(<Billing />)
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
})
it('should hide billing button when billing is disabled', () => {
setupProviderContext({ type: Plan.sandbox }, { enableBilling: false })
render(<Billing />)
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
})
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 2. Plan Type Display Integration
// Tests that different plan types render correct visual elements
// ═══════════════════════════════════════════════════════════════════════════
describe('Plan Type Display Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
setupAppContext()
})
it('should render sandbox plan with upgrade button (premium badge)', () => {
setupProviderContext({ type: Plan.sandbox })
render(<PlanComp loc="test" />)
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
expect(screen.getByText(/plans\.sandbox\.for/i)).toBeInTheDocument()
// Sandbox shows premium badge upgrade button (not plain)
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
})
it('should render professional plan with plain upgrade button', () => {
setupProviderContext({ type: Plan.professional })
render(<PlanComp loc="test" />)
expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument()
// Professional shows plain button because it's not team
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
})
it('should render team plan with plain-style upgrade button', () => {
setupProviderContext({ type: Plan.team })
render(<PlanComp loc="test" />)
expect(screen.getByText(/plans\.team\.name/i)).toBeInTheDocument()
// Team plan has isPlain=true, so shows "upgradeBtn.plain" text
expect(screen.getByText(/upgradeBtn\.plain/i)).toBeInTheDocument()
})
it('should not render upgrade button for enterprise plan', () => {
setupProviderContext({ type: Plan.enterprise })
render(<PlanComp loc="test" />)
expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
expect(screen.queryByText(/upgradeBtn\.plain/i)).not.toBeInTheDocument()
})
it('should show education verify button when enableEducationPlan is true and not yet verified', () => {
setupProviderContext({ type: Plan.sandbox }, {
enableEducationPlan: true,
isEducationAccount: false,
})
render(<PlanComp loc="test" />)
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 3. Upgrade Flow Integration
// Tests the flow: UpgradeBtn click → setShowPricingModal
// and PlanUpgradeModal → close + trigger pricing
// ═══════════════════════════════════════════════════════════════════════════
describe('Upgrade Flow Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
setupAppContext()
setupProviderContext({ type: Plan.sandbox })
})
// UpgradeBtn triggers pricing modal
describe('UpgradeBtn triggers pricing modal', () => {
it('should call setShowPricingModal when clicking premium badge upgrade button', async () => {
const user = userEvent.setup()
render(<UpgradeBtn />)
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
await user.click(badgeText)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should call setShowPricingModal when clicking plain upgrade button', async () => {
const user = userEvent.setup()
render(<UpgradeBtn isPlain />)
const button = screen.getByRole('button')
await user.click(button)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should use custom onClick when provided instead of setShowPricingModal', async () => {
const customOnClick = vi.fn()
const user = userEvent.setup()
render(<UpgradeBtn onClick={customOnClick} />)
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
await user.click(badgeText)
expect(customOnClick).toHaveBeenCalledTimes(1)
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
})
it('should fire gtag event with loc parameter when clicked', async () => {
const mockGtag = vi.fn()
;(window as unknown as Record<string, unknown>).gtag = mockGtag
const user = userEvent.setup()
render(<UpgradeBtn loc="billing-page" />)
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
await user.click(badgeText)
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', { loc: 'billing-page' })
delete (window as unknown as Record<string, unknown>).gtag
})
})
// PlanUpgradeModal integration: close modal and trigger pricing
describe('PlanUpgradeModal upgrade flow', () => {
it('should call onClose and setShowPricingModal when clicking upgrade button in modal', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(
<PlanUpgradeModal
show={true}
onClose={onClose}
title="Upgrade Required"
description="You need a better plan"
/>,
)
// The modal should show title and description
expect(screen.getByText('Upgrade Required')).toBeInTheDocument()
expect(screen.getByText('You need a better plan')).toBeInTheDocument()
// Click the upgrade button inside the modal
const upgradeText = screen.getByText(/triggerLimitModal\.upgrade/i)
await user.click(upgradeText)
// Should close the current modal first
expect(onClose).toHaveBeenCalledTimes(1)
// Then open pricing modal
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should call onClose and custom onUpgrade when provided', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const onUpgrade = vi.fn()
render(
<PlanUpgradeModal
show={true}
onClose={onClose}
onUpgrade={onUpgrade}
title="Test"
description="Test"
/>,
)
const upgradeText = screen.getByText(/triggerLimitModal\.upgrade/i)
await user.click(upgradeText)
expect(onClose).toHaveBeenCalledTimes(1)
expect(onUpgrade).toHaveBeenCalledTimes(1)
// Custom onUpgrade replaces default setShowPricingModal
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
})
it('should call onClose when clicking dismiss button', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(
<PlanUpgradeModal
show={true}
onClose={onClose}
title="Test"
description="Test"
/>,
)
const dismissBtn = screen.getByText(/triggerLimitModal\.dismiss/i)
await user.click(dismissBtn)
expect(onClose).toHaveBeenCalledTimes(1)
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
})
})
// Upgrade from PlanComp: clicking upgrade button in plan component triggers pricing
describe('PlanComp upgrade button triggers pricing', () => {
it('should open pricing modal when clicking upgrade in sandbox plan', async () => {
const user = userEvent.setup()
setupProviderContext({ type: Plan.sandbox })
render(<PlanComp loc="test-loc" />)
const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i)
await user.click(upgradeText)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 4. Capacity Full Components Integration
// Tests AppsFull, VectorSpaceFull, AnnotationFull, TriggerEventsLimitModal
// with real child components (UsageInfo, ProgressBar, UpgradeBtn)
// ═══════════════════════════════════════════════════════════════════════════
describe('Capacity Full Components Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
setupAppContext()
})
// AppsFull renders with correct messaging and components
describe('AppsFull integration', () => {
it('should display upgrade tip and upgrade button for sandbox plan at capacity', () => {
setupProviderContext({
type: Plan.sandbox,
usage: { buildApps: 5 },
total: { buildApps: 5 },
})
render(<AppsFull loc="test" />)
// Should show "full" tip
expect(screen.getByText(/apps\.fullTip1$/i)).toBeInTheDocument()
// Should show upgrade button
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
// Should show usage/total fraction "5/5"
expect(screen.getByText(/5\/5/)).toBeInTheDocument()
// Should have a progress bar rendered
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
})
it('should display upgrade tip and upgrade button for professional plan', () => {
setupProviderContext({
type: Plan.professional,
usage: { buildApps: 48 },
total: { buildApps: 50 },
})
render(<AppsFull loc="test" />)
expect(screen.getByText(/apps\.fullTip1$/i)).toBeInTheDocument()
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
})
it('should display contact tip and contact button for team plan', () => {
setupProviderContext({
type: Plan.team,
usage: { buildApps: 200 },
total: { buildApps: 200 },
})
render(<AppsFull loc="test" />)
// Team plan shows different tip
expect(screen.getByText(/apps\.fullTip2$/i)).toBeInTheDocument()
// Team plan shows "Contact Us" instead of upgrade
expect(screen.getByText(/apps\.contactUs/i)).toBeInTheDocument()
expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
})
it('should render progress bar with correct color based on usage percentage', () => {
// 100% usage should show error color
setupProviderContext({
type: Plan.sandbox,
usage: { buildApps: 5 },
total: { buildApps: 5 },
})
render(<AppsFull loc="test" />)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
})
})
// VectorSpaceFull renders with VectorSpaceInfo and UpgradeBtn
describe('VectorSpaceFull integration', () => {
it('should display full tip, upgrade button, and vector space usage info', () => {
setupProviderContext({
type: Plan.sandbox,
usage: { vectorSpace: 50 },
total: { vectorSpace: 50 },
})
render(<VectorSpaceFull />)
// Should show full tip
expect(screen.getByText(/vectorSpace\.fullTip/i)).toBeInTheDocument()
expect(screen.getByText(/vectorSpace\.fullSolution/i)).toBeInTheDocument()
// Should show upgrade button
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
// Should show vector space usage info
expect(screen.getByText(/usagePage\.vectorSpace/i)).toBeInTheDocument()
})
})
// AnnotationFull renders with Usage component and UpgradeBtn
describe('AnnotationFull integration', () => {
it('should display annotation full tip, upgrade button, and usage info', () => {
setupProviderContext({
type: Plan.sandbox,
usage: { annotatedResponse: 10 },
total: { annotatedResponse: 10 },
})
render(<AnnotationFull />)
expect(screen.getByText(/annotatedResponse\.fullTipLine1/i)).toBeInTheDocument()
expect(screen.getByText(/annotatedResponse\.fullTipLine2/i)).toBeInTheDocument()
// UpgradeBtn rendered
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
// Usage component should show annotation quota
expect(screen.getByText(/annotatedResponse\.quotaTitle/i)).toBeInTheDocument()
})
})
// AnnotationFullModal shows modal with usage and upgrade button
describe('AnnotationFullModal integration', () => {
it('should render modal with annotation info and upgrade button when show is true', () => {
setupProviderContext({
type: Plan.sandbox,
usage: { annotatedResponse: 10 },
total: { annotatedResponse: 10 },
})
render(<AnnotationFullModal show={true} onHide={vi.fn()} />)
expect(screen.getByText(/annotatedResponse\.fullTipLine1/i)).toBeInTheDocument()
expect(screen.getByText(/annotatedResponse\.quotaTitle/i)).toBeInTheDocument()
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
})
it('should not render content when show is false', () => {
setupProviderContext({
type: Plan.sandbox,
usage: { annotatedResponse: 10 },
total: { annotatedResponse: 10 },
})
render(<AnnotationFullModal show={false} onHide={vi.fn()} />)
expect(screen.queryByText(/annotatedResponse\.fullTipLine1/i)).not.toBeInTheDocument()
})
})
// TriggerEventsLimitModal renders PlanUpgradeModal with embedded UsageInfo
describe('TriggerEventsLimitModal integration', () => {
it('should display trigger limit title, usage info, and upgrade button', () => {
setupProviderContext({ type: Plan.professional })
render(
<TriggerEventsLimitModal
show={true}
onClose={vi.fn()}
onUpgrade={vi.fn()}
usage={18000}
total={20000}
resetInDays={5}
/>,
)
// Modal title and description
expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument()
expect(screen.getByText(/triggerLimitModal\.description/i)).toBeInTheDocument()
// Embedded UsageInfo with trigger events data
expect(screen.getByText(/triggerLimitModal\.usageTitle/i)).toBeInTheDocument()
expect(screen.getByText('18000')).toBeInTheDocument()
expect(screen.getByText('20000')).toBeInTheDocument()
// Reset info
expect(screen.getByText(/usagePage\.resetsIn/i)).toBeInTheDocument()
// Upgrade and dismiss buttons
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
expect(screen.getByText(/triggerLimitModal\.dismiss/i)).toBeInTheDocument()
})
it('should call onClose and onUpgrade when clicking upgrade', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const onUpgrade = vi.fn()
setupProviderContext({ type: Plan.professional })
render(
<TriggerEventsLimitModal
show={true}
onClose={onClose}
onUpgrade={onUpgrade}
usage={20000}
total={20000}
/>,
)
const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i)
await user.click(upgradeBtn)
expect(onClose).toHaveBeenCalledTimes(1)
expect(onUpgrade).toHaveBeenCalledTimes(1)
})
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 5. Header Billing Button Integration
// Tests HeaderBillingBtn behavior for different plan states
// ═══════════════════════════════════════════════════════════════════════════
describe('Header Billing Button Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
setupAppContext()
})
it('should render UpgradeBtn (premium badge) for sandbox plan', () => {
setupProviderContext({ type: Plan.sandbox })
render(<HeaderBillingBtn />)
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
})
it('should render "pro" badge for professional plan', () => {
setupProviderContext({ type: Plan.professional })
render(<HeaderBillingBtn />)
expect(screen.getByText('pro')).toBeInTheDocument()
expect(screen.queryByText(/upgradeBtn/i)).not.toBeInTheDocument()
})
it('should render "team" badge for team plan', () => {
setupProviderContext({ type: Plan.team })
render(<HeaderBillingBtn />)
expect(screen.getByText('team')).toBeInTheDocument()
})
it('should return null when billing is disabled', () => {
setupProviderContext({ type: Plan.sandbox }, { enableBilling: false })
const { container } = render(<HeaderBillingBtn />)
expect(container.innerHTML).toBe('')
})
it('should return null when plan is not fetched yet', () => {
setupProviderContext({ type: Plan.sandbox }, { isFetchedPlan: false })
const { container } = render(<HeaderBillingBtn />)
expect(container.innerHTML).toBe('')
})
it('should call onClick when clicking pro/team badge in non-display-only mode', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
setupProviderContext({ type: Plan.professional })
render(<HeaderBillingBtn onClick={onClick} />)
await user.click(screen.getByText('pro'))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should not call onClick when isDisplayOnly is true', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
setupProviderContext({ type: Plan.professional })
render(<HeaderBillingBtn onClick={onClick} isDisplayOnly />)
await user.click(screen.getByText('pro'))
expect(onClick).not.toHaveBeenCalled()
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 6. PriorityLabel Integration
// Tests priority badge display for different plan types
// ═══════════════════════════════════════════════════════════════════════════
describe('PriorityLabel Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
setupAppContext()
})
it('should display "standard" priority for sandbox plan', () => {
setupProviderContext({ type: Plan.sandbox })
render(<PriorityLabel />)
expect(screen.getByText(/plansCommon\.priority\.standard/i)).toBeInTheDocument()
})
it('should display "priority" for professional plan with icon', () => {
setupProviderContext({ type: Plan.professional })
const { container } = render(<PriorityLabel />)
expect(screen.getByText(/plansCommon\.priority\.priority/i)).toBeInTheDocument()
// Professional plan should show the priority icon
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should display "top-priority" for team plan with icon', () => {
setupProviderContext({ type: Plan.team })
const { container } = render(<PriorityLabel />)
expect(screen.getByText(/plansCommon\.priority\.top-priority/i)).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should display "top-priority" for enterprise plan', () => {
setupProviderContext({ type: Plan.enterprise })
render(<PriorityLabel />)
expect(screen.getByText(/plansCommon\.priority\.top-priority/i)).toBeInTheDocument()
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 7. Usage Display Edge Cases
// Tests storage mode, threshold logic, and progress bar color integration
// ═══════════════════════════════════════════════════════════════════════════
describe('Usage Display Edge Cases', () => {
beforeEach(() => {
vi.clearAllMocks()
setupAppContext()
})
// Vector space storage mode behavior
describe('VectorSpace storage mode in PlanComp', () => {
it('should show "< 50" for sandbox plan with low vector space usage', () => {
setupProviderContext({
type: Plan.sandbox,
usage: { vectorSpace: 10 },
total: { vectorSpace: 50 },
})
render(<PlanComp loc="test" />)
// Storage mode: usage below threshold shows "< 50"
expect(screen.getByText(/</)).toBeInTheDocument()
})
it('should show indeterminate progress bar for usage below threshold', () => {
setupProviderContext({
type: Plan.sandbox,
usage: { vectorSpace: 10 },
total: { vectorSpace: 50 },
})
render(<PlanComp loc="test" />)
// Should have an indeterminate progress bar
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should show actual usage for pro plan above threshold', () => {
setupProviderContext({
type: Plan.professional,
usage: { vectorSpace: 1024 },
total: { vectorSpace: 5120 },
})
render(<PlanComp loc="test" />)
// Pro plan above threshold shows actual value
expect(screen.getByText('1024')).toBeInTheDocument()
})
})
// Progress bar color logic through real components
describe('Progress bar color reflects usage severity', () => {
it('should show normal color for low usage percentage', () => {
setupProviderContext({
type: Plan.sandbox,
usage: { buildApps: 1 },
total: { buildApps: 5 },
})
render(<PlanComp loc="test" />)
// 20% usage - normal color
const progressBars = screen.getAllByTestId('billing-progress-bar')
// At least one should have the normal progress color
const hasNormalColor = progressBars.some(bar =>
bar.classList.contains('bg-components-progress-bar-progress-solid'),
)
expect(hasNormalColor).toBe(true)
})
})
// Reset days calculation in PlanComp
describe('Reset days integration', () => {
it('should not show reset for sandbox trigger events (no reset_date)', () => {
setupProviderContext({
type: Plan.sandbox,
total: { triggerEvents: 3000 },
reset: { triggerEvents: null },
})
render(<PlanComp loc="test" />)
// Find the trigger events section - should not have reset text
const triggerSection = screen.getByText(/usagePage\.triggerEvents/i)
const parent = triggerSection.closest('[class*="flex flex-col"]')
// No reset text should appear (sandbox doesn't show reset for triggerEvents)
expect(parent?.textContent).not.toContain('usagePage.resetsIn')
})
it('should show reset for professional trigger events with reset date', () => {
setupProviderContext({
type: Plan.professional,
total: { triggerEvents: 20000 },
reset: { triggerEvents: 14 },
})
render(<PlanComp loc="test" />)
// Professional plan with finite triggerEvents should show reset
const resetTexts = screen.getAllByText(/usagePage\.resetsIn/i)
expect(resetTexts.length).toBeGreaterThan(0)
})
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 8. Cross-Component Upgrade Flow (End-to-End)
// Tests the complete chain: capacity alert → upgrade button → pricing
// ═══════════════════════════════════════════════════════════════════════════
describe('Cross-Component Upgrade Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
setupAppContext()
})
it('should trigger pricing from AppsFull upgrade button', async () => {
const user = userEvent.setup()
setupProviderContext({
type: Plan.sandbox,
usage: { buildApps: 5 },
total: { buildApps: 5 },
})
render(<AppsFull loc="app-create" />)
const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i)
await user.click(upgradeText)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should trigger pricing from VectorSpaceFull upgrade button', async () => {
const user = userEvent.setup()
setupProviderContext({
type: Plan.sandbox,
usage: { vectorSpace: 50 },
total: { vectorSpace: 50 },
})
render(<VectorSpaceFull />)
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
await user.click(upgradeText)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should trigger pricing from AnnotationFull upgrade button', async () => {
const user = userEvent.setup()
setupProviderContext({
type: Plan.sandbox,
usage: { annotatedResponse: 10 },
total: { annotatedResponse: 10 },
})
render(<AnnotationFull />)
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
await user.click(upgradeText)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should trigger pricing from TriggerEventsLimitModal through PlanUpgradeModal', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
setupProviderContext({ type: Plan.professional })
render(
<TriggerEventsLimitModal
show={true}
onClose={onClose}
onUpgrade={vi.fn()}
usage={20000}
total={20000}
/>,
)
// TriggerEventsLimitModal passes onUpgrade to PlanUpgradeModal
// PlanUpgradeModal's upgrade button calls onClose then onUpgrade
const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i)
await user.click(upgradeBtn)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should trigger pricing from AnnotationFullModal upgrade button', async () => {
const user = userEvent.setup()
setupProviderContext({
type: Plan.sandbox,
usage: { annotatedResponse: 10 },
total: { annotatedResponse: 10 },
})
render(<AnnotationFullModal show={true} onHide={vi.fn()} />)
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
await user.click(upgradeText)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,296 @@
/**
* Integration test: Cloud Plan Payment Flow
*
* Tests the payment flow for cloud plan items:
* CloudPlanItem → Button click → permission check → fetch URL → redirect
*
* Covers plan comparison, downgrade prevention, monthly/yearly pricing,
* and workspace manager permission enforcement.
*/
import type { BasicPlan } from '@/app/components/billing/type'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { ALL_PLANS } from '@/app/components/billing/config'
import { PlanRange } from '@/app/components/billing/pricing/plan-switcher/plan-range-switcher'
import CloudPlanItem from '@/app/components/billing/pricing/plans/cloud-plan-item'
import { Plan } from '@/app/components/billing/type'
// ─── Mock state ──────────────────────────────────────────────────────────────
let mockAppCtx: Record<string, unknown> = {}
const mockFetchSubscriptionUrls = vi.fn()
const mockInvoices = vi.fn()
const mockOpenAsyncWindow = vi.fn()
const mockToastNotify = vi.fn()
// ─── Context mocks ───────────────────────────────────────────────────────────
vi.mock('@/context/app-context', () => ({
useAppContext: () => mockAppCtx,
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en-US',
}))
// ─── Service mocks ───────────────────────────────────────────────────────────
vi.mock('@/service/billing', () => ({
fetchSubscriptionUrls: (...args: unknown[]) => mockFetchSubscriptionUrls(...args),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
billing: {
invoices: () => mockInvoices(),
},
},
}))
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => mockOpenAsyncWindow,
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: (args: unknown) => mockToastNotify(args) },
}))
// ─── Navigation mocks ───────────────────────────────────────────────────────
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => '/billing',
useSearchParams: () => new URLSearchParams(),
}))
// ─── Helpers ─────────────────────────────────────────────────────────────────
const setupAppContext = (overrides: Record<string, unknown> = {}) => {
mockAppCtx = {
isCurrentWorkspaceManager: true,
...overrides,
}
}
type RenderCloudPlanItemOptions = {
currentPlan?: BasicPlan
plan?: BasicPlan
planRange?: PlanRange
canPay?: boolean
}
const renderCloudPlanItem = ({
currentPlan = Plan.sandbox,
plan = Plan.professional,
planRange = PlanRange.monthly,
canPay = true,
}: RenderCloudPlanItemOptions = {}) => {
return render(
<CloudPlanItem
currentPlan={currentPlan}
plan={plan}
planRange={planRange}
canPay={canPay}
/>,
)
}
// ═══════════════════════════════════════════════════════════════════════════════
describe('Cloud Plan Payment Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
cleanup()
setupAppContext()
mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://pay.example.com/checkout' })
mockInvoices.mockResolvedValue({ url: 'https://billing.example.com/invoices' })
})
// ─── 1. Plan Display ────────────────────────────────────────────────────
describe('Plan display', () => {
it('should render plan name and description', () => {
renderCloudPlanItem({ plan: Plan.professional })
expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument()
expect(screen.getByText(/plans\.professional\.description/i)).toBeInTheDocument()
})
it('should show "Free" price for sandbox plan', () => {
renderCloudPlanItem({ plan: Plan.sandbox })
expect(screen.getByText(/plansCommon\.free/i)).toBeInTheDocument()
})
it('should show monthly price for paid plans', () => {
renderCloudPlanItem({ plan: Plan.professional, planRange: PlanRange.monthly })
expect(screen.getByText(`$${ALL_PLANS.professional.price}`)).toBeInTheDocument()
})
it('should show yearly discounted price (10 months) and strikethrough original (12 months)', () => {
renderCloudPlanItem({ plan: Plan.professional, planRange: PlanRange.yearly })
const yearlyPrice = ALL_PLANS.professional.price * 10
const originalPrice = ALL_PLANS.professional.price * 12
expect(screen.getByText(`$${yearlyPrice}`)).toBeInTheDocument()
expect(screen.getByText(`$${originalPrice}`)).toBeInTheDocument()
})
it('should show "most popular" badge for professional plan', () => {
renderCloudPlanItem({ plan: Plan.professional })
expect(screen.getByText(/plansCommon\.mostPopular/i)).toBeInTheDocument()
})
it('should not show "most popular" badge for sandbox or team plans', () => {
const { unmount } = renderCloudPlanItem({ plan: Plan.sandbox })
expect(screen.queryByText(/plansCommon\.mostPopular/i)).not.toBeInTheDocument()
unmount()
renderCloudPlanItem({ plan: Plan.team })
expect(screen.queryByText(/plansCommon\.mostPopular/i)).not.toBeInTheDocument()
})
})
// ─── 2. Button Text Logic ───────────────────────────────────────────────
describe('Button text logic', () => {
it('should show "Current Plan" when plan matches current plan', () => {
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument()
})
it('should show "Start for Free" for sandbox plan when not current', () => {
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.sandbox })
expect(screen.getByText(/plansCommon\.startForFree/i)).toBeInTheDocument()
})
it('should show "Start Building" for professional plan when not current', () => {
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional })
expect(screen.getByText(/plansCommon\.startBuilding/i)).toBeInTheDocument()
})
it('should show "Get Started" for team plan when not current', () => {
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.team })
expect(screen.getByText(/plansCommon\.getStarted/i)).toBeInTheDocument()
})
})
// ─── 3. Downgrade Prevention ────────────────────────────────────────────
describe('Downgrade prevention', () => {
it('should disable sandbox button when user is on professional plan (downgrade)', () => {
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.sandbox })
const button = screen.getByRole('button')
expect(button).toBeDisabled()
})
it('should disable sandbox and professional buttons when user is on team plan', () => {
const { unmount } = renderCloudPlanItem({ currentPlan: Plan.team, plan: Plan.sandbox })
expect(screen.getByRole('button')).toBeDisabled()
unmount()
renderCloudPlanItem({ currentPlan: Plan.team, plan: Plan.professional })
expect(screen.getByRole('button')).toBeDisabled()
})
it('should not disable current paid plan button (for invoice management)', () => {
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
const button = screen.getByRole('button')
expect(button).not.toBeDisabled()
})
it('should enable higher-tier plan buttons for upgrade', () => {
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.team })
const button = screen.getByRole('button')
expect(button).not.toBeDisabled()
})
})
// ─── 4. Payment URL Flow ────────────────────────────────────────────────
describe('Payment URL flow', () => {
it('should call fetchSubscriptionUrls with plan and "month" for monthly range', async () => {
const user = userEvent.setup()
// Simulate clicking on a professional plan button (user is on sandbox)
renderCloudPlanItem({
currentPlan: Plan.sandbox,
plan: Plan.professional,
planRange: PlanRange.monthly,
})
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month')
})
})
it('should call fetchSubscriptionUrls with plan and "year" for yearly range', async () => {
const user = userEvent.setup()
renderCloudPlanItem({
currentPlan: Plan.sandbox,
plan: Plan.team,
planRange: PlanRange.yearly,
})
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year')
})
})
it('should open invoice management for current paid plan', async () => {
const user = userEvent.setup()
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
expect(mockOpenAsyncWindow).toHaveBeenCalled()
})
// Should NOT call fetchSubscriptionUrls (invoice, not subscription)
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
})
it('should not do anything when clicking on sandbox free plan button', async () => {
const user = userEvent.setup()
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.sandbox })
const button = screen.getByRole('button')
await user.click(button)
// Wait a tick and verify no actions were taken
await waitFor(() => {
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
expect(mockOpenAsyncWindow).not.toHaveBeenCalled()
})
})
})
// ─── 5. Permission Check ────────────────────────────────────────────────
describe('Permission check', () => {
it('should show error toast when non-manager clicks upgrade button', async () => {
setupAppContext({ isCurrentWorkspaceManager: false })
const user = userEvent.setup()
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional })
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
})
// Should not proceed with payment
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,318 @@
/**
* Integration test: Education Verification Flow
*
* Tests the education plan verification flow in PlanComp:
* PlanComp → handleVerify → useEducationVerify → router.push → education-apply
* PlanComp → handleVerify → error → show VerifyStateModal
*
* Also covers education button visibility based on context flags.
*/
import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { defaultPlan } from '@/app/components/billing/config'
import PlanComp from '@/app/components/billing/plan'
import { Plan } from '@/app/components/billing/type'
// ─── Mock state ──────────────────────────────────────────────────────────────
let mockProviderCtx: Record<string, unknown> = {}
let mockAppCtx: Record<string, unknown> = {}
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockRouterPush = vi.fn()
const mockMutateAsync = vi.fn()
// ─── Context mocks ───────────────────────────────────────────────────────────
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockProviderCtx,
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => mockAppCtx,
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowPricingModal: mockSetShowPricingModal,
}),
useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) =>
selector({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en-US',
}))
// ─── Service mocks ───────────────────────────────────────────────────────────
vi.mock('@/service/use-education', () => ({
useEducationVerify: () => ({
mutateAsync: mockMutateAsync,
isPending: false,
}),
}))
vi.mock('@/service/use-billing', () => ({
useBillingUrl: () => ({
data: 'https://billing.example.com',
isFetching: false,
refetch: vi.fn(),
}),
}))
// ─── Navigation mocks ───────────────────────────────────────────────────────
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockRouterPush }),
usePathname: () => '/billing',
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => vi.fn(),
}))
// ─── External component mocks ───────────────────────────────────────────────
vi.mock('@/app/education-apply/verify-state-modal', () => ({
default: ({ isShow, title, content, email, showLink }: {
isShow: boolean
title?: string
content?: string
email?: string
showLink?: boolean
}) =>
isShow
? (
<div data-testid="verify-state-modal">
{title && <span data-testid="modal-title">{title}</span>}
{content && <span data-testid="modal-content">{content}</span>}
{email && <span data-testid="modal-email">{email}</span>}
{showLink && <span data-testid="modal-show-link">link</span>}
</div>
)
: null,
}))
// ─── Test data factories ────────────────────────────────────────────────────
type PlanOverrides = {
type?: string
usage?: Partial<UsagePlanInfo>
total?: Partial<UsagePlanInfo>
reset?: Partial<UsageResetInfo>
}
const createPlanData = (overrides: PlanOverrides = {}) => ({
...defaultPlan,
...overrides,
type: overrides.type ?? defaultPlan.type,
usage: { ...defaultPlan.usage, ...overrides.usage },
total: { ...defaultPlan.total, ...overrides.total },
reset: { ...defaultPlan.reset, ...overrides.reset },
})
const setupContexts = (
planOverrides: PlanOverrides = {},
providerOverrides: Record<string, unknown> = {},
appOverrides: Record<string, unknown> = {},
) => {
mockProviderCtx = {
plan: createPlanData(planOverrides),
enableBilling: true,
isFetchedPlan: true,
enableEducationPlan: false,
isEducationAccount: false,
allowRefreshEducationVerify: false,
...providerOverrides,
}
mockAppCtx = {
isCurrentWorkspaceManager: true,
userProfile: { email: 'student@university.edu' },
langGeniusVersionInfo: { current_version: '1.0.0' },
...appOverrides,
}
}
// ═══════════════════════════════════════════════════════════════════════════════
describe('Education Verification Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
cleanup()
setupContexts()
})
// ─── 1. Education Button Visibility ─────────────────────────────────────
describe('Education button visibility', () => {
it('should not show verify button when enableEducationPlan is false', () => {
setupContexts({}, { enableEducationPlan: false })
render(<PlanComp loc="test" />)
expect(screen.queryByText(/toVerified/i)).not.toBeInTheDocument()
})
it('should show verify button when enableEducationPlan is true and not yet verified', () => {
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
render(<PlanComp loc="test" />)
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
})
it('should not show verify button when already verified and not about to expire', () => {
setupContexts({}, {
enableEducationPlan: true,
isEducationAccount: true,
allowRefreshEducationVerify: false,
})
render(<PlanComp loc="test" />)
expect(screen.queryByText(/toVerified/i)).not.toBeInTheDocument()
})
it('should show verify button when about to expire (allowRefreshEducationVerify is true)', () => {
setupContexts({}, {
enableEducationPlan: true,
isEducationAccount: true,
allowRefreshEducationVerify: true,
})
render(<PlanComp loc="test" />)
// Shown because isAboutToExpire = allowRefreshEducationVerify = true
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
})
})
// ─── 2. Successful Verification Flow ────────────────────────────────────
describe('Successful verification flow', () => {
it('should navigate to education-apply with token on successful verification', async () => {
mockMutateAsync.mockResolvedValue({ token: 'edu-token-123' })
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
const user = userEvent.setup()
render(<PlanComp loc="test" />)
const verifyButton = screen.getByText(/toVerified/i)
await user.click(verifyButton)
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
expect(mockRouterPush).toHaveBeenCalledWith('/education-apply?token=edu-token-123')
})
})
it('should remove education verifying flag from localStorage on success', async () => {
mockMutateAsync.mockResolvedValue({ token: 'token-xyz' })
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
const user = userEvent.setup()
render(<PlanComp loc="test" />)
await user.click(screen.getByText(/toVerified/i))
await waitFor(() => {
expect(localStorage.removeItem).toHaveBeenCalledWith('educationVerifying')
})
})
})
// ─── 3. Failed Verification Flow ────────────────────────────────────────
describe('Failed verification flow', () => {
it('should show VerifyStateModal with rejection info on error', async () => {
mockMutateAsync.mockRejectedValue(new Error('Verification failed'))
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
const user = userEvent.setup()
render(<PlanComp loc="test" />)
// Modal should not be visible initially
expect(screen.queryByTestId('verify-state-modal')).not.toBeInTheDocument()
const verifyButton = screen.getByText(/toVerified/i)
await user.click(verifyButton)
// Modal should appear after verification failure
await waitFor(() => {
expect(screen.getByTestId('verify-state-modal')).toBeInTheDocument()
})
// Modal should display rejection title and content
expect(screen.getByTestId('modal-title')).toHaveTextContent(/rejectTitle/i)
expect(screen.getByTestId('modal-content')).toHaveTextContent(/rejectContent/i)
})
it('should show email and link in VerifyStateModal', async () => {
mockMutateAsync.mockRejectedValue(new Error('fail'))
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
const user = userEvent.setup()
render(<PlanComp loc="test" />)
await user.click(screen.getByText(/toVerified/i))
await waitFor(() => {
expect(screen.getByTestId('modal-email')).toHaveTextContent('student@university.edu')
expect(screen.getByTestId('modal-show-link')).toBeInTheDocument()
})
})
it('should not redirect on verification failure', async () => {
mockMutateAsync.mockRejectedValue(new Error('fail'))
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
const user = userEvent.setup()
render(<PlanComp loc="test" />)
await user.click(screen.getByText(/toVerified/i))
await waitFor(() => {
expect(screen.getByTestId('verify-state-modal')).toBeInTheDocument()
})
// Should NOT navigate
expect(mockRouterPush).not.toHaveBeenCalled()
})
})
// ─── 4. Education + Upgrade Coexistence ─────────────────────────────────
describe('Education and upgrade button coexistence', () => {
it('should show both education verify and upgrade buttons for sandbox user', () => {
setupContexts(
{ type: Plan.sandbox },
{ enableEducationPlan: true, isEducationAccount: false },
)
render(<PlanComp loc="test" />)
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
})
it('should not show upgrade button for enterprise plan', () => {
setupContexts(
{ type: Plan.enterprise },
{ enableEducationPlan: true, isEducationAccount: false },
)
render(<PlanComp loc="test" />)
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
expect(screen.queryByText(/upgradeBtn\.plain/i)).not.toBeInTheDocument()
})
it('should show team plan with plain upgrade button and education button', () => {
setupContexts(
{ type: Plan.team },
{ enableEducationPlan: true, isEducationAccount: false },
)
render(<PlanComp loc="test" />)
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
expect(screen.getByText(/upgradeBtn\.plain/i)).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,326 @@
/**
* Integration test: Partner Stack Flow
*
* Tests the PartnerStack integration:
* PartnerStack component → usePSInfo hook → cookie management → bind API call
*
* Covers URL param reading, cookie persistence, API bind on mount,
* cookie cleanup after successful bind, and error handling for 400 status.
*/
import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react'
import Cookies from 'js-cookie'
import * as React from 'react'
import usePSInfo from '@/app/components/billing/partner-stack/use-ps-info'
import { PARTNER_STACK_CONFIG } from '@/config'
// ─── Mock state ──────────────────────────────────────────────────────────────
let mockSearchParams = new URLSearchParams()
const mockMutateAsync = vi.fn()
// ─── Module mocks ────────────────────────────────────────────────────────────
vi.mock('next/navigation', () => ({
useSearchParams: () => mockSearchParams,
useRouter: () => ({ push: vi.fn() }),
usePathname: () => '/',
}))
vi.mock('@/service/use-billing', () => ({
useBindPartnerStackInfo: () => ({
mutateAsync: mockMutateAsync,
}),
useBillingUrl: () => ({
data: '',
isFetching: false,
refetch: vi.fn(),
}),
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return {
...actual,
IS_CLOUD_EDITION: true,
PARTNER_STACK_CONFIG: {
cookieName: 'partner_stack_info',
saveCookieDays: 90,
},
}
})
// ─── Cookie helpers ──────────────────────────────────────────────────────────
const getCookieData = () => {
const raw = Cookies.get(PARTNER_STACK_CONFIG.cookieName)
if (!raw)
return null
try {
return JSON.parse(raw)
}
catch {
return null
}
}
const setCookieData = (data: Record<string, string>) => {
Cookies.set(PARTNER_STACK_CONFIG.cookieName, JSON.stringify(data))
}
const clearCookie = () => {
Cookies.remove(PARTNER_STACK_CONFIG.cookieName)
}
// ═══════════════════════════════════════════════════════════════════════════════
describe('Partner Stack Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
cleanup()
clearCookie()
mockSearchParams = new URLSearchParams()
mockMutateAsync.mockResolvedValue({})
})
// ─── 1. URL Param Reading ───────────────────────────────────────────────
describe('URL param reading', () => {
it('should read ps_partner_key and ps_xid from URL search params', () => {
mockSearchParams = new URLSearchParams({
ps_partner_key: 'partner-123',
ps_xid: 'click-456',
})
const { result } = renderHook(() => usePSInfo())
expect(result.current.psPartnerKey).toBe('partner-123')
expect(result.current.psClickId).toBe('click-456')
})
it('should fall back to cookie when URL params are not present', () => {
setCookieData({ partnerKey: 'cookie-partner', clickId: 'cookie-click' })
const { result } = renderHook(() => usePSInfo())
expect(result.current.psPartnerKey).toBe('cookie-partner')
expect(result.current.psClickId).toBe('cookie-click')
})
it('should prefer URL params over cookie values', () => {
setCookieData({ partnerKey: 'cookie-partner', clickId: 'cookie-click' })
mockSearchParams = new URLSearchParams({
ps_partner_key: 'url-partner',
ps_xid: 'url-click',
})
const { result } = renderHook(() => usePSInfo())
expect(result.current.psPartnerKey).toBe('url-partner')
expect(result.current.psClickId).toBe('url-click')
})
it('should return null for both values when no params and no cookie', () => {
const { result } = renderHook(() => usePSInfo())
expect(result.current.psPartnerKey).toBeUndefined()
expect(result.current.psClickId).toBeUndefined()
})
})
// ─── 2. Cookie Persistence (saveOrUpdate) ───────────────────────────────
describe('Cookie persistence via saveOrUpdate', () => {
it('should save PS info to cookie when URL params provide new values', () => {
mockSearchParams = new URLSearchParams({
ps_partner_key: 'new-partner',
ps_xid: 'new-click',
})
const { result } = renderHook(() => usePSInfo())
act(() => result.current.saveOrUpdate())
const cookieData = getCookieData()
expect(cookieData).toEqual({
partnerKey: 'new-partner',
clickId: 'new-click',
})
})
it('should not update cookie when values have not changed', () => {
setCookieData({ partnerKey: 'same-partner', clickId: 'same-click' })
mockSearchParams = new URLSearchParams({
ps_partner_key: 'same-partner',
ps_xid: 'same-click',
})
const cookieSetSpy = vi.spyOn(Cookies, 'set')
const { result } = renderHook(() => usePSInfo())
act(() => result.current.saveOrUpdate())
// Should not call set because values haven't changed
expect(cookieSetSpy).not.toHaveBeenCalled()
cookieSetSpy.mockRestore()
})
it('should not save to cookie when partner key is missing', () => {
mockSearchParams = new URLSearchParams({
ps_xid: 'click-only',
})
const cookieSetSpy = vi.spyOn(Cookies, 'set')
const { result } = renderHook(() => usePSInfo())
act(() => result.current.saveOrUpdate())
expect(cookieSetSpy).not.toHaveBeenCalled()
cookieSetSpy.mockRestore()
})
it('should not save to cookie when click ID is missing', () => {
mockSearchParams = new URLSearchParams({
ps_partner_key: 'partner-only',
})
const cookieSetSpy = vi.spyOn(Cookies, 'set')
const { result } = renderHook(() => usePSInfo())
act(() => result.current.saveOrUpdate())
expect(cookieSetSpy).not.toHaveBeenCalled()
cookieSetSpy.mockRestore()
})
})
// ─── 3. Bind API Flow ──────────────────────────────────────────────────
describe('Bind API flow', () => {
it('should call mutateAsync with partnerKey and clickId on bind', async () => {
mockSearchParams = new URLSearchParams({
ps_partner_key: 'bind-partner',
ps_xid: 'bind-click',
})
const { result } = renderHook(() => usePSInfo())
await act(async () => {
await result.current.bind()
})
expect(mockMutateAsync).toHaveBeenCalledWith({
partnerKey: 'bind-partner',
clickId: 'bind-click',
})
})
it('should remove cookie after successful bind', async () => {
setCookieData({ partnerKey: 'rm-partner', clickId: 'rm-click' })
mockSearchParams = new URLSearchParams({
ps_partner_key: 'rm-partner',
ps_xid: 'rm-click',
})
const { result } = renderHook(() => usePSInfo())
await act(async () => {
await result.current.bind()
})
// Cookie should be removed after successful bind
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
})
it('should remove cookie on 400 error (already bound)', async () => {
mockMutateAsync.mockRejectedValue({ status: 400 })
setCookieData({ partnerKey: 'err-partner', clickId: 'err-click' })
mockSearchParams = new URLSearchParams({
ps_partner_key: 'err-partner',
ps_xid: 'err-click',
})
const { result } = renderHook(() => usePSInfo())
await act(async () => {
await result.current.bind()
})
// Cookie should be removed even on 400
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
})
it('should not remove cookie on non-400 errors', async () => {
mockMutateAsync.mockRejectedValue({ status: 500 })
setCookieData({ partnerKey: 'keep-partner', clickId: 'keep-click' })
mockSearchParams = new URLSearchParams({
ps_partner_key: 'keep-partner',
ps_xid: 'keep-click',
})
const { result } = renderHook(() => usePSInfo())
await act(async () => {
await result.current.bind()
})
// Cookie should still exist for non-400 errors
const cookieData = getCookieData()
expect(cookieData).toBeTruthy()
})
it('should not call bind when partner key is missing', async () => {
mockSearchParams = new URLSearchParams({
ps_xid: 'click-only',
})
const { result } = renderHook(() => usePSInfo())
await act(async () => {
await result.current.bind()
})
expect(mockMutateAsync).not.toHaveBeenCalled()
})
it('should not call bind a second time (idempotency)', async () => {
mockSearchParams = new URLSearchParams({
ps_partner_key: 'partner-once',
ps_xid: 'click-once',
})
const { result } = renderHook(() => usePSInfo())
// First bind
await act(async () => {
await result.current.bind()
})
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
// Second bind should be skipped (hasBind = true)
await act(async () => {
await result.current.bind()
})
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
})
})
// ─── 4. PartnerStack Component Mount ────────────────────────────────────
describe('PartnerStack component mount behavior', () => {
it('should call saveOrUpdate and bind on mount when IS_CLOUD_EDITION is true', async () => {
mockSearchParams = new URLSearchParams({
ps_partner_key: 'mount-partner',
ps_xid: 'mount-click',
})
// Use lazy import so the mocks are applied
const { default: PartnerStack } = await import('@/app/components/billing/partner-stack')
render(<PartnerStack />)
// The component calls saveOrUpdate and bind in useEffect
await waitFor(() => {
// Bind should have been called
expect(mockMutateAsync).toHaveBeenCalledWith({
partnerKey: 'mount-partner',
clickId: 'mount-click',
})
})
// Cookie should have been saved (saveOrUpdate was called before bind)
// After bind succeeds, cookie is removed
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
})
it('should render nothing (return null)', async () => {
const { default: PartnerStack } = await import('@/app/components/billing/partner-stack')
const { container } = render(<PartnerStack />)
expect(container.innerHTML).toBe('')
})
})
})

View File

@@ -0,0 +1,327 @@
/**
* Integration test: Pricing Modal Flow
*
* Tests the full Pricing modal lifecycle:
* Pricing → PlanSwitcher (category + range toggle) → Plans (cloud / self-hosted)
* → CloudPlanItem / SelfHostedPlanItem → Footer
*
* Validates cross-component state propagation when the user switches between
* cloud / self-hosted categories and monthly / yearly plan ranges.
*/
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { ALL_PLANS } from '@/app/components/billing/config'
import Pricing from '@/app/components/billing/pricing'
import { Plan } from '@/app/components/billing/type'
// ─── Mock state ──────────────────────────────────────────────────────────────
let mockProviderCtx: Record<string, unknown> = {}
let mockAppCtx: Record<string, unknown> = {}
// ─── Context mocks ───────────────────────────────────────────────────────────
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockProviderCtx,
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => mockAppCtx,
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en-US',
useGetPricingPageLanguage: () => 'en',
}))
// ─── Service mocks ───────────────────────────────────────────────────────────
vi.mock('@/service/billing', () => ({
fetchSubscriptionUrls: vi.fn().mockResolvedValue({ url: 'https://pay.example.com' }),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
billing: {
invoices: vi.fn().mockResolvedValue({ url: 'https://invoice.example.com' }),
},
},
}))
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => vi.fn(),
}))
// ─── Navigation mocks ───────────────────────────────────────────────────────
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => '/billing',
useSearchParams: () => new URLSearchParams(),
}))
// ─── External component mocks (lightweight) ─────────────────────────────────
vi.mock('@/app/components/base/icons/src/public/billing', () => ({
Azure: () => <span data-testid="icon-azure" />,
GoogleCloud: () => <span data-testid="icon-gcloud" />,
AwsMarketplaceLight: () => <span data-testid="icon-aws-light" />,
AwsMarketplaceDark: () => <span data-testid="icon-aws-dark" />,
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),
useTheme: () => ({ theme: 'light' }),
}))
// Self-hosted List uses t() with returnObjects which returns string in mock;
// mock it to avoid deep i18n dependency (unit tests cover this component)
vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', () => ({
default: ({ plan }: { plan: string }) => (
<div data-testid={`self-hosted-list-${plan}`}>Features</div>
),
}))
// ─── Helpers ─────────────────────────────────────────────────────────────────
const defaultPlanData = {
type: Plan.sandbox,
usage: {
buildApps: 1,
teamMembers: 1,
documentsUploadQuota: 0,
vectorSpace: 10,
annotatedResponse: 1,
triggerEvents: 0,
apiRateLimit: 0,
},
total: {
buildApps: 5,
teamMembers: 1,
documentsUploadQuota: 50,
vectorSpace: 50,
annotatedResponse: 10,
triggerEvents: 3000,
apiRateLimit: 5000,
},
}
const setupContexts = (planOverrides: Record<string, unknown> = {}, appOverrides: Record<string, unknown> = {}) => {
mockProviderCtx = {
plan: { ...defaultPlanData, ...planOverrides },
enableBilling: true,
isFetchedPlan: true,
enableEducationPlan: false,
isEducationAccount: false,
allowRefreshEducationVerify: false,
}
mockAppCtx = {
isCurrentWorkspaceManager: true,
userProfile: { email: 'test@example.com' },
langGeniusVersionInfo: { current_version: '1.0.0' },
...appOverrides,
}
}
// ═══════════════════════════════════════════════════════════════════════════════
describe('Pricing Modal Flow', () => {
const onCancel = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
cleanup()
setupContexts()
})
// ─── 1. Initial Rendering ────────────────────────────────────────────────
describe('Initial rendering', () => {
it('should render header with close button and footer with pricing link', () => {
render(<Pricing onCancel={onCancel} />)
// Header close button exists (multiple plan buttons also exist)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThanOrEqual(1)
// Footer pricing link
expect(screen.getByText(/plansCommon\.comparePlanAndFeatures/i)).toBeInTheDocument()
})
it('should default to cloud category with three cloud plans', () => {
render(<Pricing onCancel={onCancel} />)
// Three cloud plans: sandbox, professional, team
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument()
expect(screen.getByText(/plans\.team\.name/i)).toBeInTheDocument()
})
it('should show plan range switcher (annual billing toggle) by default for cloud', () => {
render(<Pricing onCancel={onCancel} />)
expect(screen.getByText(/plansCommon\.annualBilling/i)).toBeInTheDocument()
})
it('should show tax tip in footer for cloud category', () => {
render(<Pricing onCancel={onCancel} />)
// Use exact match to avoid matching taxTipSecond
expect(screen.getByText('billing.plansCommon.taxTip')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.taxTipSecond')).toBeInTheDocument()
})
})
// ─── 2. Category Switching ───────────────────────────────────────────────
describe('Category switching', () => {
it('should switch to self-hosted plans when clicking self-hosted tab', async () => {
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
// Click the self-hosted tab
const selfTab = screen.getByText(/plansCommon\.self/i)
await user.click(selfTab)
// Self-hosted plans should appear
expect(screen.getByText(/plans\.community\.name/i)).toBeInTheDocument()
expect(screen.getByText(/plans\.premium\.name/i)).toBeInTheDocument()
expect(screen.getByText(/plans\.enterprise\.name/i)).toBeInTheDocument()
// Cloud plans should disappear
expect(screen.queryByText(/plans\.sandbox\.name/i)).not.toBeInTheDocument()
})
it('should hide plan range switcher for self-hosted category', async () => {
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
await user.click(screen.getByText(/plansCommon\.self/i))
// Annual billing toggle should not be visible
expect(screen.queryByText(/plansCommon\.annualBilling/i)).not.toBeInTheDocument()
})
it('should hide tax tip in footer for self-hosted category', async () => {
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
await user.click(screen.getByText(/plansCommon\.self/i))
expect(screen.queryByText('billing.plansCommon.taxTip')).not.toBeInTheDocument()
})
it('should switch back to cloud plans when clicking cloud tab', async () => {
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
// Switch to self-hosted
await user.click(screen.getByText(/plansCommon\.self/i))
expect(screen.queryByText(/plans\.sandbox\.name/i)).not.toBeInTheDocument()
// Switch back to cloud
await user.click(screen.getByText(/plansCommon\.cloud/i))
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
expect(screen.getByText(/plansCommon\.annualBilling/i)).toBeInTheDocument()
})
})
// ─── 3. Plan Range Switching (Monthly ↔ Yearly) ──────────────────────────
describe('Plan range switching', () => {
it('should show monthly prices by default', () => {
render(<Pricing onCancel={onCancel} />)
// Professional monthly price: $59
const proPriceStr = `$${ALL_PLANS.professional.price}`
expect(screen.getByText(proPriceStr)).toBeInTheDocument()
// Team monthly price: $159
const teamPriceStr = `$${ALL_PLANS.team.price}`
expect(screen.getByText(teamPriceStr)).toBeInTheDocument()
})
it('should show "Free" for sandbox plan regardless of range', () => {
render(<Pricing onCancel={onCancel} />)
expect(screen.getByText(/plansCommon\.free/i)).toBeInTheDocument()
})
it('should show "most popular" badge only for professional plan', () => {
render(<Pricing onCancel={onCancel} />)
expect(screen.getByText(/plansCommon\.mostPopular/i)).toBeInTheDocument()
})
})
// ─── 4. Cloud Plan Button States ─────────────────────────────────────────
describe('Cloud plan button states', () => {
it('should show "Current Plan" for the current plan (sandbox)', () => {
setupContexts({ type: Plan.sandbox })
render(<Pricing onCancel={onCancel} />)
expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument()
})
it('should show specific button text for non-current plans', () => {
setupContexts({ type: Plan.sandbox })
render(<Pricing onCancel={onCancel} />)
// Professional button text
expect(screen.getByText(/plansCommon\.startBuilding/i)).toBeInTheDocument()
// Team button text
expect(screen.getByText(/plansCommon\.getStarted/i)).toBeInTheDocument()
})
it('should mark sandbox as "Current Plan" for professional user (enterprise normalized to team)', () => {
setupContexts({ type: Plan.enterprise })
render(<Pricing onCancel={onCancel} />)
// Enterprise is normalized to team for display, so team is "Current Plan"
expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument()
})
})
// ─── 5. Self-Hosted Plan Details ─────────────────────────────────────────
describe('Self-hosted plan details', () => {
it('should show cloud provider icons only for premium plan', async () => {
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
await user.click(screen.getByText(/plansCommon\.self/i))
// Premium plan should show Azure and Google Cloud icons
expect(screen.getByTestId('icon-azure')).toBeInTheDocument()
expect(screen.getByTestId('icon-gcloud')).toBeInTheDocument()
})
it('should show "coming soon" text for premium plan cloud providers', async () => {
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
await user.click(screen.getByText(/plansCommon\.self/i))
expect(screen.getByText(/plans\.premium\.comingSoon/i)).toBeInTheDocument()
})
})
// ─── 6. Close Handling ───────────────────────────────────────────────────
describe('Close handling', () => {
it('should call onCancel when pressing ESC key', () => {
render(<Pricing onCancel={onCancel} />)
// ahooks useKeyPress listens on document for keydown events
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
bubbles: true,
}))
expect(onCancel).toHaveBeenCalledTimes(1)
})
})
// ─── 7. Pricing URL ─────────────────────────────────────────────────────
describe('Pricing page URL', () => {
it('should render pricing link with correct URL', () => {
render(<Pricing onCancel={onCancel} />)
const link = screen.getByText(/plansCommon\.comparePlanAndFeatures/i)
expect(link.closest('a')).toHaveAttribute(
'href',
'https://dify.ai/en/pricing#plans-and-features',
)
})
})
})

View File

@@ -0,0 +1,225 @@
/**
* Integration test: Self-Hosted Plan Flow
*
* Tests the self-hosted plan items:
* SelfHostedPlanItem → Button click → permission check → redirect to external URL
*
* Covers community/premium/enterprise plan rendering, external URL navigation,
* and workspace manager permission enforcement.
*/
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '@/app/components/billing/config'
import SelfHostedPlanItem from '@/app/components/billing/pricing/plans/self-hosted-plan-item'
import { SelfHostedPlan } from '@/app/components/billing/type'
let mockAppCtx: Record<string, unknown> = {}
const mockToastNotify = vi.fn()
const originalLocation = window.location
let assignedHref = ''
vi.mock('@/context/app-context', () => ({
useAppContext: () => mockAppCtx,
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en-US',
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),
useTheme: () => ({ theme: 'light' }),
}))
vi.mock('@/app/components/base/icons/src/public/billing', () => ({
Azure: () => <span data-testid="icon-azure" />,
GoogleCloud: () => <span data-testid="icon-gcloud" />,
AwsMarketplaceLight: () => <span data-testid="icon-aws-light" />,
AwsMarketplaceDark: () => <span data-testid="icon-aws-dark" />,
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: (args: unknown) => mockToastNotify(args) },
}))
vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', () => ({
default: ({ plan }: { plan: string }) => (
<div data-testid={`self-hosted-list-${plan}`}>Features</div>
),
}))
const setupAppContext = (overrides: Record<string, unknown> = {}) => {
mockAppCtx = {
isCurrentWorkspaceManager: true,
...overrides,
}
}
describe('Self-Hosted Plan Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
cleanup()
setupAppContext()
// Mock window.location with minimal getter/setter (Location props are non-enumerable)
assignedHref = ''
Object.defineProperty(window, 'location', {
configurable: true,
value: {
get href() { return assignedHref },
set href(value: string) { assignedHref = value },
},
})
})
afterEach(() => {
// Restore original location
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
})
})
// ─── 1. Plan Rendering ──────────────────────────────────────────────────
describe('Plan rendering', () => {
it('should render community plan with name and description', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
expect(screen.getByText(/plans\.community\.name/i)).toBeInTheDocument()
expect(screen.getByText(/plans\.community\.description/i)).toBeInTheDocument()
})
it('should render premium plan with cloud provider icons', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
expect(screen.getByText(/plans\.premium\.name/i)).toBeInTheDocument()
expect(screen.getByTestId('icon-azure')).toBeInTheDocument()
expect(screen.getByTestId('icon-gcloud')).toBeInTheDocument()
})
it('should render enterprise plan without cloud provider icons', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
expect(screen.getByText(/plans\.enterprise\.name/i)).toBeInTheDocument()
expect(screen.queryByTestId('icon-azure')).not.toBeInTheDocument()
})
it('should not show price tip for community (free) plan', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
expect(screen.queryByText(/plans\.community\.priceTip/i)).not.toBeInTheDocument()
})
it('should show price tip for premium plan', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
expect(screen.getByText(/plans\.premium\.priceTip/i)).toBeInTheDocument()
})
it('should render features list for each plan', () => {
const { unmount: unmount1 } = render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
expect(screen.getByTestId('self-hosted-list-community')).toBeInTheDocument()
unmount1()
const { unmount: unmount2 } = render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
expect(screen.getByTestId('self-hosted-list-premium')).toBeInTheDocument()
unmount2()
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
expect(screen.getByTestId('self-hosted-list-enterprise')).toBeInTheDocument()
})
it('should show AWS marketplace icon for premium plan button', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
expect(screen.getByTestId('icon-aws-light')).toBeInTheDocument()
})
})
// ─── 2. Navigation Flow ─────────────────────────────────────────────────
describe('Navigation flow', () => {
it('should redirect to GitHub when clicking community plan button', async () => {
const user = userEvent.setup()
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
const button = screen.getByRole('button')
await user.click(button)
expect(assignedHref).toBe(getStartedWithCommunityUrl)
})
it('should redirect to AWS Marketplace when clicking premium plan button', async () => {
const user = userEvent.setup()
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
const button = screen.getByRole('button')
await user.click(button)
expect(assignedHref).toBe(getWithPremiumUrl)
})
it('should redirect to Typeform when clicking enterprise plan button', async () => {
const user = userEvent.setup()
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
const button = screen.getByRole('button')
await user.click(button)
expect(assignedHref).toBe(contactSalesUrl)
})
})
// ─── 3. Permission Check ────────────────────────────────────────────────
describe('Permission check', () => {
it('should show error toast when non-manager clicks community button', async () => {
setupAppContext({ isCurrentWorkspaceManager: false })
const user = userEvent.setup()
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
// Should NOT redirect
expect(assignedHref).toBe('')
})
it('should show error toast when non-manager clicks premium button', async () => {
setupAppContext({ isCurrentWorkspaceManager: false })
const user = userEvent.setup()
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
expect(assignedHref).toBe('')
})
it('should show error toast when non-manager clicks enterprise button', async () => {
setupAppContext({ isCurrentWorkspaceManager: false })
const user = userEvent.setup()
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
expect(assignedHref).toBe('')
})
})
})

View File

@@ -0,0 +1,301 @@
/**
* Integration Test: Create Dataset Flow
*
* Tests cross-module data flow: step-one data → step-two hooks → creation params → API call
* Validates data contracts between steps.
*/
import type { CustomFile } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
const mockCreateFirstDocument = vi.fn()
const mockCreateDocument = vi.fn()
vi.mock('@/service/knowledge/use-create-dataset', () => ({
useCreateFirstDocument: () => ({ mutateAsync: mockCreateFirstDocument, isPending: false }),
useCreateDocument: () => ({ mutateAsync: mockCreateDocument, isPending: false }),
getNotionInfo: (pages: { page_id: string }[], credentialId: string) => ({
workspace_id: 'ws-1',
pages: pages.map(p => p.page_id),
notion_credential_id: credentialId,
}),
getWebsiteInfo: (opts: { websitePages: { url: string }[], websiteCrawlProvider: string }) => ({
urls: opts.websitePages.map(p => p.url),
only_main_content: true,
provider: opts.websiteCrawlProvider,
}),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
// Import hooks after mocks
const { useSegmentationState, DEFAULT_SEGMENT_IDENTIFIER, DEFAULT_MAXIMUM_CHUNK_LENGTH, DEFAULT_OVERLAP }
= await import('@/app/components/datasets/create/step-two/hooks')
const { useDocumentCreation, IndexingType }
= await import('@/app/components/datasets/create/step-two/hooks')
const createMockFile = (overrides?: Partial<CustomFile>): CustomFile => ({
id: 'file-1',
name: 'test.txt',
type: 'text/plain',
size: 1024,
extension: '.txt',
mime_type: 'text/plain',
created_at: 0,
created_by: '',
...overrides,
} as CustomFile)
describe('Create Dataset Flow - Cross-Step Data Contract', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Step-One → Step-Two: Segmentation Defaults', () => {
it('should initialise with correct default segmentation values', () => {
const { result } = renderHook(() => useSegmentationState())
expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER)
expect(result.current.maxChunkLength).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH)
expect(result.current.overlap).toBe(DEFAULT_OVERLAP)
expect(result.current.segmentationType).toBe(ProcessMode.general)
})
it('should produce valid process rule for general chunking', () => {
const { result } = renderHook(() => useSegmentationState())
const processRule = result.current.getProcessRule(ChunkingMode.text)
// mode should be segmentationType = ProcessMode.general = 'custom'
expect(processRule.mode).toBe('custom')
expect(processRule.rules.segmentation).toEqual({
separator: '\n\n', // unescaped from \\n\\n
max_tokens: DEFAULT_MAXIMUM_CHUNK_LENGTH,
chunk_overlap: DEFAULT_OVERLAP,
})
// rules is empty initially since no default config loaded
expect(processRule.rules.pre_processing_rules).toEqual([])
})
it('should produce valid process rule for parent-child chunking', () => {
const { result } = renderHook(() => useSegmentationState())
const processRule = result.current.getProcessRule(ChunkingMode.parentChild)
expect(processRule.mode).toBe('hierarchical')
expect(processRule.rules.parent_mode).toBe('paragraph')
expect(processRule.rules.segmentation).toEqual({
separator: '\n\n',
max_tokens: 1024,
})
expect(processRule.rules.subchunk_segmentation).toEqual({
separator: '\n',
max_tokens: 512,
})
})
})
describe('Step-Two → Creation API: Params Building', () => {
it('should build valid creation params for file upload workflow', () => {
const files = [createMockFile()]
const { result: segResult } = renderHook(() => useSegmentationState())
const { result: creationResult } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
const processRule = segResult.current.getProcessRule(ChunkingMode.text)
const retrievalConfig: RetrievalConfig = {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
}
const params = creationResult.current.buildCreationParams(
ChunkingMode.text,
'English',
processRule,
retrievalConfig,
{ provider: 'openai', model: 'text-embedding-ada-002' },
IndexingType.QUALIFIED,
)
expect(params).not.toBeNull()
// File IDs come from file.id (not file.file.id)
expect(params!.data_source.type).toBe(DataSourceType.FILE)
expect(params!.data_source.info_list.file_info_list?.file_ids).toContain('file-1')
expect(params!.indexing_technique).toBe(IndexingType.QUALIFIED)
expect(params!.doc_form).toBe(ChunkingMode.text)
expect(params!.doc_language).toBe('English')
expect(params!.embedding_model).toBe('text-embedding-ada-002')
expect(params!.embedding_model_provider).toBe('openai')
expect(params!.process_rule.mode).toBe('custom')
})
it('should validate params: overlap must not exceed maxChunkLength', () => {
const { result } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files: [createMockFile()],
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
// validateParams returns false (invalid) when overlap > maxChunkLength for general mode
const isValid = result.current.validateParams({
segmentationType: 'general',
maxChunkLength: 100,
limitMaxChunkLength: 4000,
overlap: 200, // overlap > maxChunkLength
indexType: IndexingType.QUALIFIED,
embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' },
rerankModelList: [],
retrievalConfig: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
},
})
expect(isValid).toBe(false)
})
it('should validate params: maxChunkLength must not exceed limit', () => {
const { result } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files: [createMockFile()],
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
const isValid = result.current.validateParams({
segmentationType: 'general',
maxChunkLength: 5000,
limitMaxChunkLength: 4000, // limit < maxChunkLength
overlap: 50,
indexType: IndexingType.QUALIFIED,
embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' },
rerankModelList: [],
retrievalConfig: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
},
})
expect(isValid).toBe(false)
})
})
describe('Full Flow: Segmentation State → Process Rule → Creation Params Consistency', () => {
it('should keep segmentation values consistent across getProcessRule and buildCreationParams', () => {
const files = [createMockFile()]
const { result: segResult } = renderHook(() => useSegmentationState())
const { result: creationResult } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
// Change segmentation settings
act(() => {
segResult.current.setMaxChunkLength(2048)
segResult.current.setOverlap(100)
})
const processRule = segResult.current.getProcessRule(ChunkingMode.text)
expect(processRule.rules.segmentation.max_tokens).toBe(2048)
expect(processRule.rules.segmentation.chunk_overlap).toBe(100)
const params = creationResult.current.buildCreationParams(
ChunkingMode.text,
'Chinese',
processRule,
{
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
},
{ provider: 'openai', model: 'text-embedding-ada-002' },
IndexingType.QUALIFIED,
)
expect(params).not.toBeNull()
expect(params!.process_rule.rules.segmentation.max_tokens).toBe(2048)
expect(params!.process_rule.rules.segmentation.chunk_overlap).toBe(100)
expect(params!.doc_language).toBe('Chinese')
})
it('should support parent-child mode through the full pipeline', () => {
const files = [createMockFile()]
const { result: segResult } = renderHook(() => useSegmentationState())
const { result: creationResult } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
const processRule = segResult.current.getProcessRule(ChunkingMode.parentChild)
const params = creationResult.current.buildCreationParams(
ChunkingMode.parentChild,
'English',
processRule,
{
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
},
{ provider: 'openai', model: 'text-embedding-ada-002' },
IndexingType.QUALIFIED,
)
expect(params).not.toBeNull()
expect(params!.doc_form).toBe(ChunkingMode.parentChild)
expect(params!.process_rule.mode).toBe('hierarchical')
expect(params!.process_rule.rules.parent_mode).toBe('paragraph')
expect(params!.process_rule.rules.subchunk_segmentation).toBeDefined()
})
})
})

View File

@@ -0,0 +1,451 @@
/**
* Integration Test: Dataset Settings Flow
*
* Tests cross-module data contracts in the dataset settings form:
* useFormState hook ↔ index method config ↔ retrieval config ↔ permission state.
*
* The unit-level use-form-state.spec.ts validates the hook in isolation.
* This integration test verifies that changing one configuration dimension
* correctly cascades to dependent parts (index method → retrieval config,
* permission → member list visibility, embedding model → embedding available state).
*/
import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { act, renderHook, waitFor } from '@testing-library/react'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType, WeightedScoreEnum } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
// --- Mocks ---
const mockMutateDatasets = vi.fn()
const mockInvalidDatasetList = vi.fn()
const mockUpdateDatasetSetting = vi.fn().mockResolvedValue({})
vi.mock('@/context/app-context', () => ({
useSelector: () => false,
}))
vi.mock('@/service/datasets', () => ({
updateDatasetSetting: (...args: unknown[]) => mockUpdateDatasetSetting(...args),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'user-1', name: 'Alice', email: 'alice@example.com', role: 'owner', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
{ id: 'user-2', name: 'Bob', email: 'bob@example.com', role: 'admin', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
{ id: 'user-3', name: 'Charlie', email: 'charlie@example.com', role: 'normal', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
],
},
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: () => ({ data: [] }),
}))
vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({
isReRankModelSelected: () => true,
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
}))
// --- Dataset factory ---
const createMockDataset = (overrides?: Partial<DataSet>): DataSet => ({
id: 'ds-settings-1',
name: 'Settings Test Dataset',
description: 'Integration test dataset',
permission: DatasetPermission.onlyMe,
icon_info: {
icon_type: 'emoji',
icon: '📙',
icon_background: '#FFF4ED',
icon_url: '',
},
indexing_technique: 'high_quality',
indexing_status: 'completed',
data_source_type: DataSourceType.FILE,
doc_form: ChunkingMode.text,
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
embedding_available: true,
app_count: 2,
document_count: 10,
total_document_count: 10,
word_count: 5000,
provider: 'vendor',
tags: [],
partial_member_list: [],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 2,
score_threshold: 0.5,
score_threshold_enabled: false,
},
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
} as RetrievalConfig,
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
} as RetrievalConfig,
built_in_field_enabled: false,
keyword_number: 10,
created_by: 'user-1',
updated_by: 'user-1',
updated_at: Date.now(),
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
...overrides,
} as DataSet)
let mockDataset: DataSet = createMockDataset()
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (
selector: (state: { dataset: DataSet | null, mutateDatasetRes: () => void }) => unknown,
) => selector({ dataset: mockDataset, mutateDatasetRes: mockMutateDatasets }),
}))
// Import after mocks are registered
const { useFormState } = await import(
'@/app/components/datasets/settings/form/hooks/use-form-state',
)
describe('Dataset Settings Flow - Cross-Module Configuration Cascade', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUpdateDatasetSetting.mockResolvedValue({})
mockDataset = createMockDataset()
})
describe('Form State Initialization from Dataset → Index Method → Retrieval Config Chain', () => {
it('should initialise all form dimensions from a QUALIFIED dataset', () => {
const { result } = renderHook(() => useFormState())
expect(result.current.name).toBe('Settings Test Dataset')
expect(result.current.description).toBe('Integration test dataset')
expect(result.current.indexMethod).toBe('high_quality')
expect(result.current.embeddingModel).toEqual({
provider: 'openai',
model: 'text-embedding-ada-002',
})
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.semantic)
})
it('should initialise from an ECONOMICAL dataset with keyword retrieval', () => {
mockDataset = createMockDataset({
indexing_technique: IndexingType.ECONOMICAL,
embedding_model: '',
embedding_model_provider: '',
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.keywordSearch,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
} as RetrievalConfig,
})
const { result } = renderHook(() => useFormState())
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
expect(result.current.embeddingModel).toEqual({ provider: '', model: '' })
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch)
})
})
describe('Index Method Change → Retrieval Config Sync', () => {
it('should allow switching index method from QUALIFIED to ECONOMICAL', () => {
const { result } = renderHook(() => useFormState())
expect(result.current.indexMethod).toBe('high_quality')
act(() => {
result.current.setIndexMethod(IndexingType.ECONOMICAL)
})
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
})
it('should allow updating retrieval config after index method switch', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setIndexMethod(IndexingType.ECONOMICAL)
})
act(() => {
result.current.setRetrievalConfig({
...result.current.retrievalConfig,
search_method: RETRIEVE_METHOD.keywordSearch,
reranking_enable: false,
})
})
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch)
expect(result.current.retrievalConfig.reranking_enable).toBe(false)
})
it('should preserve retrieval config when switching back to QUALIFIED', () => {
const { result } = renderHook(() => useFormState())
const originalConfig = { ...result.current.retrievalConfig }
act(() => {
result.current.setIndexMethod(IndexingType.ECONOMICAL)
})
act(() => {
result.current.setIndexMethod(IndexingType.QUALIFIED)
})
expect(result.current.indexMethod).toBe('high_quality')
expect(result.current.retrievalConfig.search_method).toBe(originalConfig.search_method)
})
})
describe('Permission Change → Member List Visibility Logic', () => {
it('should start with onlyMe permission and empty member selection', () => {
const { result } = renderHook(() => useFormState())
expect(result.current.permission).toBe(DatasetPermission.onlyMe)
expect(result.current.selectedMemberIDs).toEqual([])
})
it('should enable member selection when switching to partialMembers', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setPermission(DatasetPermission.partialMembers)
})
expect(result.current.permission).toBe(DatasetPermission.partialMembers)
expect(result.current.memberList).toHaveLength(3)
expect(result.current.memberList.map(m => m.id)).toEqual(['user-1', 'user-2', 'user-3'])
})
it('should persist member selection through permission toggle', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setPermission(DatasetPermission.partialMembers)
result.current.setSelectedMemberIDs(['user-1', 'user-3'])
})
act(() => {
result.current.setPermission(DatasetPermission.allTeamMembers)
})
act(() => {
result.current.setPermission(DatasetPermission.partialMembers)
})
expect(result.current.selectedMemberIDs).toEqual(['user-1', 'user-3'])
})
it('should include partial_member_list in save payload only for partialMembers', async () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setPermission(DatasetPermission.partialMembers)
result.current.setSelectedMemberIDs(['user-2'])
})
await act(async () => {
await result.current.handleSave()
})
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
datasetId: 'ds-settings-1',
body: expect.objectContaining({
permission: DatasetPermission.partialMembers,
partial_member_list: [
expect.objectContaining({ user_id: 'user-2', role: 'admin' }),
],
}),
})
})
it('should not include partial_member_list for allTeamMembers permission', async () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setPermission(DatasetPermission.allTeamMembers)
})
await act(async () => {
await result.current.handleSave()
})
const savedBody = mockUpdateDatasetSetting.mock.calls[0][0].body as Record<string, unknown>
expect(savedBody).not.toHaveProperty('partial_member_list')
})
})
describe('Form Submission Validation → All Fields Together', () => {
it('should reject empty name on save', async () => {
const Toast = await import('@/app/components/base/toast')
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setName('')
})
await act(async () => {
await result.current.handleSave()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
})
it('should include all configuration dimensions in a successful save', async () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setName('Updated Name')
result.current.setDescription('Updated Description')
result.current.setIndexMethod(IndexingType.ECONOMICAL)
result.current.setKeywordNumber(15)
})
await act(async () => {
await result.current.handleSave()
})
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
datasetId: 'ds-settings-1',
body: expect.objectContaining({
name: 'Updated Name',
description: 'Updated Description',
indexing_technique: 'economy',
keyword_number: 15,
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
}),
})
})
it('should call mutateDatasets and invalidDatasetList after successful save', async () => {
const { result } = renderHook(() => useFormState())
await act(async () => {
await result.current.handleSave()
})
await waitFor(() => {
expect(mockMutateDatasets).toHaveBeenCalled()
expect(mockInvalidDatasetList).toHaveBeenCalled()
})
})
})
describe('Embedding Model Change → Retrieval Config Cascade', () => {
it('should update embedding model independently of retrieval config', () => {
const { result } = renderHook(() => useFormState())
const originalRetrievalConfig = { ...result.current.retrievalConfig }
act(() => {
result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-english-v3.0' })
})
expect(result.current.embeddingModel).toEqual({
provider: 'cohere',
model: 'embed-english-v3.0',
})
expect(result.current.retrievalConfig.search_method).toBe(originalRetrievalConfig.search_method)
})
it('should propagate embedding model into weighted retrieval config on save', async () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-v3' })
result.current.setRetrievalConfig({
...result.current.retrievalConfig,
search_method: RETRIEVE_METHOD.hybrid,
weights: {
weight_type: WeightedScoreEnum.Customized,
vector_setting: {
vector_weight: 0.6,
embedding_provider_name: '',
embedding_model_name: '',
},
keyword_setting: { keyword_weight: 0.4 },
},
})
})
await act(async () => {
await result.current.handleSave()
})
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
datasetId: 'ds-settings-1',
body: expect.objectContaining({
embedding_model: 'embed-v3',
embedding_model_provider: 'cohere',
retrieval_model: expect.objectContaining({
weights: expect.objectContaining({
vector_setting: expect.objectContaining({
embedding_provider_name: 'cohere',
embedding_model_name: 'embed-v3',
}),
}),
}),
}),
})
})
it('should handle switching from semantic to hybrid search with embedding model', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setRetrievalConfig({
...result.current.retrievalConfig,
search_method: RETRIEVE_METHOD.hybrid,
reranking_enable: true,
reranking_model: {
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-english-v3.0',
},
})
})
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.hybrid)
expect(result.current.retrievalConfig.reranking_enable).toBe(true)
expect(result.current.embeddingModel.model).toBe('text-embedding-ada-002')
})
})
})

View File

@@ -0,0 +1,335 @@
/**
* Integration Test: Document Management Flow
*
* Tests cross-module interactions: query state (URL-based) → document list sorting →
* document selection → status filter utilities.
* Validates the data contract between documents page hooks and list component hooks.
*/
import type { SimpleDocumentDetail } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useSearchParams: () => new URLSearchParams(''),
useRouter: () => ({ push: mockPush }),
usePathname: () => '/datasets/ds-1/documents',
}))
const { sanitizeStatusValue, normalizeStatusForQuery } = await import(
'@/app/components/datasets/documents/status-filter',
)
const { useDocumentSort } = await import(
'@/app/components/datasets/documents/components/document-list/hooks/use-document-sort',
)
const { useDocumentSelection } = await import(
'@/app/components/datasets/documents/components/document-list/hooks/use-document-selection',
)
const { default: useDocumentListQueryState } = await import(
'@/app/components/datasets/documents/hooks/use-document-list-query-state',
)
type LocalDoc = SimpleDocumentDetail & { percent?: number }
const createDoc = (overrides?: Partial<LocalDoc>): LocalDoc => ({
id: `doc-${Math.random().toString(36).slice(2, 8)}`,
name: 'test-doc.txt',
word_count: 500,
hit_count: 10,
created_at: Date.now() / 1000,
data_source_type: DataSourceType.FILE,
display_status: 'available',
indexing_status: 'completed',
enabled: true,
archived: false,
doc_type: null,
doc_metadata: null,
position: 1,
dataset_process_rule_id: 'rule-1',
...overrides,
} as LocalDoc)
describe('Document Management Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Status Filter Utilities', () => {
it('should sanitize valid status values', () => {
expect(sanitizeStatusValue('all')).toBe('all')
expect(sanitizeStatusValue('available')).toBe('available')
expect(sanitizeStatusValue('error')).toBe('error')
})
it('should fallback to "all" for invalid values', () => {
expect(sanitizeStatusValue(null)).toBe('all')
expect(sanitizeStatusValue(undefined)).toBe('all')
expect(sanitizeStatusValue('')).toBe('all')
expect(sanitizeStatusValue('nonexistent')).toBe('all')
})
it('should handle URL aliases', () => {
// 'active' is aliased to 'available'
expect(sanitizeStatusValue('active')).toBe('available')
})
it('should normalize status for API query', () => {
expect(normalizeStatusForQuery('all')).toBe('all')
// 'enabled' normalized to 'available' for query
expect(normalizeStatusForQuery('enabled')).toBe('available')
})
})
describe('URL-based Query State', () => {
it('should parse default query from empty URL params', () => {
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query).toEqual({
page: 1,
limit: 10,
keyword: '',
status: 'all',
sort: '-created_at',
})
})
it('should update query and push to router', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({ keyword: 'test', page: 2 })
})
expect(mockPush).toHaveBeenCalled()
// The push call should contain the updated query params
const pushUrl = mockPush.mock.calls[0][0] as string
expect(pushUrl).toContain('keyword=test')
expect(pushUrl).toContain('page=2')
})
it('should reset query to defaults', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.resetQuery()
})
expect(mockPush).toHaveBeenCalled()
// Default query omits default values from URL
const pushUrl = mockPush.mock.calls[0][0] as string
expect(pushUrl).toBe('/datasets/ds-1/documents')
})
})
describe('Document Sort Integration', () => {
it('should return documents unsorted when no sort field set', () => {
const docs = [
createDoc({ id: 'doc-1', name: 'Banana.txt', word_count: 300 }),
createDoc({ id: 'doc-2', name: 'Apple.txt', word_count: 100 }),
createDoc({ id: 'doc-3', name: 'Cherry.txt', word_count: 200 }),
]
const { result } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '-created_at',
}))
expect(result.current.sortField).toBeNull()
expect(result.current.sortedDocuments).toHaveLength(3)
})
it('should sort by name descending', () => {
const docs = [
createDoc({ id: 'doc-1', name: 'Banana.txt' }),
createDoc({ id: 'doc-2', name: 'Apple.txt' }),
createDoc({ id: 'doc-3', name: 'Cherry.txt' }),
]
const { result } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '-created_at',
}))
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortField).toBe('name')
expect(result.current.sortOrder).toBe('desc')
const names = result.current.sortedDocuments.map(d => d.name)
expect(names).toEqual(['Cherry.txt', 'Banana.txt', 'Apple.txt'])
})
it('should toggle sort order on same field click', () => {
const docs = [createDoc({ id: 'doc-1', name: 'A.txt' }), createDoc({ id: 'doc-2', name: 'B.txt' })]
const { result } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '-created_at',
}))
act(() => result.current.handleSort('name'))
expect(result.current.sortOrder).toBe('desc')
act(() => result.current.handleSort('name'))
expect(result.current.sortOrder).toBe('asc')
})
it('should filter by status before sorting', () => {
const docs = [
createDoc({ id: 'doc-1', name: 'A.txt', display_status: 'available' }),
createDoc({ id: 'doc-2', name: 'B.txt', display_status: 'error' }),
createDoc({ id: 'doc-3', name: 'C.txt', display_status: 'available' }),
]
const { result } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: 'available',
remoteSortValue: '-created_at',
}))
// Only 'available' documents should remain
expect(result.current.sortedDocuments).toHaveLength(2)
expect(result.current.sortedDocuments.every(d => d.display_status === 'available')).toBe(true)
})
})
describe('Document Selection Integration', () => {
it('should manage selection state externally', () => {
const docs = [
createDoc({ id: 'doc-1' }),
createDoc({ id: 'doc-2' }),
createDoc({ id: 'doc-3' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() => useDocumentSelection({
documents: docs,
selectedIds: [],
onSelectedIdChange,
}))
expect(result.current.isAllSelected).toBe(false)
expect(result.current.isSomeSelected).toBe(false)
})
it('should select all documents', () => {
const docs = [
createDoc({ id: 'doc-1' }),
createDoc({ id: 'doc-2' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() => useDocumentSelection({
documents: docs,
selectedIds: [],
onSelectedIdChange,
}))
act(() => {
result.current.onSelectAll()
})
expect(onSelectedIdChange).toHaveBeenCalledWith(
expect.arrayContaining(['doc-1', 'doc-2']),
)
})
it('should detect all-selected state', () => {
const docs = [
createDoc({ id: 'doc-1' }),
createDoc({ id: 'doc-2' }),
]
const { result } = renderHook(() => useDocumentSelection({
documents: docs,
selectedIds: ['doc-1', 'doc-2'],
onSelectedIdChange: vi.fn(),
}))
expect(result.current.isAllSelected).toBe(true)
})
it('should detect partial selection', () => {
const docs = [
createDoc({ id: 'doc-1' }),
createDoc({ id: 'doc-2' }),
createDoc({ id: 'doc-3' }),
]
const { result } = renderHook(() => useDocumentSelection({
documents: docs,
selectedIds: ['doc-1'],
onSelectedIdChange: vi.fn(),
}))
expect(result.current.isSomeSelected).toBe(true)
expect(result.current.isAllSelected).toBe(false)
})
it('should identify downloadable selected documents (FILE type only)', () => {
const docs = [
createDoc({ id: 'doc-1', data_source_type: DataSourceType.FILE }),
createDoc({ id: 'doc-2', data_source_type: DataSourceType.NOTION }),
]
const { result } = renderHook(() => useDocumentSelection({
documents: docs,
selectedIds: ['doc-1', 'doc-2'],
onSelectedIdChange: vi.fn(),
}))
expect(result.current.downloadableSelectedIds).toEqual(['doc-1'])
})
it('should clear selection', () => {
const onSelectedIdChange = vi.fn()
const docs = [createDoc({ id: 'doc-1' })]
const { result } = renderHook(() => useDocumentSelection({
documents: docs,
selectedIds: ['doc-1'],
onSelectedIdChange,
}))
act(() => {
result.current.clearSelection()
})
expect(onSelectedIdChange).toHaveBeenCalledWith([])
})
})
describe('Cross-Module: Query State → Sort → Selection Pipeline', () => {
it('should maintain consistent default state across all hooks', () => {
const docs = [createDoc({ id: 'doc-1' })]
const { result: queryResult } = renderHook(() => useDocumentListQueryState())
const { result: sortResult } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: queryResult.current.query.status,
remoteSortValue: queryResult.current.query.sort,
}))
const { result: selResult } = renderHook(() => useDocumentSelection({
documents: sortResult.current.sortedDocuments,
selectedIds: [],
onSelectedIdChange: vi.fn(),
}))
// Query defaults
expect(queryResult.current.query.sort).toBe('-created_at')
expect(queryResult.current.query.status).toBe('all')
// Sort inherits 'all' status → no filtering applied
expect(sortResult.current.sortedDocuments).toHaveLength(1)
// Selection starts empty
expect(selResult.current.isAllSelected).toBe(false)
})
})
})

View File

@@ -0,0 +1,215 @@
/**
* Integration Test: External Knowledge Base Creation Flow
*
* Tests the data contract, validation logic, and API interaction
* for external knowledge base creation.
*/
import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations'
import { describe, expect, it } from 'vitest'
// --- Factory ---
const createFormData = (overrides?: Partial<CreateKnowledgeBaseReq>): CreateKnowledgeBaseReq => ({
name: 'My External KB',
description: 'A test external knowledge base',
external_knowledge_api_id: 'api-1',
external_knowledge_id: 'ext-kb-123',
external_retrieval_model: {
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
},
provider: 'external',
...overrides,
})
describe('External Knowledge Base Creation Flow', () => {
describe('Data Contract: CreateKnowledgeBaseReq', () => {
it('should define a complete form structure', () => {
const form = createFormData()
expect(form).toHaveProperty('name')
expect(form).toHaveProperty('external_knowledge_api_id')
expect(form).toHaveProperty('external_knowledge_id')
expect(form).toHaveProperty('external_retrieval_model')
expect(form).toHaveProperty('provider')
expect(form.provider).toBe('external')
})
it('should include retrieval model settings', () => {
const form = createFormData()
expect(form.external_retrieval_model).toEqual({
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
})
})
it('should allow partial overrides', () => {
const form = createFormData({
name: 'Custom Name',
external_retrieval_model: {
top_k: 10,
score_threshold: 0.8,
score_threshold_enabled: true,
},
})
expect(form.name).toBe('Custom Name')
expect(form.external_retrieval_model.top_k).toBe(10)
expect(form.external_retrieval_model.score_threshold_enabled).toBe(true)
})
})
describe('Form Validation Logic', () => {
const isFormValid = (form: CreateKnowledgeBaseReq): boolean => {
return (
form.name.trim() !== ''
&& form.external_knowledge_api_id !== ''
&& form.external_knowledge_id !== ''
&& form.external_retrieval_model.top_k !== undefined
&& form.external_retrieval_model.score_threshold !== undefined
)
}
it('should validate a complete form', () => {
const form = createFormData()
expect(isFormValid(form)).toBe(true)
})
it('should reject empty name', () => {
const form = createFormData({ name: '' })
expect(isFormValid(form)).toBe(false)
})
it('should reject whitespace-only name', () => {
const form = createFormData({ name: ' ' })
expect(isFormValid(form)).toBe(false)
})
it('should reject empty external_knowledge_api_id', () => {
const form = createFormData({ external_knowledge_api_id: '' })
expect(isFormValid(form)).toBe(false)
})
it('should reject empty external_knowledge_id', () => {
const form = createFormData({ external_knowledge_id: '' })
expect(isFormValid(form)).toBe(false)
})
})
describe('Form State Transitions', () => {
it('should start with empty default state', () => {
const defaultForm: CreateKnowledgeBaseReq = {
name: '',
description: '',
external_knowledge_api_id: '',
external_knowledge_id: '',
external_retrieval_model: {
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
},
provider: 'external',
}
// Verify default state matches component's initial useState
expect(defaultForm.name).toBe('')
expect(defaultForm.external_knowledge_api_id).toBe('')
expect(defaultForm.external_knowledge_id).toBe('')
expect(defaultForm.provider).toBe('external')
})
it('should support immutable form updates', () => {
const form = createFormData({ name: '' })
const updated = { ...form, name: 'Updated Name' }
expect(form.name).toBe('')
expect(updated.name).toBe('Updated Name')
// Other fields should remain unchanged
expect(updated.external_knowledge_api_id).toBe(form.external_knowledge_api_id)
})
it('should support retrieval model updates', () => {
const form = createFormData()
const updated = {
...form,
external_retrieval_model: {
...form.external_retrieval_model,
top_k: 10,
score_threshold_enabled: true,
},
}
expect(updated.external_retrieval_model.top_k).toBe(10)
expect(updated.external_retrieval_model.score_threshold_enabled).toBe(true)
// Unchanged field
expect(updated.external_retrieval_model.score_threshold).toBe(0.5)
})
})
describe('API Call Data Contract', () => {
it('should produce a valid API payload from form data', () => {
const form = createFormData()
// The API expects the full CreateKnowledgeBaseReq
expect(form.name).toBeTruthy()
expect(form.external_knowledge_api_id).toBeTruthy()
expect(form.external_knowledge_id).toBeTruthy()
expect(form.provider).toBe('external')
expect(typeof form.external_retrieval_model.top_k).toBe('number')
expect(typeof form.external_retrieval_model.score_threshold).toBe('number')
expect(typeof form.external_retrieval_model.score_threshold_enabled).toBe('boolean')
})
it('should support optional description', () => {
const formWithDesc = createFormData({ description: 'Some description' })
const formWithoutDesc = createFormData({ description: '' })
expect(formWithDesc.description).toBe('Some description')
expect(formWithoutDesc.description).toBe('')
})
it('should validate retrieval model bounds', () => {
const form = createFormData({
external_retrieval_model: {
top_k: 0,
score_threshold: 0,
score_threshold_enabled: false,
},
})
expect(form.external_retrieval_model.top_k).toBe(0)
expect(form.external_retrieval_model.score_threshold).toBe(0)
})
})
describe('External API List Integration', () => {
it('should validate API item structure', () => {
const apiItem = {
id: 'api-1',
name: 'Production API',
settings: {
endpoint: 'https://api.example.com',
api_key: 'key-123',
},
}
expect(apiItem).toHaveProperty('id')
expect(apiItem).toHaveProperty('name')
expect(apiItem).toHaveProperty('settings')
expect(apiItem.settings).toHaveProperty('endpoint')
expect(apiItem.settings).toHaveProperty('api_key')
})
it('should link API selection to form data', () => {
const selectedApi = { id: 'api-2', name: 'Staging API' }
const form = createFormData({
external_knowledge_api_id: selectedApi.id,
})
expect(form.external_knowledge_api_id).toBe('api-2')
})
})
})

View File

@@ -0,0 +1,404 @@
/**
* Integration Test: Hit Testing Flow
*
* Tests the query submission → API response → callback chain flow
* by rendering the actual QueryInput component and triggering user interactions.
* Validates that the production onSubmit logic correctly constructs payloads
* and invokes callbacks on success/failure.
*/
import type {
HitTestingResponse,
Query,
} from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import QueryInput from '@/app/components/datasets/hit-testing/components/query-input'
import { RETRIEVE_METHOD } from '@/types/app'
// --- Mocks ---
vi.mock('@/context/dataset-detail', () => ({
default: {},
useDatasetDetailContext: vi.fn(() => ({ dataset: undefined })),
useDatasetDetailContextWithSelector: vi.fn(() => false),
}))
vi.mock('use-context-selector', () => ({
useContext: vi.fn(() => ({})),
useContextSelector: vi.fn(() => false),
createContext: vi.fn(() => ({})),
}))
vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({
default: ({ textArea, actionButton }: { textArea: React.ReactNode, actionButton: React.ReactNode }) => (
<div data-testid="image-uploader-mock">
{textArea}
{actionButton}
</div>
),
}))
// --- Factories ---
const createRetrievalConfig = (overrides = {}): RetrievalConfig => ({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_mode: undefined,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
weights: undefined,
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
...overrides,
} as RetrievalConfig)
const createHitTestingResponse = (numResults: number): HitTestingResponse => ({
query: {
content: 'What is Dify?',
tsne_position: { x: 0, y: 0 },
},
records: Array.from({ length: numResults }, (_, i) => ({
segment: {
id: `seg-${i}`,
document: {
id: `doc-${i}`,
data_source_type: 'upload_file',
name: `document-${i}.txt`,
doc_type: null as unknown as import('@/models/datasets').DocType,
},
content: `Result content ${i}`,
sign_content: `Result content ${i}`,
position: i + 1,
word_count: 100 + i * 50,
tokens: 50 + i * 25,
keywords: ['test', 'dify'],
hit_count: i * 5,
index_node_hash: `hash-${i}`,
answer: '',
},
content: {
id: `seg-${i}`,
document: {
id: `doc-${i}`,
data_source_type: 'upload_file',
name: `document-${i}.txt`,
doc_type: null as unknown as import('@/models/datasets').DocType,
},
content: `Result content ${i}`,
sign_content: `Result content ${i}`,
position: i + 1,
word_count: 100 + i * 50,
tokens: 50 + i * 25,
keywords: ['test', 'dify'],
hit_count: i * 5,
index_node_hash: `hash-${i}`,
answer: '',
},
score: 0.95 - i * 0.1,
tsne_position: { x: 0, y: 0 },
child_chunks: null,
files: [],
})),
})
const createTextQuery = (content: string): Query[] => [
{ content, content_type: 'text_query', file_info: null },
]
// --- Helpers ---
const findSubmitButton = () => {
const buttons = screen.getAllByRole('button')
const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
expect(submitButton).toBeTruthy()
return submitButton!
}
// --- Tests ---
describe('Hit Testing Flow', () => {
const mockHitTestingMutation = vi.fn()
const mockExternalMutation = vi.fn()
const mockSetHitResult = vi.fn()
const mockSetExternalHitResult = vi.fn()
const mockOnUpdateList = vi.fn()
const mockSetQueries = vi.fn()
const mockOnClickRetrievalMethod = vi.fn()
const mockOnSubmit = vi.fn()
const createDefaultProps = (overrides: Record<string, unknown> = {}) => ({
onUpdateList: mockOnUpdateList,
setHitResult: mockSetHitResult,
setExternalHitResult: mockSetExternalHitResult,
loading: false,
queries: [] as Query[],
setQueries: mockSetQueries,
isExternal: false,
onClickRetrievalMethod: mockOnClickRetrievalMethod,
retrievalConfig: createRetrievalConfig(),
isEconomy: false,
onSubmit: mockOnSubmit,
hitTestingMutation: mockHitTestingMutation,
externalKnowledgeBaseHitTestingMutation: mockExternalMutation,
...overrides,
})
beforeEach(() => {
vi.clearAllMocks()
})
describe('Query Submission → API Call', () => {
it('should call hitTestingMutation with correct payload including retrieval model', async () => {
const retrievalConfig = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
top_k: 3,
score_threshold_enabled: false,
})
mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(3))
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('How does RAG work?'),
retrievalConfig,
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockHitTestingMutation).toHaveBeenCalledWith(
expect.objectContaining({
query: 'How does RAG work?',
attachment_ids: [],
retrieval_model: expect.objectContaining({
search_method: RETRIEVE_METHOD.semantic,
top_k: 3,
score_threshold_enabled: false,
}),
}),
expect.objectContaining({
onSuccess: expect.any(Function),
}),
)
})
})
it('should override search_method to keywordSearch when isEconomy is true', async () => {
const retrievalConfig = createRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic })
mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(1))
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('test query'),
retrievalConfig,
isEconomy: true,
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockHitTestingMutation).toHaveBeenCalledWith(
expect.objectContaining({
retrieval_model: expect.objectContaining({
search_method: RETRIEVE_METHOD.keywordSearch,
}),
}),
expect.anything(),
)
})
})
it('should handle empty results by calling setHitResult with empty records', async () => {
const emptyResponse = createHitTestingResponse(0)
mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => {
options?.onSuccess?.(emptyResponse)
return emptyResponse
})
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('nonexistent topic'),
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockSetHitResult).toHaveBeenCalledWith(
expect.objectContaining({ records: [] }),
)
})
})
it('should not call success callbacks when mutation resolves without onSuccess', async () => {
// Simulate a mutation that resolves but does not invoke the onSuccess callback
mockHitTestingMutation.mockResolvedValue(undefined)
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('test'),
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockHitTestingMutation).toHaveBeenCalled()
})
// Success callbacks should not fire when onSuccess is not invoked
expect(mockSetHitResult).not.toHaveBeenCalled()
expect(mockOnUpdateList).not.toHaveBeenCalled()
expect(mockOnSubmit).not.toHaveBeenCalled()
})
})
describe('API Response → Results Data Contract', () => {
it('should produce results with required segment fields for rendering', () => {
const response = createHitTestingResponse(3)
// Validate each result has the fields needed by ResultItem component
response.records.forEach((record) => {
expect(record.segment).toHaveProperty('id')
expect(record.segment).toHaveProperty('content')
expect(record.segment).toHaveProperty('position')
expect(record.segment).toHaveProperty('word_count')
expect(record.segment).toHaveProperty('document')
expect(record.segment.document).toHaveProperty('name')
expect(record.score).toBeGreaterThanOrEqual(0)
expect(record.score).toBeLessThanOrEqual(1)
})
})
it('should maintain correct score ordering', () => {
const response = createHitTestingResponse(5)
for (let i = 1; i < response.records.length; i++) {
expect(response.records[i - 1].score).toBeGreaterThanOrEqual(response.records[i].score)
}
})
it('should include document metadata for result item display', () => {
const response = createHitTestingResponse(1)
const record = response.records[0]
expect(record.segment.document.name).toBeTruthy()
expect(record.segment.document.data_source_type).toBeTruthy()
})
})
describe('Successful Submission → Callback Chain', () => {
it('should call setHitResult, onUpdateList, and onSubmit after successful submission', async () => {
const response = createHitTestingResponse(3)
mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => {
options?.onSuccess?.(response)
return response
})
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('Test query'),
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockSetHitResult).toHaveBeenCalledWith(response)
expect(mockOnUpdateList).toHaveBeenCalledTimes(1)
expect(mockOnSubmit).toHaveBeenCalledTimes(1)
})
})
it('should trigger records list refresh via onUpdateList after query', async () => {
const response = createHitTestingResponse(1)
mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => {
options?.onSuccess?.(response)
return response
})
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('new query'),
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockOnUpdateList).toHaveBeenCalledTimes(1)
})
})
})
describe('External KB Hit Testing', () => {
it('should use external mutation with correct payload for external datasets', async () => {
mockExternalMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: { records: never[] }) => void }) => {
const response = { records: [] }
options?.onSuccess?.(response)
return response
})
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('test'),
isExternal: true,
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockExternalMutation).toHaveBeenCalledWith(
expect.objectContaining({
query: 'test',
external_retrieval_model: expect.objectContaining({
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
}),
}),
expect.objectContaining({
onSuccess: expect.any(Function),
}),
)
// Internal mutation should NOT be called
expect(mockHitTestingMutation).not.toHaveBeenCalled()
})
})
it('should call setExternalHitResult and onUpdateList on successful external submission', async () => {
const externalResponse = { records: [] }
mockExternalMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: { records: never[] }) => void }) => {
options?.onSuccess?.(externalResponse)
return externalResponse
})
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('external query'),
isExternal: true,
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockSetExternalHitResult).toHaveBeenCalledWith(externalResponse)
expect(mockOnUpdateList).toHaveBeenCalledTimes(1)
})
})
})
})

View File

@@ -0,0 +1,337 @@
/**
* Integration Test: Metadata Management Flow
*
* Tests the cross-module composition of metadata name validation, type constraints,
* and duplicate detection across the metadata management hooks.
*
* The unit-level use-check-metadata-name.spec.ts tests the validation hook alone.
* This integration test verifies:
* - Name validation combined with existing metadata list (duplicate detection)
* - Metadata type enum constraints matching expected data model
* - Full add/rename workflow: validate name → check duplicates → allow or reject
* - Name uniqueness logic: existing metadata keeps its own name, cannot take another's
*/
import type { MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types'
import { renderHook } from '@testing-library/react'
import { DataType } from '@/app/components/datasets/metadata/types'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const { default: useCheckMetadataName } = await import(
'@/app/components/datasets/metadata/hooks/use-check-metadata-name',
)
// --- Factory functions ---
const createMetadataItem = (
id: string,
name: string,
type = DataType.string,
count = 0,
): MetadataItemWithValueLength => ({
id,
name,
type,
count,
})
const createMetadataList = (): MetadataItemWithValueLength[] => [
createMetadataItem('meta-1', 'author', DataType.string, 5),
createMetadataItem('meta-2', 'created_date', DataType.time, 10),
createMetadataItem('meta-3', 'page_count', DataType.number, 3),
createMetadataItem('meta-4', 'source_url', DataType.string, 8),
createMetadataItem('meta-5', 'version', DataType.number, 2),
]
describe('Metadata Management Flow - Cross-Module Validation Composition', () => {
describe('Name Validation Flow: Format Rules', () => {
it('should accept valid lowercase names with underscores', () => {
const { result } = renderHook(() => useCheckMetadataName())
expect(result.current.checkName('valid_name').errorMsg).toBe('')
expect(result.current.checkName('author').errorMsg).toBe('')
expect(result.current.checkName('page_count').errorMsg).toBe('')
expect(result.current.checkName('v2_field').errorMsg).toBe('')
})
it('should reject empty names', () => {
const { result } = renderHook(() => useCheckMetadataName())
expect(result.current.checkName('').errorMsg).toBeTruthy()
})
it('should reject names with invalid characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
expect(result.current.checkName('Author').errorMsg).toBeTruthy()
expect(result.current.checkName('my-field').errorMsg).toBeTruthy()
expect(result.current.checkName('field name').errorMsg).toBeTruthy()
expect(result.current.checkName('1field').errorMsg).toBeTruthy()
expect(result.current.checkName('_private').errorMsg).toBeTruthy()
})
it('should reject names exceeding 255 characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const longName = 'a'.repeat(256)
expect(result.current.checkName(longName).errorMsg).toBeTruthy()
const maxName = 'a'.repeat(255)
expect(result.current.checkName(maxName).errorMsg).toBe('')
})
})
describe('Metadata Type Constraints: Enum Values Match Expected Set', () => {
it('should define exactly three data types', () => {
const typeValues = Object.values(DataType)
expect(typeValues).toHaveLength(3)
})
it('should include string, number, and time types', () => {
expect(DataType.string).toBe('string')
expect(DataType.number).toBe('number')
expect(DataType.time).toBe('time')
})
it('should use consistent types in metadata items', () => {
const metadataList = createMetadataList()
const stringItems = metadataList.filter(m => m.type === DataType.string)
const numberItems = metadataList.filter(m => m.type === DataType.number)
const timeItems = metadataList.filter(m => m.type === DataType.time)
expect(stringItems).toHaveLength(2)
expect(numberItems).toHaveLength(2)
expect(timeItems).toHaveLength(1)
})
it('should enforce type-safe metadata item construction', () => {
const item = createMetadataItem('test-1', 'test_field', DataType.number, 0)
expect(item.id).toBe('test-1')
expect(item.name).toBe('test_field')
expect(item.type).toBe(DataType.number)
expect(item.count).toBe(0)
})
})
describe('Duplicate Name Detection: Add Metadata → Check Name → Detect Duplicates', () => {
it('should detect duplicate names against an existing metadata list', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const checkDuplicate = (newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return false
return existingMetadata.some(m => m.name === newName)
}
expect(checkDuplicate('author')).toBe(true)
expect(checkDuplicate('created_date')).toBe(true)
expect(checkDuplicate('page_count')).toBe(true)
})
it('should allow names that do not conflict with existing metadata', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isNameAvailable = (newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return false
return !existingMetadata.some(m => m.name === newName)
}
expect(isNameAvailable('category')).toBe(true)
expect(isNameAvailable('file_size')).toBe(true)
expect(isNameAvailable('language')).toBe(true)
})
it('should reject names that fail format validation before duplicate check', () => {
const { result } = renderHook(() => useCheckMetadataName())
const validateAndCheckDuplicate = (newName: string): { valid: boolean, reason: string } => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return { valid: false, reason: 'format' }
return { valid: true, reason: '' }
}
expect(validateAndCheckDuplicate('Author').reason).toBe('format')
expect(validateAndCheckDuplicate('').reason).toBe('format')
expect(validateAndCheckDuplicate('valid_name').valid).toBe(true)
})
})
describe('Name Uniqueness Across Edits: Rename Workflow', () => {
it('should allow an existing metadata item to keep its own name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isRenameValid = (itemId: string, newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return false
// Allow keeping the same name (skip self in duplicate check)
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
}
// Author keeping its own name should be valid
expect(isRenameValid('meta-1', 'author')).toBe(true)
// page_count keeping its own name should be valid
expect(isRenameValid('meta-3', 'page_count')).toBe(true)
})
it('should reject renaming to another existing metadata name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isRenameValid = (itemId: string, newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return false
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
}
// Author trying to rename to "page_count" (taken by meta-3)
expect(isRenameValid('meta-1', 'page_count')).toBe(false)
// version trying to rename to "source_url" (taken by meta-4)
expect(isRenameValid('meta-5', 'source_url')).toBe(false)
})
it('should allow renaming to a completely new valid name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isRenameValid = (itemId: string, newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return false
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
}
expect(isRenameValid('meta-1', 'document_author')).toBe(true)
expect(isRenameValid('meta-2', 'publish_date')).toBe(true)
expect(isRenameValid('meta-3', 'total_pages')).toBe(true)
})
it('should reject renaming with an invalid format even if name is unique', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isRenameValid = (itemId: string, newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return false
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
}
expect(isRenameValid('meta-1', 'New Author')).toBe(false)
expect(isRenameValid('meta-2', '2024_date')).toBe(false)
expect(isRenameValid('meta-3', '')).toBe(false)
})
})
describe('Full Metadata Management Workflow', () => {
it('should support a complete add-validate-check-duplicate cycle', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const addMetadataField = (
name: string,
type: DataType,
): { success: boolean, error?: string } => {
const formatCheck = result.current.checkName(name)
if (formatCheck.errorMsg)
return { success: false, error: 'invalid_format' }
if (existingMetadata.some(m => m.name === name))
return { success: false, error: 'duplicate_name' }
existingMetadata.push(createMetadataItem(`meta-${existingMetadata.length + 1}`, name, type))
return { success: true }
}
// Add a valid new field
const result1 = addMetadataField('department', DataType.string)
expect(result1.success).toBe(true)
expect(existingMetadata).toHaveLength(6)
// Try to add a duplicate
const result2 = addMetadataField('author', DataType.string)
expect(result2.success).toBe(false)
expect(result2.error).toBe('duplicate_name')
expect(existingMetadata).toHaveLength(6)
// Try to add an invalid name
const result3 = addMetadataField('Invalid Name', DataType.string)
expect(result3.success).toBe(false)
expect(result3.error).toBe('invalid_format')
expect(existingMetadata).toHaveLength(6)
// Add another valid field
const result4 = addMetadataField('priority_level', DataType.number)
expect(result4.success).toBe(true)
expect(existingMetadata).toHaveLength(7)
})
it('should support a complete rename workflow with validation chain', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const renameMetadataField = (
itemId: string,
newName: string,
): { success: boolean, error?: string } => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return { success: false, error: 'invalid_format' }
if (existingMetadata.some(m => m.name === newName && m.id !== itemId))
return { success: false, error: 'duplicate_name' }
const item = existingMetadata.find(m => m.id === itemId)
if (!item)
return { success: false, error: 'not_found' }
// Simulate the rename in-place
const index = existingMetadata.indexOf(item)
existingMetadata[index] = { ...item, name: newName }
return { success: true }
}
// Rename author to document_author
expect(renameMetadataField('meta-1', 'document_author').success).toBe(true)
expect(existingMetadata.find(m => m.id === 'meta-1')?.name).toBe('document_author')
// Try renaming created_date to page_count (already taken)
expect(renameMetadataField('meta-2', 'page_count').error).toBe('duplicate_name')
// Rename to invalid format
expect(renameMetadataField('meta-3', 'Page Count').error).toBe('invalid_format')
// Rename non-existent item
expect(renameMetadataField('meta-999', 'something').error).toBe('not_found')
})
it('should maintain validation consistency across multiple operations', () => {
const { result } = renderHook(() => useCheckMetadataName())
// Validate the same name multiple times for consistency
const name = 'consistent_field'
const results = Array.from({ length: 5 }, () => result.current.checkName(name))
expect(results.every(r => r.errorMsg === '')).toBe(true)
// Validate an invalid name multiple times
const invalidResults = Array.from({ length: 5 }, () => result.current.checkName('Invalid'))
expect(invalidResults.every(r => r.errorMsg !== '')).toBe(true)
})
})
})

View File

@@ -0,0 +1,477 @@
/**
* Integration Test: Pipeline Data Source Store Composition
*
* Tests cross-slice interactions in the pipeline data source Zustand store.
* The unit-level slice specs test each slice in isolation.
* This integration test verifies:
* - Store initialization produces correct defaults across all slices
* - Cross-slice coordination (e.g. credential shared across slices)
* - State isolation: changes in one slice do not affect others
* - Full workflow simulation through credential → source → data path
*/
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem, FileItem } from '@/models/datasets'
import type { OnlineDriveFile } from '@/models/pipeline'
import { createDataSourceStore } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store'
import { CrawlStep } from '@/models/datasets'
import { OnlineDriveFileType } from '@/models/pipeline'
// --- Factory functions ---
const createFileItem = (id: string): FileItem => ({
fileID: id,
file: { id, name: `${id}.txt`, size: 1024 } as FileItem['file'],
progress: 100,
})
const createCrawlResultItem = (url: string, title?: string): CrawlResultItem => ({
title: title ?? `Page: ${url}`,
markdown: `# ${title ?? url}\n\nContent for ${url}`,
description: `Description for ${url}`,
source_url: url,
})
const createOnlineDriveFile = (id: string, name: string, type = OnlineDriveFileType.file): OnlineDriveFile => ({
id,
name,
size: 2048,
type,
})
const createNotionPage = (pageId: string): NotionPage => ({
page_id: pageId,
page_name: `Page ${pageId}`,
page_icon: null,
is_bound: true,
parent_id: 'parent-1',
type: 'page',
workspace_id: 'ws-1',
})
describe('Pipeline Data Source Store Composition - Cross-Slice Integration', () => {
describe('Store Initialization → All Slices Have Correct Defaults', () => {
it('should create a store with all five slices combined', () => {
const store = createDataSourceStore()
const state = store.getState()
// Common slice defaults
expect(state.currentCredentialId).toBe('')
expect(state.currentNodeIdRef.current).toBe('')
// Local file slice defaults
expect(state.localFileList).toEqual([])
expect(state.currentLocalFile).toBeUndefined()
// Online document slice defaults
expect(state.documentsData).toEqual([])
expect(state.onlineDocuments).toEqual([])
expect(state.searchValue).toBe('')
expect(state.selectedPagesId).toEqual(new Set())
// Website crawl slice defaults
expect(state.websitePages).toEqual([])
expect(state.step).toBe(CrawlStep.init)
expect(state.previewIndex).toBe(-1)
// Online drive slice defaults
expect(state.breadcrumbs).toEqual([])
expect(state.prefix).toEqual([])
expect(state.keywords).toBe('')
expect(state.selectedFileIds).toEqual([])
expect(state.onlineDriveFileList).toEqual([])
expect(state.bucket).toBe('')
expect(state.hasBucket).toBe(false)
})
})
describe('Cross-Slice Coordination: Shared Credential', () => {
it('should set credential that is accessible from the common slice', () => {
const store = createDataSourceStore()
store.getState().setCurrentCredentialId('cred-abc-123')
expect(store.getState().currentCredentialId).toBe('cred-abc-123')
})
it('should allow credential update independently of all other slices', () => {
const store = createDataSourceStore()
store.getState().setLocalFileList([createFileItem('f1')])
store.getState().setCurrentCredentialId('cred-xyz')
expect(store.getState().currentCredentialId).toBe('cred-xyz')
expect(store.getState().localFileList).toHaveLength(1)
})
})
describe('Local File Workflow: Set Files → Verify List → Clear', () => {
it('should set and retrieve local file list', () => {
const store = createDataSourceStore()
const files = [createFileItem('f1'), createFileItem('f2'), createFileItem('f3')]
store.getState().setLocalFileList(files)
expect(store.getState().localFileList).toHaveLength(3)
expect(store.getState().localFileList[0].fileID).toBe('f1')
expect(store.getState().localFileList[2].fileID).toBe('f3')
})
it('should update preview ref when setting file list', () => {
const store = createDataSourceStore()
const files = [createFileItem('f-preview')]
store.getState().setLocalFileList(files)
expect(store.getState().previewLocalFileRef.current).toBeDefined()
})
it('should clear files by setting empty list', () => {
const store = createDataSourceStore()
store.getState().setLocalFileList([createFileItem('f1')])
expect(store.getState().localFileList).toHaveLength(1)
store.getState().setLocalFileList([])
expect(store.getState().localFileList).toHaveLength(0)
})
it('should set and clear current local file selection', () => {
const store = createDataSourceStore()
const file = { id: 'current-file', name: 'current.txt' } as FileItem['file']
store.getState().setCurrentLocalFile(file)
expect(store.getState().currentLocalFile).toBeDefined()
expect(store.getState().currentLocalFile?.id).toBe('current-file')
store.getState().setCurrentLocalFile(undefined)
expect(store.getState().currentLocalFile).toBeUndefined()
})
})
describe('Online Document Workflow: Set Documents → Select Pages → Verify', () => {
it('should set documents data and online documents', () => {
const store = createDataSourceStore()
const pages = [createNotionPage('page-1'), createNotionPage('page-2')]
store.getState().setOnlineDocuments(pages)
expect(store.getState().onlineDocuments).toHaveLength(2)
expect(store.getState().onlineDocuments[0].page_id).toBe('page-1')
})
it('should update preview ref when setting online documents', () => {
const store = createDataSourceStore()
const pages = [createNotionPage('page-preview')]
store.getState().setOnlineDocuments(pages)
expect(store.getState().previewOnlineDocumentRef.current).toBeDefined()
expect(store.getState().previewOnlineDocumentRef.current?.page_id).toBe('page-preview')
})
it('should track selected page IDs', () => {
const store = createDataSourceStore()
const pages = [createNotionPage('p1'), createNotionPage('p2'), createNotionPage('p3')]
store.getState().setOnlineDocuments(pages)
store.getState().setSelectedPagesId(new Set(['p1', 'p3']))
expect(store.getState().selectedPagesId.size).toBe(2)
expect(store.getState().selectedPagesId.has('p1')).toBe(true)
expect(store.getState().selectedPagesId.has('p2')).toBe(false)
expect(store.getState().selectedPagesId.has('p3')).toBe(true)
})
it('should manage search value for filtering documents', () => {
const store = createDataSourceStore()
store.getState().setSearchValue('meeting notes')
expect(store.getState().searchValue).toBe('meeting notes')
})
it('should set and clear current document selection', () => {
const store = createDataSourceStore()
const page = createNotionPage('current-page')
store.getState().setCurrentDocument(page)
expect(store.getState().currentDocument?.page_id).toBe('current-page')
store.getState().setCurrentDocument(undefined)
expect(store.getState().currentDocument).toBeUndefined()
})
})
describe('Website Crawl Workflow: Set Pages → Track Step → Preview', () => {
it('should set website pages and update preview ref', () => {
const store = createDataSourceStore()
const pages = [
createCrawlResultItem('https://example.com'),
createCrawlResultItem('https://example.com/about'),
]
store.getState().setWebsitePages(pages)
expect(store.getState().websitePages).toHaveLength(2)
expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://example.com')
})
it('should manage crawl step transitions', () => {
const store = createDataSourceStore()
expect(store.getState().step).toBe(CrawlStep.init)
store.getState().setStep(CrawlStep.running)
expect(store.getState().step).toBe(CrawlStep.running)
store.getState().setStep(CrawlStep.finished)
expect(store.getState().step).toBe(CrawlStep.finished)
})
it('should set crawl result with data and timing', () => {
const store = createDataSourceStore()
const result = {
data: [createCrawlResultItem('https://test.com')],
time_consuming: 3.5,
}
store.getState().setCrawlResult(result)
expect(store.getState().crawlResult?.data).toHaveLength(1)
expect(store.getState().crawlResult?.time_consuming).toBe(3.5)
})
it('should manage preview index for page navigation', () => {
const store = createDataSourceStore()
store.getState().setPreviewIndex(2)
expect(store.getState().previewIndex).toBe(2)
store.getState().setPreviewIndex(-1)
expect(store.getState().previewIndex).toBe(-1)
})
it('should set and clear current website selection', () => {
const store = createDataSourceStore()
const page = createCrawlResultItem('https://current.com')
store.getState().setCurrentWebsite(page)
expect(store.getState().currentWebsite?.source_url).toBe('https://current.com')
store.getState().setCurrentWebsite(undefined)
expect(store.getState().currentWebsite).toBeUndefined()
})
})
describe('Online Drive Workflow: Breadcrumbs → File Selection → Navigation', () => {
it('should manage breadcrumb navigation', () => {
const store = createDataSourceStore()
store.getState().setBreadcrumbs(['root', 'folder-a', 'subfolder'])
expect(store.getState().breadcrumbs).toEqual(['root', 'folder-a', 'subfolder'])
})
it('should support breadcrumb push/pop pattern', () => {
const store = createDataSourceStore()
store.getState().setBreadcrumbs(['root'])
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-1'])
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-2'])
expect(store.getState().breadcrumbs).toEqual(['root', 'level-1', 'level-2'])
// Pop back one level
store.getState().setBreadcrumbs(store.getState().breadcrumbs.slice(0, -1))
expect(store.getState().breadcrumbs).toEqual(['root', 'level-1'])
})
it('should manage file list and selection', () => {
const store = createDataSourceStore()
const files = [
createOnlineDriveFile('drive-1', 'report.pdf'),
createOnlineDriveFile('drive-2', 'data.csv'),
createOnlineDriveFile('drive-3', 'images', OnlineDriveFileType.folder),
]
store.getState().setOnlineDriveFileList(files)
expect(store.getState().onlineDriveFileList).toHaveLength(3)
store.getState().setSelectedFileIds(['drive-1', 'drive-2'])
expect(store.getState().selectedFileIds).toEqual(['drive-1', 'drive-2'])
})
it('should update preview ref when selecting files', () => {
const store = createDataSourceStore()
const files = [
createOnlineDriveFile('drive-a', 'file-a.txt'),
createOnlineDriveFile('drive-b', 'file-b.txt'),
]
store.getState().setOnlineDriveFileList(files)
store.getState().setSelectedFileIds(['drive-b'])
expect(store.getState().previewOnlineDriveFileRef.current?.id).toBe('drive-b')
})
it('should manage bucket and prefix for S3-like navigation', () => {
const store = createDataSourceStore()
store.getState().setBucket('my-data-bucket')
store.getState().setPrefix(['data', '2024'])
store.getState().setHasBucket(true)
expect(store.getState().bucket).toBe('my-data-bucket')
expect(store.getState().prefix).toEqual(['data', '2024'])
expect(store.getState().hasBucket).toBe(true)
})
it('should manage keywords for search filtering', () => {
const store = createDataSourceStore()
store.getState().setKeywords('quarterly report')
expect(store.getState().keywords).toBe('quarterly report')
})
})
describe('State Isolation: Changes to One Slice Do Not Affect Others', () => {
it('should keep local file state independent from online document state', () => {
const store = createDataSourceStore()
store.getState().setLocalFileList([createFileItem('local-1')])
store.getState().setOnlineDocuments([createNotionPage('notion-1')])
expect(store.getState().localFileList).toHaveLength(1)
expect(store.getState().onlineDocuments).toHaveLength(1)
// Clearing local files should not affect online documents
store.getState().setLocalFileList([])
expect(store.getState().localFileList).toHaveLength(0)
expect(store.getState().onlineDocuments).toHaveLength(1)
})
it('should keep website crawl state independent from online drive state', () => {
const store = createDataSourceStore()
store.getState().setWebsitePages([createCrawlResultItem('https://site.com')])
store.getState().setOnlineDriveFileList([createOnlineDriveFile('d1', 'file.txt')])
expect(store.getState().websitePages).toHaveLength(1)
expect(store.getState().onlineDriveFileList).toHaveLength(1)
// Clearing website pages should not affect drive files
store.getState().setWebsitePages([])
expect(store.getState().websitePages).toHaveLength(0)
expect(store.getState().onlineDriveFileList).toHaveLength(1)
})
it('should create fully independent store instances', () => {
const storeA = createDataSourceStore()
const storeB = createDataSourceStore()
storeA.getState().setCurrentCredentialId('cred-A')
storeA.getState().setLocalFileList([createFileItem('fa-1')])
expect(storeA.getState().currentCredentialId).toBe('cred-A')
expect(storeB.getState().currentCredentialId).toBe('')
expect(storeB.getState().localFileList).toEqual([])
})
})
describe('Full Workflow Simulation: Credential → Source → Data → Verify', () => {
it('should support a complete local file upload workflow', () => {
const store = createDataSourceStore()
// Step 1: Set credential
store.getState().setCurrentCredentialId('upload-cred-1')
// Step 2: Set file list
const files = [createFileItem('upload-1'), createFileItem('upload-2')]
store.getState().setLocalFileList(files)
// Step 3: Select current file for preview
store.getState().setCurrentLocalFile(files[0].file)
// Verify all state is consistent
expect(store.getState().currentCredentialId).toBe('upload-cred-1')
expect(store.getState().localFileList).toHaveLength(2)
expect(store.getState().currentLocalFile?.id).toBe('upload-1')
expect(store.getState().previewLocalFileRef.current).toBeDefined()
})
it('should support a complete website crawl workflow', () => {
const store = createDataSourceStore()
// Step 1: Set credential
store.getState().setCurrentCredentialId('crawl-cred-1')
// Step 2: Init crawl
store.getState().setStep(CrawlStep.running)
// Step 3: Crawl completes with results
const crawledPages = [
createCrawlResultItem('https://docs.example.com/guide'),
createCrawlResultItem('https://docs.example.com/api'),
createCrawlResultItem('https://docs.example.com/faq'),
]
store.getState().setCrawlResult({ data: crawledPages, time_consuming: 12.5 })
store.getState().setStep(CrawlStep.finished)
// Step 4: Set website pages from results
store.getState().setWebsitePages(crawledPages)
// Step 5: Set preview
store.getState().setPreviewIndex(1)
// Verify all state
expect(store.getState().currentCredentialId).toBe('crawl-cred-1')
expect(store.getState().step).toBe(CrawlStep.finished)
expect(store.getState().websitePages).toHaveLength(3)
expect(store.getState().crawlResult?.time_consuming).toBe(12.5)
expect(store.getState().previewIndex).toBe(1)
expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://docs.example.com/guide')
})
it('should support a complete online drive navigation workflow', () => {
const store = createDataSourceStore()
// Step 1: Set credential
store.getState().setCurrentCredentialId('drive-cred-1')
// Step 2: Set bucket
store.getState().setBucket('company-docs')
store.getState().setHasBucket(true)
// Step 3: Navigate into folders
store.getState().setBreadcrumbs(['company-docs'])
store.getState().setPrefix(['projects'])
const folderFiles = [
createOnlineDriveFile('proj-1', 'project-alpha', OnlineDriveFileType.folder),
createOnlineDriveFile('proj-2', 'project-beta', OnlineDriveFileType.folder),
createOnlineDriveFile('readme', 'README.md', OnlineDriveFileType.file),
]
store.getState().setOnlineDriveFileList(folderFiles)
// Step 4: Navigate deeper
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'project-alpha'])
store.getState().setPrefix([...store.getState().prefix, 'project-alpha'])
// Step 5: Select files
store.getState().setOnlineDriveFileList([
createOnlineDriveFile('doc-1', 'spec.pdf'),
createOnlineDriveFile('doc-2', 'design.fig'),
])
store.getState().setSelectedFileIds(['doc-1'])
// Verify full state
expect(store.getState().currentCredentialId).toBe('drive-cred-1')
expect(store.getState().bucket).toBe('company-docs')
expect(store.getState().breadcrumbs).toEqual(['company-docs', 'project-alpha'])
expect(store.getState().prefix).toEqual(['projects', 'project-alpha'])
expect(store.getState().onlineDriveFileList).toHaveLength(2)
expect(store.getState().selectedFileIds).toEqual(['doc-1'])
expect(store.getState().previewOnlineDriveFileRef.current?.name).toBe('spec.pdf')
})
})
})

View File

@@ -0,0 +1,301 @@
/**
* Integration Test: Segment CRUD Flow
*
* Tests segment selection, search/filter, and modal state management across hooks.
* Validates cross-hook data contracts in the completed segment module.
*/
import type { SegmentDetailModel } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useModalState } from '@/app/components/datasets/documents/detail/completed/hooks/use-modal-state'
import { useSearchFilter } from '@/app/components/datasets/documents/detail/completed/hooks/use-search-filter'
import { useSegmentSelection } from '@/app/components/datasets/documents/detail/completed/hooks/use-segment-selection'
const createSegment = (id: string, content = 'Test segment content'): SegmentDetailModel => ({
id,
position: 1,
document_id: 'doc-1',
content,
sign_content: content,
answer: '',
word_count: 50,
tokens: 25,
keywords: ['test'],
index_node_id: 'idx-1',
index_node_hash: 'hash-1',
hit_count: 0,
enabled: true,
disabled_at: 0,
disabled_by: '',
status: 'completed',
created_by: 'user-1',
created_at: Date.now(),
indexing_at: Date.now(),
completed_at: Date.now(),
error: null,
stopped_at: 0,
updated_at: Date.now(),
attachments: [],
} as SegmentDetailModel)
describe('Segment CRUD Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Search and Filter → Segment List Query', () => {
it('should manage search input with debounce', () => {
vi.useFakeTimers()
const onPageChange = vi.fn()
const { result } = renderHook(() => useSearchFilter({ onPageChange }))
act(() => {
result.current.handleInputChange('keyword')
})
expect(result.current.inputValue).toBe('keyword')
expect(result.current.searchValue).toBe('')
act(() => {
vi.advanceTimersByTime(500)
})
expect(result.current.searchValue).toBe('keyword')
expect(onPageChange).toHaveBeenCalledWith(1)
vi.useRealTimers()
})
it('should manage status filter state', () => {
const onPageChange = vi.fn()
const { result } = renderHook(() => useSearchFilter({ onPageChange }))
// status value 1 maps to !!1 = true (enabled)
act(() => {
result.current.onChangeStatus({ value: 1, name: 'enabled' })
})
// onChangeStatus converts: value === 'all' ? 'all' : !!value
expect(result.current.selectedStatus).toBe(true)
act(() => {
result.current.onClearFilter()
})
expect(result.current.selectedStatus).toBe('all')
expect(result.current.inputValue).toBe('')
})
it('should provide status list for filter dropdown', () => {
const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() }))
expect(result.current.statusList).toBeInstanceOf(Array)
expect(result.current.statusList.length).toBe(3) // all, disabled, enabled
})
it('should compute selectDefaultValue based on selectedStatus', () => {
const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() }))
// Initial state: 'all'
expect(result.current.selectDefaultValue).toBe('all')
// Set to enabled (true)
act(() => {
result.current.onChangeStatus({ value: 1, name: 'enabled' })
})
expect(result.current.selectDefaultValue).toBe(1)
// Set to disabled (false)
act(() => {
result.current.onChangeStatus({ value: 0, name: 'disabled' })
})
expect(result.current.selectDefaultValue).toBe(0)
})
})
describe('Segment Selection → Batch Operations', () => {
const segments = [
createSegment('seg-1'),
createSegment('seg-2'),
createSegment('seg-3'),
]
it('should manage individual segment selection', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelected('seg-1')
})
expect(result.current.selectedSegmentIds).toContain('seg-1')
act(() => {
result.current.onSelected('seg-2')
})
expect(result.current.selectedSegmentIds).toContain('seg-1')
expect(result.current.selectedSegmentIds).toContain('seg-2')
expect(result.current.selectedSegmentIds).toHaveLength(2)
})
it('should toggle selection on repeated click', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelected('seg-1')
})
expect(result.current.selectedSegmentIds).toContain('seg-1')
act(() => {
result.current.onSelected('seg-1')
})
expect(result.current.selectedSegmentIds).not.toContain('seg-1')
})
it('should support select all toggle', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelectedAll()
})
expect(result.current.selectedSegmentIds).toHaveLength(3)
expect(result.current.isAllSelected).toBe(true)
act(() => {
result.current.onSelectedAll()
})
expect(result.current.selectedSegmentIds).toHaveLength(0)
expect(result.current.isAllSelected).toBe(false)
})
it('should detect partial selection via isSomeSelected', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelected('seg-1')
})
// After selecting one of three, isSomeSelected should be true
expect(result.current.selectedSegmentIds).toEqual(['seg-1'])
expect(result.current.isSomeSelected).toBe(true)
expect(result.current.isAllSelected).toBe(false)
})
it('should clear selection via onCancelBatchOperation', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelected('seg-1')
result.current.onSelected('seg-2')
})
expect(result.current.selectedSegmentIds).toHaveLength(2)
act(() => {
result.current.onCancelBatchOperation()
})
expect(result.current.selectedSegmentIds).toHaveLength(0)
})
})
describe('Modal State Management', () => {
const onNewSegmentModalChange = vi.fn()
it('should open segment detail modal on card click', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
const segment = createSegment('seg-detail-1', 'Detail content')
act(() => {
result.current.onClickCard(segment)
})
expect(result.current.currSegment.showModal).toBe(true)
expect(result.current.currSegment.segInfo).toBeDefined()
expect(result.current.currSegment.segInfo!.id).toBe('seg-detail-1')
})
it('should close segment detail modal', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
const segment = createSegment('seg-1')
act(() => {
result.current.onClickCard(segment)
})
expect(result.current.currSegment.showModal).toBe(true)
act(() => {
result.current.onCloseSegmentDetail()
})
expect(result.current.currSegment.showModal).toBe(false)
})
it('should manage full screen toggle', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
expect(result.current.fullScreen).toBe(false)
act(() => {
result.current.toggleFullScreen()
})
expect(result.current.fullScreen).toBe(true)
act(() => {
result.current.toggleFullScreen()
})
expect(result.current.fullScreen).toBe(false)
})
it('should manage collapsed state', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
expect(result.current.isCollapsed).toBe(true)
act(() => {
result.current.toggleCollapsed()
})
expect(result.current.isCollapsed).toBe(false)
})
it('should manage new child segment modal', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
expect(result.current.showNewChildSegmentModal).toBe(false)
act(() => {
result.current.handleAddNewChildChunk('chunk-parent-1')
})
expect(result.current.showNewChildSegmentModal).toBe(true)
expect(result.current.currChunkId).toBe('chunk-parent-1')
act(() => {
result.current.onCloseNewChildChunkModal()
})
expect(result.current.showNewChildSegmentModal).toBe(false)
})
})
describe('Cross-Hook Data Flow: Search → Selection → Modal', () => {
it('should maintain independent state across all three hooks', () => {
const segments = [createSegment('seg-1'), createSegment('seg-2')]
const { result: filterResult } = renderHook(() =>
useSearchFilter({ onPageChange: vi.fn() }),
)
const { result: selectionResult } = renderHook(() =>
useSegmentSelection(segments),
)
const { result: modalResult } = renderHook(() =>
useModalState({ onNewSegmentModalChange: vi.fn() }),
)
// Set search filter to enabled
act(() => {
filterResult.current.onChangeStatus({ value: 1, name: 'enabled' })
})
// Select a segment
act(() => {
selectionResult.current.onSelected('seg-1')
})
// Open detail modal
act(() => {
modalResult.current.onClickCard(segments[0])
})
// All states should be independent
expect(filterResult.current.selectedStatus).toBe(true) // !!1
expect(selectionResult.current.selectedSegmentIds).toContain('seg-1')
expect(modalResult.current.currSegment.showModal).toBe(true)
})
})
})

View File

@@ -0,0 +1,192 @@
/**
* Integration test: API Key management flow
*
* Tests the cross-component interaction:
* ApiServer → SecretKeyButton → SecretKeyModal
*
* Renders real ApiServer, SecretKeyButton, and SecretKeyModal together
* with only service-layer mocks. Deep modal interactions (create/delete)
* are covered by unit tests in secret-key-modal.spec.tsx.
*/
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ApiServer from '@/app/components/develop/ApiServer'
// ---------- fake timers (HeadlessUI Dialog transitions) ----------
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
async function flushUI() {
await act(async () => {
vi.runAllTimers()
})
}
// ---------- mocks ----------
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: { id: 'ws-1', name: 'Workspace' },
isCurrentWorkspaceManager: true,
isCurrentWorkspaceEditor: true,
}),
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: vi.fn((val: number) => `Time:${val}`),
formatDate: vi.fn((val: string) => `Date:${val}`),
}),
}))
vi.mock('@/service/apps', () => ({
createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-token-1234567890abcdef' }),
delApikey: vi.fn().mockResolvedValue({}),
}))
vi.mock('@/service/datasets', () => ({
createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }),
delApikey: vi.fn().mockResolvedValue({}),
}))
const mockApiKeys = vi.fn().mockReturnValue({ data: [] })
const mockIsLoading = vi.fn().mockReturnValue(false)
vi.mock('@/service/use-apps', () => ({
useAppApiKeys: () => ({
data: mockApiKeys(),
isLoading: mockIsLoading(),
}),
useInvalidateAppApiKeys: () => vi.fn(),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetApiKeys: () => ({ data: null, isLoading: false }),
useInvalidateDatasetApiKeys: () => vi.fn(),
}))
// ---------- tests ----------
describe('API Key management flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockApiKeys.mockReturnValue({ data: [] })
mockIsLoading.mockReturnValue(false)
})
it('ApiServer renders URL, status badge, and API Key button', () => {
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
expect(screen.getByText('https://api.dify.ai/v1')).toBeInTheDocument()
expect(screen.getByText('appApi.ok')).toBeInTheDocument()
expect(screen.getByText('appApi.apiKey')).toBeInTheDocument()
})
it('clicking API Key button opens SecretKeyModal with real modal content', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
// Click API Key button (rendered by SecretKeyButton)
await act(async () => {
await user.click(screen.getByText('appApi.apiKey'))
})
await flushUI()
// SecretKeyModal should render with real HeadlessUI Dialog
await waitFor(() => {
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument()
expect(screen.getByText('appApi.apiKeyModal.createNewSecretKey')).toBeInTheDocument()
})
})
it('modal shows loading state when API keys are being fetched', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
mockIsLoading.mockReturnValue(true)
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
await act(async () => {
await user.click(screen.getByText('appApi.apiKey'))
})
await flushUI()
await waitFor(() => {
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
// Loading indicator should be present
expect(document.body.querySelector('[role="status"]')).toBeInTheDocument()
})
it('modal can be closed by clicking X icon', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
// Open modal
await act(async () => {
await user.click(screen.getByText('appApi.apiKey'))
})
await flushUI()
await waitFor(() => {
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
// Click X icon to close
const closeIcon = document.body.querySelector('svg.cursor-pointer')
expect(closeIcon).toBeInTheDocument()
await act(async () => {
await user.click(closeIcon!)
})
await flushUI()
// Modal should close
await waitFor(() => {
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKeyTips')).not.toBeInTheDocument()
})
})
it('renders correctly with different API URLs', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const { rerender } = render(
<ApiServer apiBaseUrl="http://localhost:5001/v1" appId="app-dev" />,
)
expect(screen.getByText('http://localhost:5001/v1')).toBeInTheDocument()
// Open modal and verify it works with the same appId
await act(async () => {
await user.click(screen.getByText('appApi.apiKey'))
})
await flushUI()
await waitFor(() => {
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
// Close modal, update URL and re-verify
const xIcon = document.body.querySelector('svg.cursor-pointer')
await act(async () => {
await user.click(xIcon!)
})
await flushUI()
rerender(
<ApiServer apiBaseUrl="https://api.production.com/v1" appId="app-prod" />,
)
expect(screen.getByText('https://api.production.com/v1')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,241 @@
/**
* Integration test: DevelopMain page flow
*
* Tests the full page lifecycle:
* Loading state → App loaded → Header (ApiServer) + Content (Doc) rendered
*
* Uses real DevelopMain, ApiServer, and Doc components with minimal mocks.
*/
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DevelopMain from '@/app/components/develop'
import { AppModeEnum, Theme } from '@/types/app'
// ---------- fake timers ----------
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
async function flushUI() {
await act(async () => {
vi.runAllTimers()
})
}
// ---------- store mock ----------
let storeAppDetail: unknown
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
return selector({ appDetail: storeAppDetail })
},
}))
// ---------- Doc dependencies ----------
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: Theme.light }),
}))
vi.mock('@/i18n-config/language', () => ({
LanguagesSupported: ['en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR', 'de-DE', 'ja-JP'],
}))
// ---------- SecretKeyModal dependencies ----------
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: { id: 'ws-1', name: 'Workspace' },
isCurrentWorkspaceManager: true,
isCurrentWorkspaceEditor: true,
}),
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: vi.fn((val: number) => `Time:${val}`),
formatDate: vi.fn((val: string) => `Date:${val}`),
}),
}))
vi.mock('@/service/apps', () => ({
createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-1234567890' }),
delApikey: vi.fn().mockResolvedValue({}),
}))
vi.mock('@/service/datasets', () => ({
createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }),
delApikey: vi.fn().mockResolvedValue({}),
}))
vi.mock('@/service/use-apps', () => ({
useAppApiKeys: () => ({ data: { data: [] }, isLoading: false }),
useInvalidateAppApiKeys: () => vi.fn(),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetApiKeys: () => ({ data: null, isLoading: false }),
useInvalidateDatasetApiKeys: () => vi.fn(),
}))
// ---------- tests ----------
describe('DevelopMain page flow', () => {
beforeEach(() => {
vi.clearAllMocks()
storeAppDetail = undefined
})
it('should show loading indicator when appDetail is not available', () => {
storeAppDetail = undefined
render(<DevelopMain appId="app-1" />)
expect(screen.getByRole('status')).toBeInTheDocument()
// No content should be visible
expect(screen.queryByText('appApi.apiServer')).not.toBeInTheDocument()
})
it('should render full page when appDetail is loaded', () => {
storeAppDetail = {
id: 'app-1',
name: 'Test App',
api_base_url: 'https://api.test.com/v1',
mode: AppModeEnum.CHAT,
}
render(<DevelopMain appId="app-1" />)
// ApiServer section should be visible
expect(screen.getByText('appApi.apiServer')).toBeInTheDocument()
expect(screen.getByText('https://api.test.com/v1')).toBeInTheDocument()
expect(screen.getByText('appApi.ok')).toBeInTheDocument()
expect(screen.getByText('appApi.apiKey')).toBeInTheDocument()
// Loading should NOT be visible
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('should render Doc component with correct app mode template', () => {
storeAppDetail = {
id: 'app-1',
name: 'Chat App',
api_base_url: 'https://api.test.com/v1',
mode: AppModeEnum.CHAT,
}
const { container } = render(<DevelopMain appId="app-1" />)
// Doc renders an article element with prose classes
const article = container.querySelector('article')
expect(article).toBeInTheDocument()
expect(article?.className).toContain('prose')
})
it('should transition from loading to content when appDetail becomes available', () => {
// Start with no data
storeAppDetail = undefined
const { rerender } = render(<DevelopMain appId="app-1" />)
expect(screen.getByRole('status')).toBeInTheDocument()
// Simulate store update
storeAppDetail = {
id: 'app-1',
name: 'My App',
api_base_url: 'https://api.example.com/v1',
mode: AppModeEnum.COMPLETION,
}
rerender(<DevelopMain appId="app-1" />)
// Content should now be visible
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByText('https://api.example.com/v1')).toBeInTheDocument()
})
it('should open API key modal from the page', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
storeAppDetail = {
id: 'app-1',
name: 'Test App',
api_base_url: 'https://api.test.com/v1',
mode: AppModeEnum.WORKFLOW,
}
render(<DevelopMain appId="app-1" />)
// Click API Key button in the header
await act(async () => {
await user.click(screen.getByText('appApi.apiKey'))
})
await flushUI()
// SecretKeyModal should open
await waitFor(() => {
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
})
it('should render correctly for different app modes', () => {
const modes = [
AppModeEnum.CHAT,
AppModeEnum.COMPLETION,
AppModeEnum.ADVANCED_CHAT,
AppModeEnum.WORKFLOW,
]
for (const mode of modes) {
storeAppDetail = {
id: 'app-1',
name: `${mode} App`,
api_base_url: 'https://api.test.com/v1',
mode,
}
const { container, unmount } = render(<DevelopMain appId="app-1" />)
// ApiServer should always be present
expect(screen.getByText('appApi.apiServer')).toBeInTheDocument()
// Doc should render an article
expect(container.querySelector('article')).toBeInTheDocument()
unmount()
}
})
it('should have correct page layout structure', () => {
storeAppDetail = {
id: 'app-1',
name: 'Test App',
api_base_url: 'https://api.test.com/v1',
mode: AppModeEnum.CHAT,
}
render(<DevelopMain appId="app-1" />)
// Main container: flex column with full height
const mainDiv = screen.getByTestId('develop-main')
expect(mainDiv.className).toContain('flex')
expect(mainDiv.className).toContain('flex-col')
expect(mainDiv.className).toContain('h-full')
// Header section with border
const header = mainDiv.querySelector('.border-b')
expect(header).toBeInTheDocument()
// Content section with overflow scroll
const content = mainDiv.querySelector('.overflow-auto')
expect(content).toBeInTheDocument()
})
})

View File

@@ -1,18 +1,23 @@
/**
* Integration test: Explore App List Flow
*
* Tests the end-to-end user flow of browsing, filtering, searching,
* and adding apps to workspace from the explore page.
*/
import type { Mock } from 'vitest'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { App } from '@/models/explore'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import AppList from '@/app/components/explore/app-list'
import ExploreContext from '@/context/explore-context'
import { fetchAppDetail } from '@/service/explore'
import { AppModeEnum } from '@/types/app'
import AppList from './index'
const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}'
let mockTabValue = allCategoriesEn
const mockSetTab = vi.fn()
let mockExploreData: { categories: string[], allList: App[] } | undefined = { categories: [], allList: [] }
let mockExploreData: { categories: string[], allList: App[] } | undefined
let mockIsLoading = false
let mockIsError = false
const mockHandleImportDSL = vi.fn()
const mockHandleImportDSLConfirm = vi.fn()
@@ -43,7 +48,7 @@ vi.mock('@/service/use-explore', () => ({
useExploreAppList: () => ({
data: mockExploreData,
isLoading: mockIsLoading,
isError: mockIsError,
isError: false,
}),
}))
@@ -96,7 +101,7 @@ vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({
const createApp = (overrides: Partial<App> = {}): App => ({
app: {
id: overrides.app?.id ?? 'app-basic-id',
id: overrides.app?.id ?? 'app-id',
mode: overrides.app?.mode ?? AppModeEnum.CHAT,
icon_type: overrides.app?.icon_type ?? 'emoji',
icon: overrides.app?.icon ?? '😀',
@@ -121,113 +126,80 @@ const createApp = (overrides: Partial<App> = {}): App => ({
is_agent: overrides.is_agent ?? false,
})
const renderWithContext = (hasEditPermission = false, onSuccess?: () => void) => {
return render(
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission,
installedApps: [],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: false,
setShowTryAppPanel: vi.fn(),
}}
>
<AppList onSuccess={onSuccess} />
</ExploreContext.Provider>,
)
const createContextValue = (hasEditPermission = true) => ({
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission,
installedApps: [] as never[],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: false,
setShowTryAppPanel: vi.fn(),
})
const wrapWithContext = (hasEditPermission = true, onSuccess?: () => void) => (
<ExploreContext.Provider value={createContextValue(hasEditPermission)}>
<AppList onSuccess={onSuccess} />
</ExploreContext.Provider>
)
const renderWithContext = (hasEditPermission = true, onSuccess?: () => void) => {
return render(wrapWithContext(hasEditPermission, onSuccess))
}
describe('AppList', () => {
describe('Explore App List Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTabValue = allCategoriesEn
mockExploreData = { categories: [], allList: [] }
mockIsLoading = false
mockIsError = false
mockExploreData = {
categories: ['Writing', 'Translate', 'Programming'],
allList: [
createApp({ app_id: 'app-1', app: { ...createApp().app, name: 'Writer Bot' }, category: 'Writing' }),
createApp({ app_id: 'app-2', app: { ...createApp().app, id: 'app-id-2', name: 'Translator' }, category: 'Translate' }),
createApp({ app_id: 'app-3', app: { ...createApp().app, id: 'app-id-3', name: 'Code Helper' }, category: 'Programming' }),
],
}
})
// Rendering: show loading when categories are not ready.
describe('Rendering', () => {
it('should render loading when the query is loading', () => {
// Arrange
mockExploreData = undefined
mockIsLoading = true
// Act
describe('Browse and Filter Flow', () => {
it('should display all apps when no category filter is applied', () => {
renderWithContext()
// Assert
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.getByText('Writer Bot')).toBeInTheDocument()
expect(screen.getByText('Translator')).toBeInTheDocument()
expect(screen.getByText('Code Helper')).toBeInTheDocument()
})
it('should render app cards when data is available', () => {
// Arrange
mockExploreData = {
categories: ['Writing', 'Translate'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
}
// Act
renderWithContext()
// Assert
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Beta')).toBeInTheDocument()
})
})
// Props: category selection filters the list.
describe('Props', () => {
it('should filter apps by selected category', () => {
// Arrange
mockTabValue = 'Writing'
mockExploreData = {
categories: ['Writing', 'Translate'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
}
// Act
renderWithContext()
// Assert
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.queryByText('Beta')).not.toBeInTheDocument()
expect(screen.getByText('Writer Bot')).toBeInTheDocument()
expect(screen.queryByText('Translator')).not.toBeInTheDocument()
expect(screen.queryByText('Code Helper')).not.toBeInTheDocument()
})
})
// User interactions: search and create flow.
describe('User Interactions', () => {
it('should filter apps by search keywords', async () => {
// Arrange
mockExploreData = {
categories: ['Writing'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
}
it('should filter apps by search keyword', async () => {
renderWithContext()
// Act
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'gam' } })
fireEvent.change(input, { target: { value: 'trans' } })
// Assert
await waitFor(() => {
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
expect(screen.getByText('Gamma')).toBeInTheDocument()
expect(screen.getByText('Translator')).toBeInTheDocument()
expect(screen.queryByText('Writer Bot')).not.toBeInTheDocument()
expect(screen.queryByText('Code Helper')).not.toBeInTheDocument()
})
})
})
it('should handle create flow and confirm DSL when pending', async () => {
// Arrange
describe('Add to Workspace Flow', () => {
it('should complete the full add-to-workspace flow with DSL confirmation', async () => {
// Step 1: User clicks "Add to Workspace" on an app card
const onSuccess = vi.fn()
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
};
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content' })
;(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content' })
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => {
options.onPending?.()
})
@@ -235,19 +207,27 @@ describe('AppList', () => {
options.onSuccess?.()
})
// Act
renderWithContext(true, onSuccess)
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
// Step 2: Click add to workspace button - opens create modal
fireEvent.click(screen.getAllByText('explore.appCard.addToWorkspace')[0])
// Step 3: Confirm creation in modal
fireEvent.click(await screen.findByTestId('confirm-create'))
// Assert
// Step 4: API fetches app detail
await waitFor(() => {
expect(fetchAppDetail).toHaveBeenCalledWith('app-basic-id')
expect(fetchAppDetail).toHaveBeenCalledWith('app-id')
})
expect(mockHandleImportDSL).toHaveBeenCalledTimes(1)
expect(await screen.findByTestId('dsl-confirm-modal')).toBeInTheDocument()
// Step 5: DSL import triggers pending confirmation
expect(mockHandleImportDSL).toHaveBeenCalledTimes(1)
// Step 6: DSL confirm modal appears and user confirms
expect(await screen.findByTestId('dsl-confirm-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('dsl-confirm'))
// Step 7: Flow completes successfully
await waitFor(() => {
expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1)
expect(onSuccess).toHaveBeenCalledTimes(1)
@@ -255,30 +235,39 @@ describe('AppList', () => {
})
})
// Edge cases: handle clearing search keywords.
describe('Edge Cases', () => {
it('should reset search results when clear icon is clicked', async () => {
// Arrange
describe('Loading and Empty States', () => {
it('should transition from loading to content', () => {
// Step 1: Loading state
mockIsLoading = true
mockExploreData = undefined
const { rerender } = render(wrapWithContext())
expect(screen.getByRole('status')).toBeInTheDocument()
// Step 2: Data loads
mockIsLoading = false
mockExploreData = {
categories: ['Writing'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
allList: [createApp()],
}
renderWithContext()
rerender(wrapWithContext())
// Act
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'gam' } })
await waitFor(() => {
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
})
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByText('Alpha')).toBeInTheDocument()
})
})
fireEvent.click(screen.getByTestId('input-clear'))
describe('Permission-Based Behavior', () => {
it('should hide add-to-workspace button when user has no edit permission', () => {
renderWithContext(false)
// Assert
await waitFor(() => {
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Gamma')).toBeInTheDocument()
})
expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument()
})
it('should show add-to-workspace button when user has edit permission', () => {
renderWithContext(true)
expect(screen.getAllByText('explore.appCard.addToWorkspace').length).toBeGreaterThan(0)
})
})
})

View File

@@ -0,0 +1,260 @@
/**
* Integration test: Installed App Flow
*
* Tests the end-to-end user flow of installed apps: sidebar navigation,
* mode-based routing (Chat / Completion / Workflow), and lifecycle
* operations (pin/unpin, delete).
*/
import type { Mock } from 'vitest'
import type { InstalledApp as InstalledAppModel } from '@/models/explore'
import { render, screen, waitFor } from '@testing-library/react'
import { useContext } from 'use-context-selector'
import InstalledApp from '@/app/components/explore/installed-app'
import { useWebAppStore } from '@/context/web-app-context'
import { AccessMode } from '@/models/access-control'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
import { AppModeEnum } from '@/types/app'
// Mock external dependencies
vi.mock('use-context-selector', () => ({
useContext: vi.fn(),
createContext: vi.fn(() => ({})),
}))
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: vi.fn(),
}))
vi.mock('@/service/access-control', () => ({
useGetUserCanAccessApp: vi.fn(),
}))
vi.mock('@/service/use-explore', () => ({
useGetInstalledAppAccessModeByAppId: vi.fn(),
useGetInstalledAppParams: vi.fn(),
useGetInstalledAppMeta: vi.fn(),
}))
vi.mock('@/app/components/share/text-generation', () => ({
default: ({ isWorkflow }: { isWorkflow?: boolean }) => (
<div data-testid="text-generation-app">
Text Generation
{isWorkflow && ' (Workflow)'}
</div>
),
}))
vi.mock('@/app/components/base/chat/chat-with-history', () => ({
default: ({ installedAppInfo }: { installedAppInfo?: InstalledAppModel }) => (
<div data-testid="chat-with-history">
Chat -
{' '}
{installedAppInfo?.app.name}
</div>
),
}))
describe('Installed App Flow', () => {
const mockUpdateAppInfo = vi.fn()
const mockUpdateWebAppAccessMode = vi.fn()
const mockUpdateAppParams = vi.fn()
const mockUpdateWebAppMeta = vi.fn()
const mockUpdateUserCanAccessApp = vi.fn()
const createInstalledApp = (mode: AppModeEnum = AppModeEnum.CHAT): InstalledAppModel => ({
id: 'installed-app-1',
app: {
id: 'real-app-id',
name: 'Integration Test App',
mode,
icon_type: 'emoji',
icon: '🧪',
icon_background: '#FFFFFF',
icon_url: '',
description: 'Test app for integration',
use_icon_as_answer_icon: false,
},
uninstallable: true,
is_pinned: false,
})
const mockAppParams = {
user_input_form: [],
file_upload: { image: { enabled: false, number_limits: 0, transfer_methods: [] } },
system_parameters: {},
}
type MockOverrides = {
context?: { installedApps?: InstalledAppModel[], isFetchingInstalledApps?: boolean }
accessMode?: { isFetching?: boolean, data?: unknown, error?: unknown }
params?: { isFetching?: boolean, data?: unknown, error?: unknown }
meta?: { isFetching?: boolean, data?: unknown, error?: unknown }
userAccess?: { data?: unknown, error?: unknown }
}
const setupDefaultMocks = (app?: InstalledAppModel, overrides: MockOverrides = {}) => {
;(useContext as Mock).mockReturnValue({
installedApps: app ? [app] : [],
isFetchingInstalledApps: false,
...overrides.context,
})
;(useWebAppStore as unknown as Mock).mockImplementation((selector: (state: Record<string, Mock>) => unknown) => {
return selector({
updateAppInfo: mockUpdateAppInfo,
updateWebAppAccessMode: mockUpdateWebAppAccessMode,
updateAppParams: mockUpdateAppParams,
updateWebAppMeta: mockUpdateWebAppMeta,
updateUserCanAccessApp: mockUpdateUserCanAccessApp,
})
})
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
isFetching: false,
data: { accessMode: AccessMode.PUBLIC },
error: null,
...overrides.accessMode,
})
;(useGetInstalledAppParams as Mock).mockReturnValue({
isFetching: false,
data: mockAppParams,
error: null,
...overrides.params,
})
;(useGetInstalledAppMeta as Mock).mockReturnValue({
isFetching: false,
data: { tool_icons: {} },
error: null,
...overrides.meta,
})
;(useGetUserCanAccessApp as Mock).mockReturnValue({
data: { result: true },
error: null,
...overrides.userAccess,
})
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Mode-Based Routing', () => {
it.each([
[AppModeEnum.CHAT, 'chat-with-history'],
[AppModeEnum.ADVANCED_CHAT, 'chat-with-history'],
[AppModeEnum.AGENT_CHAT, 'chat-with-history'],
])('should render ChatWithHistory for %s mode', (mode, testId) => {
const app = createInstalledApp(mode)
setupDefaultMocks(app)
render(<InstalledApp id="installed-app-1" />)
expect(screen.getByTestId(testId)).toBeInTheDocument()
expect(screen.getByText(/Integration Test App/)).toBeInTheDocument()
})
it('should render TextGenerationApp for COMPLETION mode', () => {
const app = createInstalledApp(AppModeEnum.COMPLETION)
setupDefaultMocks(app)
render(<InstalledApp id="installed-app-1" />)
expect(screen.getByTestId('text-generation-app')).toBeInTheDocument()
expect(screen.getByText('Text Generation')).toBeInTheDocument()
expect(screen.queryByText(/Workflow/)).not.toBeInTheDocument()
})
it('should render TextGenerationApp with workflow flag for WORKFLOW mode', () => {
const app = createInstalledApp(AppModeEnum.WORKFLOW)
setupDefaultMocks(app)
render(<InstalledApp id="installed-app-1" />)
expect(screen.getByTestId('text-generation-app')).toBeInTheDocument()
expect(screen.getByText(/Workflow/)).toBeInTheDocument()
})
})
describe('Data Loading Flow', () => {
it('should show loading spinner when params are being fetched', () => {
const app = createInstalledApp()
setupDefaultMocks(app, { params: { isFetching: true, data: null } })
const { container } = render(<InstalledApp id="installed-app-1" />)
expect(container.querySelector('svg.spin-animation')).toBeInTheDocument()
expect(screen.queryByTestId('chat-with-history')).not.toBeInTheDocument()
})
it('should render content when all data is available', () => {
const app = createInstalledApp()
setupDefaultMocks(app)
render(<InstalledApp id="installed-app-1" />)
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
})
})
describe('Error Handling Flow', () => {
it('should show error state when API fails', () => {
const app = createInstalledApp()
setupDefaultMocks(app, { params: { data: null, error: new Error('Network error') } })
render(<InstalledApp id="installed-app-1" />)
expect(screen.getByText(/Network error/)).toBeInTheDocument()
})
it('should show 404 when app is not found', () => {
setupDefaultMocks(undefined, {
accessMode: { data: null },
params: { data: null },
meta: { data: null },
userAccess: { data: null },
})
render(<InstalledApp id="nonexistent" />)
expect(screen.getByText(/404/)).toBeInTheDocument()
})
it('should show 403 when user has no permission', () => {
const app = createInstalledApp()
setupDefaultMocks(app, { userAccess: { data: { result: false } } })
render(<InstalledApp id="installed-app-1" />)
expect(screen.getByText(/403/)).toBeInTheDocument()
})
})
describe('State Synchronization', () => {
it('should update all stores when app data is loaded', async () => {
const app = createInstalledApp()
setupDefaultMocks(app)
render(<InstalledApp id="installed-app-1" />)
await waitFor(() => {
expect(mockUpdateAppInfo).toHaveBeenCalledWith(
expect.objectContaining({
app_id: 'installed-app-1',
site: expect.objectContaining({
title: 'Integration Test App',
icon: '🧪',
}),
}),
)
expect(mockUpdateAppParams).toHaveBeenCalledWith(mockAppParams)
expect(mockUpdateWebAppMeta).toHaveBeenCalledWith({ tool_icons: {} })
expect(mockUpdateWebAppAccessMode).toHaveBeenCalledWith(AccessMode.PUBLIC)
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(true)
})
})
})
})

View File

@@ -0,0 +1,225 @@
import type { IExplore } from '@/context/explore-context'
/**
* Integration test: Sidebar Lifecycle Flow
*
* Tests the sidebar interactions for installed apps lifecycle:
* navigation, pin/unpin ordering, delete confirmation, and
* fold/unfold behavior.
*/
import type { InstalledApp } from '@/models/explore'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import Toast from '@/app/components/base/toast'
import SideBar from '@/app/components/explore/sidebar'
import ExploreContext from '@/context/explore-context'
import { MediaType } from '@/hooks/use-breakpoints'
import { AppModeEnum } from '@/types/app'
let mockMediaType: string = MediaType.pc
const mockSegments = ['apps']
const mockPush = vi.fn()
const mockRefetch = vi.fn()
const mockUninstall = vi.fn()
const mockUpdatePinStatus = vi.fn()
let mockInstalledApps: InstalledApp[] = []
vi.mock('next/navigation', () => ({
useSelectedLayoutSegments: () => mockSegments,
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => mockMediaType,
MediaType: {
mobile: 'mobile',
tablet: 'tablet',
pc: 'pc',
},
}))
vi.mock('@/service/use-explore', () => ({
useGetInstalledApps: () => ({
isFetching: false,
data: { installed_apps: mockInstalledApps },
refetch: mockRefetch,
}),
useUninstallApp: () => ({
mutateAsync: mockUninstall,
}),
useUpdateAppPinStatus: () => ({
mutateAsync: mockUpdatePinStatus,
}),
}))
const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp => ({
id: overrides.id ?? 'app-1',
uninstallable: overrides.uninstallable ?? false,
is_pinned: overrides.is_pinned ?? false,
app: {
id: overrides.app?.id ?? 'app-basic-id',
mode: overrides.app?.mode ?? AppModeEnum.CHAT,
icon_type: overrides.app?.icon_type ?? 'emoji',
icon: overrides.app?.icon ?? '🤖',
icon_background: overrides.app?.icon_background ?? '#fff',
icon_url: overrides.app?.icon_url ?? '',
name: overrides.app?.name ?? 'App One',
description: overrides.app?.description ?? 'desc',
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
},
})
const createContextValue = (installedApps: InstalledApp[] = []): IExplore => ({
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission: true,
installedApps,
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: false,
setShowTryAppPanel: vi.fn(),
})
const renderSidebar = (installedApps: InstalledApp[] = []) => {
return render(
<ExploreContext.Provider value={createContextValue(installedApps)}>
<SideBar controlUpdateInstalledApps={0} />
</ExploreContext.Provider>,
)
}
describe('Sidebar Lifecycle Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMediaType = MediaType.pc
mockInstalledApps = []
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
})
describe('Pin / Unpin / Delete Flow', () => {
it('should complete pin → unpin cycle for an app', async () => {
mockUpdatePinStatus.mockResolvedValue(undefined)
// Step 1: Start with an unpinned app and pin it
const unpinnedApp = createInstalledApp({ is_pinned: false })
mockInstalledApps = [unpinnedApp]
const { unmount } = renderSidebar(mockInstalledApps)
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
await waitFor(() => {
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: true })
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
}))
})
// Step 2: Simulate refetch returning pinned state, then unpin
unmount()
vi.clearAllMocks()
mockUpdatePinStatus.mockResolvedValue(undefined)
const pinnedApp = createInstalledApp({ is_pinned: true })
mockInstalledApps = [pinnedApp]
renderSidebar(mockInstalledApps)
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.unpin'))
await waitFor(() => {
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: false })
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
}))
})
})
it('should complete the delete flow with confirmation', async () => {
const app = createInstalledApp()
mockInstalledApps = [app]
mockUninstall.mockResolvedValue(undefined)
renderSidebar(mockInstalledApps)
// Step 1: Open operation menu and click delete
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
// Step 2: Confirm dialog appears
expect(await screen.findByText('explore.sidebar.delete.title')).toBeInTheDocument()
// Step 3: Confirm deletion
fireEvent.click(screen.getByText('common.operation.confirm'))
// Step 4: Uninstall API called and success toast shown
await waitFor(() => {
expect(mockUninstall).toHaveBeenCalledWith('app-1')
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
message: 'common.api.remove',
}))
})
})
it('should cancel deletion when user clicks cancel', async () => {
const app = createInstalledApp()
mockInstalledApps = [app]
renderSidebar(mockInstalledApps)
// Open delete flow
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
// Cancel the deletion
fireEvent.click(await screen.findByText('common.operation.cancel'))
// Uninstall should not be called
expect(mockUninstall).not.toHaveBeenCalled()
})
})
describe('Multi-App Ordering', () => {
it('should display pinned apps before unpinned apps with divider', () => {
mockInstalledApps = [
createInstalledApp({ id: 'pinned-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned App' } }),
createInstalledApp({ id: 'unpinned-1', is_pinned: false, app: { ...createInstalledApp().app, name: 'Regular App' } }),
]
const { container } = renderSidebar(mockInstalledApps)
// Both apps are rendered
const pinnedApp = screen.getByText('Pinned App')
const regularApp = screen.getByText('Regular App')
expect(pinnedApp).toBeInTheDocument()
expect(regularApp).toBeInTheDocument()
// Pinned app appears before unpinned app in the DOM
const pinnedItem = pinnedApp.closest('[class*="rounded-lg"]')!
const regularItem = regularApp.closest('[class*="rounded-lg"]')!
expect(pinnedItem.compareDocumentPosition(regularItem) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
// Divider is rendered between pinned and unpinned sections
const divider = container.querySelector('[class*="bg-divider-regular"]')
expect(divider).toBeInTheDocument()
})
})
describe('Empty State', () => {
it('should show NoApps component when no apps are installed on desktop', () => {
mockMediaType = MediaType.pc
renderSidebar([])
expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument()
})
it('should hide NoApps on mobile', () => {
mockMediaType = MediaType.mobile
renderSidebar([])
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
})
})
})

View File

@@ -49,14 +49,14 @@ describe('Slash Command Dual-Mode System', () => {
beforeEach(() => {
vi.clearAllMocks()
;(slashCommandRegistry as any).findCommand = vi.fn((name: string) => {
vi.mocked(slashCommandRegistry.findCommand).mockImplementation((name: string) => {
if (name === 'docs')
return mockDirectCommand
if (name === 'theme')
return mockSubmenuCommand
return null
return undefined
})
;(slashCommandRegistry as any).getAllCommands = vi.fn(() => [
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
mockDirectCommand,
mockSubmenuCommand,
])
@@ -147,7 +147,7 @@ describe('Slash Command Dual-Mode System', () => {
unregister: vi.fn(),
}
;(slashCommandRegistry as any).findCommand = vi.fn(() => commandWithoutMode)
vi.mocked(slashCommandRegistry.findCommand).mockReturnValue(commandWithoutMode)
const handler = slashCommandRegistry.findCommand('test')
// Default behavior should be submenu when mode is not specified

View File

@@ -0,0 +1,271 @@
/**
* Integration Test: Plugin Authentication Flow
*
* Tests the integration between PluginAuth, usePluginAuth hook,
* Authorize/Authorized components, and credential management.
* Verifies the complete auth flow from checking authorization status
* to rendering the correct UI state.
*/
import { cleanup, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthCategory, CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const map: Record<string, string> = {
'plugin.auth.setUpTip': 'Set up your credentials',
'plugin.auth.authorized': 'Authorized',
'plugin.auth.apiKey': 'API Key',
'plugin.auth.oauth': 'OAuth',
}
return map[key] ?? key
},
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: true,
}),
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}))
const mockUsePluginAuth = vi.fn()
vi.mock('@/app/components/plugins/plugin-auth/hooks/use-plugin-auth', () => ({
usePluginAuth: (...args: unknown[]) => mockUsePluginAuth(...args),
}))
vi.mock('@/app/components/plugins/plugin-auth/authorize', () => ({
default: ({ pluginPayload, canOAuth, canApiKey }: {
pluginPayload: { provider: string }
canOAuth: boolean
canApiKey: boolean
}) => (
<div data-testid="authorize-component">
<span data-testid="auth-provider">{pluginPayload.provider}</span>
{canOAuth && <span data-testid="auth-oauth">OAuth available</span>}
{canApiKey && <span data-testid="auth-apikey">API Key available</span>}
</div>
),
}))
vi.mock('@/app/components/plugins/plugin-auth/authorized', () => ({
default: ({ pluginPayload, credentials }: {
pluginPayload: { provider: string }
credentials: Array<{ id: string, name: string }>
}) => (
<div data-testid="authorized-component">
<span data-testid="auth-provider">{pluginPayload.provider}</span>
<span data-testid="auth-credential-count">
{credentials.length}
{' '}
credentials
</span>
</div>
),
}))
const { default: PluginAuth } = await import('@/app/components/plugins/plugin-auth/plugin-auth')
describe('Plugin Authentication Flow Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
cleanup()
})
const basePayload = {
category: AuthCategory.tool,
provider: 'test-provider',
}
describe('Unauthorized State', () => {
it('renders Authorize component when not authorized', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: false,
canOAuth: false,
canApiKey: true,
credentials: [],
disabled: false,
invalidPluginCredentialInfo: vi.fn(),
notAllowCustomCredential: false,
})
render(<PluginAuth pluginPayload={basePayload} />)
expect(screen.getByTestId('authorize-component')).toBeInTheDocument()
expect(screen.queryByTestId('authorized-component')).not.toBeInTheDocument()
expect(screen.getByTestId('auth-apikey')).toBeInTheDocument()
})
it('shows OAuth option when plugin supports it', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: false,
canOAuth: true,
canApiKey: true,
credentials: [],
disabled: false,
invalidPluginCredentialInfo: vi.fn(),
notAllowCustomCredential: false,
})
render(<PluginAuth pluginPayload={basePayload} />)
expect(screen.getByTestId('auth-oauth')).toBeInTheDocument()
expect(screen.getByTestId('auth-apikey')).toBeInTheDocument()
})
it('applies className to wrapper when not authorized', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: false,
canOAuth: false,
canApiKey: true,
credentials: [],
disabled: false,
invalidPluginCredentialInfo: vi.fn(),
notAllowCustomCredential: false,
})
const { container } = render(
<PluginAuth pluginPayload={basePayload} className="custom-class" />,
)
expect(container.firstChild).toHaveClass('custom-class')
})
})
describe('Authorized State', () => {
it('renders Authorized component when authorized and no children', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: true,
canOAuth: false,
canApiKey: true,
credentials: [
{ id: 'cred-1', name: 'My API Key', is_default: true },
],
disabled: false,
invalidPluginCredentialInfo: vi.fn(),
notAllowCustomCredential: false,
})
render(<PluginAuth pluginPayload={basePayload} />)
expect(screen.queryByTestId('authorize-component')).not.toBeInTheDocument()
expect(screen.getByTestId('authorized-component')).toBeInTheDocument()
expect(screen.getByTestId('auth-credential-count')).toHaveTextContent('1 credentials')
})
it('renders children instead of Authorized when authorized and children provided', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: true,
canOAuth: false,
canApiKey: true,
credentials: [{ id: 'cred-1', name: 'Key', is_default: true }],
disabled: false,
invalidPluginCredentialInfo: vi.fn(),
notAllowCustomCredential: false,
})
render(
<PluginAuth pluginPayload={basePayload}>
<div data-testid="custom-children">Custom authorized view</div>
</PluginAuth>,
)
expect(screen.queryByTestId('authorize-component')).not.toBeInTheDocument()
expect(screen.queryByTestId('authorized-component')).not.toBeInTheDocument()
expect(screen.getByTestId('custom-children')).toBeInTheDocument()
})
it('does not apply className when authorized', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: true,
canOAuth: false,
canApiKey: true,
credentials: [{ id: 'cred-1', name: 'Key', is_default: true }],
disabled: false,
invalidPluginCredentialInfo: vi.fn(),
notAllowCustomCredential: false,
})
const { container } = render(
<PluginAuth pluginPayload={basePayload} className="custom-class" />,
)
expect(container.firstChild).not.toHaveClass('custom-class')
})
})
describe('Auth Category Integration', () => {
it('passes correct provider to usePluginAuth for tool category', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: false,
canOAuth: false,
canApiKey: true,
credentials: [],
disabled: false,
invalidPluginCredentialInfo: vi.fn(),
notAllowCustomCredential: false,
})
const toolPayload = {
category: AuthCategory.tool,
provider: 'google-search-provider',
}
render(<PluginAuth pluginPayload={toolPayload} />)
expect(mockUsePluginAuth).toHaveBeenCalledWith(toolPayload, true)
expect(screen.getByTestId('auth-provider')).toHaveTextContent('google-search-provider')
})
it('passes correct provider to usePluginAuth for datasource category', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: false,
canOAuth: true,
canApiKey: false,
credentials: [],
disabled: false,
invalidPluginCredentialInfo: vi.fn(),
notAllowCustomCredential: false,
})
const dsPayload = {
category: AuthCategory.datasource,
provider: 'notion-datasource',
}
render(<PluginAuth pluginPayload={dsPayload} />)
expect(mockUsePluginAuth).toHaveBeenCalledWith(dsPayload, true)
expect(screen.getByTestId('auth-oauth')).toBeInTheDocument()
expect(screen.queryByTestId('auth-apikey')).not.toBeInTheDocument()
})
})
describe('Multiple Credentials', () => {
it('shows credential count when multiple credentials exist', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: true,
canOAuth: true,
canApiKey: true,
credentials: [
{ id: 'cred-1', name: 'API Key 1', is_default: true },
{ id: 'cred-2', name: 'API Key 2', is_default: false },
{ id: 'cred-3', name: 'OAuth Token', is_default: false, credential_type: CredentialTypeEnum.OAUTH2 },
],
disabled: false,
invalidPluginCredentialInfo: vi.fn(),
notAllowCustomCredential: false,
})
render(<PluginAuth pluginPayload={basePayload} />)
expect(screen.getByTestId('auth-credential-count')).toHaveTextContent('3 credentials')
})
})
})

View File

@@ -0,0 +1,224 @@
/**
* Integration Test: Plugin Card Rendering Pipeline
*
* Tests the integration between Card, Icon, Title, Description,
* OrgInfo, CornerMark, and CardMoreInfo components. Verifies that
* plugin data flows correctly through the card rendering pipeline.
*/
import { cleanup, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en_US',
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),
}))
vi.mock('@/i18n-config', () => ({
renderI18nObject: (obj: Record<string, string>, locale: string) => obj[locale] || obj.en_US || '',
}))
vi.mock('@/types/app', () => ({
Theme: { dark: 'dark', light: 'light' },
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: unknown[]) => args.filter(a => typeof a === 'string' && a).join(' '),
}))
vi.mock('@/app/components/plugins/hooks', () => ({
useCategories: () => ({
categoriesMap: {
tool: { label: 'Tool' },
model: { label: 'Model' },
extension: { label: 'Extension' },
},
}),
}))
vi.mock('@/app/components/plugins/base/badges/partner', () => ({
default: () => <span data-testid="partner-badge">Partner</span>,
}))
vi.mock('@/app/components/plugins/base/badges/verified', () => ({
default: () => <span data-testid="verified-badge">Verified</span>,
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: ({ src, installed, installFailed }: { src: string | object, installed?: boolean, installFailed?: boolean }) => (
<div data-testid="card-icon" data-installed={installed} data-install-failed={installFailed}>
{typeof src === 'string' ? src : 'emoji-icon'}
</div>
),
}))
vi.mock('@/app/components/plugins/card/base/corner-mark', () => ({
default: ({ text }: { text: string }) => (
<div data-testid="corner-mark">{text}</div>
),
}))
vi.mock('@/app/components/plugins/card/base/description', () => ({
default: ({ text, descriptionLineRows }: { text: string, descriptionLineRows?: number }) => (
<div data-testid="description" data-rows={descriptionLineRows}>{text}</div>
),
}))
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
default: ({ orgName, packageName }: { orgName: string, packageName: string }) => (
<div data-testid="org-info">
{orgName}
/
{packageName}
</div>
),
}))
vi.mock('@/app/components/plugins/card/base/placeholder', () => ({
default: ({ text }: { text: string }) => (
<div data-testid="placeholder">{text}</div>
),
}))
vi.mock('@/app/components/plugins/card/base/title', () => ({
default: ({ title }: { title: string }) => (
<div data-testid="title">{title}</div>
),
}))
const { default: Card } = await import('@/app/components/plugins/card/index')
type CardPayload = Parameters<typeof Card>[0]['payload']
describe('Plugin Card Rendering Integration', () => {
beforeEach(() => {
cleanup()
})
const makePayload = (overrides = {}) => ({
category: 'tool',
type: 'plugin',
name: 'google-search',
org: 'langgenius',
label: { en_US: 'Google Search', zh_Hans: 'Google搜索' },
brief: { en_US: 'Search the web using Google', zh_Hans: '使用Google搜索网页' },
icon: 'https://example.com/icon.png',
verified: true,
badges: [] as string[],
...overrides,
}) as CardPayload
it('renders a complete plugin card with all subcomponents', () => {
const payload = makePayload()
render(<Card payload={payload} />)
expect(screen.getByTestId('card-icon')).toBeInTheDocument()
expect(screen.getByTestId('title')).toHaveTextContent('Google Search')
expect(screen.getByTestId('org-info')).toHaveTextContent('langgenius/google-search')
expect(screen.getByTestId('description')).toHaveTextContent('Search the web using Google')
})
it('shows corner mark with category label when not hidden', () => {
const payload = makePayload()
render(<Card payload={payload} />)
expect(screen.getByTestId('corner-mark')).toBeInTheDocument()
})
it('hides corner mark when hideCornerMark is true', () => {
const payload = makePayload()
render(<Card payload={payload} hideCornerMark />)
expect(screen.queryByTestId('corner-mark')).not.toBeInTheDocument()
})
it('shows installed status on icon', () => {
const payload = makePayload()
render(<Card payload={payload} installed />)
const icon = screen.getByTestId('card-icon')
expect(icon).toHaveAttribute('data-installed', 'true')
})
it('shows install failed status on icon', () => {
const payload = makePayload()
render(<Card payload={payload} installFailed />)
const icon = screen.getByTestId('card-icon')
expect(icon).toHaveAttribute('data-install-failed', 'true')
})
it('renders verified badge when plugin is verified', () => {
const payload = makePayload({ verified: true })
render(<Card payload={payload} />)
expect(screen.getByTestId('verified-badge')).toBeInTheDocument()
})
it('renders partner badge when plugin has partner badge', () => {
const payload = makePayload({ badges: ['partner'] })
render(<Card payload={payload} />)
expect(screen.getByTestId('partner-badge')).toBeInTheDocument()
})
it('renders footer content when provided', () => {
const payload = makePayload()
render(
<Card
payload={payload}
footer={<div data-testid="custom-footer">Custom footer</div>}
/>,
)
expect(screen.getByTestId('custom-footer')).toBeInTheDocument()
})
it('renders titleLeft content when provided', () => {
const payload = makePayload()
render(
<Card
payload={payload}
titleLeft={<span data-testid="title-left-content">New</span>}
/>,
)
expect(screen.getByTestId('title-left-content')).toBeInTheDocument()
})
it('uses dark icon when theme is dark and icon_dark is provided', () => {
vi.doMock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'dark' }),
}))
const payload = makePayload({
icon: 'https://example.com/icon-light.png',
icon_dark: 'https://example.com/icon-dark.png',
})
render(<Card payload={payload} />)
expect(screen.getByTestId('card-icon')).toBeInTheDocument()
})
it('shows loading placeholder when isLoading is true', () => {
const payload = makePayload()
render(<Card payload={payload} isLoading loadingFileName="uploading.difypkg" />)
expect(screen.getByTestId('placeholder')).toBeInTheDocument()
})
it('renders description with custom line rows', () => {
const payload = makePayload()
render(<Card payload={payload} descriptionLineRows={3} />)
const description = screen.getByTestId('description')
expect(description).toHaveAttribute('data-rows', '3')
})
})

View File

@@ -0,0 +1,159 @@
/**
* Integration Test: Plugin Data Utilities
*
* Tests the integration between plugin utility functions, including
* tag/category validation, form schema transformation, and
* credential data processing. Verifies that these utilities work
* correctly together in processing plugin metadata.
*/
import { describe, expect, it } from 'vitest'
import { transformFormSchemasSecretInput } from '@/app/components/plugins/plugin-auth/utils'
import { getValidCategoryKeys, getValidTagKeys } from '@/app/components/plugins/utils'
type TagInput = Parameters<typeof getValidTagKeys>[0]
describe('Plugin Data Utilities Integration', () => {
describe('Tag and Category Validation Pipeline', () => {
it('validates tags and categories in a metadata processing flow', () => {
const pluginMetadata = {
tags: ['search', 'productivity', 'invalid-tag', 'media-generate'],
category: 'tool',
}
const validTags = getValidTagKeys(pluginMetadata.tags as TagInput)
expect(validTags.length).toBeGreaterThan(0)
expect(validTags.length).toBeLessThanOrEqual(pluginMetadata.tags.length)
const validCategory = getValidCategoryKeys(pluginMetadata.category)
expect(validCategory).toBeDefined()
})
it('handles completely invalid metadata gracefully', () => {
const invalidMetadata = {
tags: ['nonexistent-1', 'nonexistent-2'],
category: 'nonexistent-category',
}
const validTags = getValidTagKeys(invalidMetadata.tags as TagInput)
expect(validTags).toHaveLength(0)
const validCategory = getValidCategoryKeys(invalidMetadata.category)
expect(validCategory).toBeUndefined()
})
it('handles undefined and empty inputs', () => {
expect(getValidTagKeys([] as TagInput)).toHaveLength(0)
expect(getValidCategoryKeys(undefined)).toBeUndefined()
expect(getValidCategoryKeys('')).toBeUndefined()
})
})
describe('Credential Secret Masking Pipeline', () => {
it('masks secrets when displaying credential form data', () => {
const credentialValues = {
api_key: 'sk-abc123456789',
api_endpoint: 'https://api.example.com',
secret_token: 'secret-token-value',
description: 'My credential set',
}
const secretFields = ['api_key', 'secret_token']
const displayValues = transformFormSchemasSecretInput(secretFields, credentialValues)
expect(displayValues.api_key).toBe('[__HIDDEN__]')
expect(displayValues.secret_token).toBe('[__HIDDEN__]')
expect(displayValues.api_endpoint).toBe('https://api.example.com')
expect(displayValues.description).toBe('My credential set')
})
it('preserves original values when no secret fields', () => {
const values = {
name: 'test',
endpoint: 'https://api.example.com',
}
const result = transformFormSchemasSecretInput([], values)
expect(result).toEqual(values)
})
it('handles falsy secret values without masking', () => {
const values = {
api_key: '',
secret: null as unknown as string,
other: 'visible',
}
const result = transformFormSchemasSecretInput(['api_key', 'secret'], values)
expect(result.api_key).toBe('')
expect(result.secret).toBeNull()
expect(result.other).toBe('visible')
})
it('does not mutate the original values object', () => {
const original = {
api_key: 'my-secret-key',
name: 'test',
}
const originalCopy = { ...original }
transformFormSchemasSecretInput(['api_key'], original)
expect(original).toEqual(originalCopy)
})
})
describe('Combined Plugin Metadata Validation', () => {
it('processes a complete plugin entry with tags and credentials', () => {
const pluginEntry = {
name: 'test-plugin',
category: 'tool',
tags: ['search', 'invalid-tag'],
credentials: {
api_key: 'sk-test-key-123',
base_url: 'https://api.test.com',
},
secretFields: ['api_key'],
}
const validCategory = getValidCategoryKeys(pluginEntry.category)
expect(validCategory).toBe('tool')
const validTags = getValidTagKeys(pluginEntry.tags as TagInput)
expect(validTags).toContain('search')
const displayCredentials = transformFormSchemasSecretInput(
pluginEntry.secretFields,
pluginEntry.credentials,
)
expect(displayCredentials.api_key).toBe('[__HIDDEN__]')
expect(displayCredentials.base_url).toBe('https://api.test.com')
expect(pluginEntry.credentials.api_key).toBe('sk-test-key-123')
})
it('handles multiple plugins in batch processing', () => {
const plugins = [
{ tags: ['search', 'productivity'], category: 'tool' },
{ tags: ['image', 'design'], category: 'model' },
{ tags: ['invalid'], category: 'extension' },
]
const results = plugins.map(p => ({
validTags: getValidTagKeys(p.tags as TagInput),
validCategory: getValidCategoryKeys(p.category),
}))
expect(results[0].validTags.length).toBeGreaterThan(0)
expect(results[0].validCategory).toBe('tool')
expect(results[1].validTags).toContain('image')
expect(results[1].validTags).toContain('design')
expect(results[1].validCategory).toBe('model')
expect(results[2].validTags).toHaveLength(0)
expect(results[2].validCategory).toBe('extension')
})
})
})

View File

@@ -0,0 +1,269 @@
/**
* Integration Test: Plugin Installation Flow
*
* Tests the integration between GitHub release fetching, version comparison,
* upload handling, and task status polling. Verifies the complete plugin
* installation pipeline from source discovery to completion.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/config', () => ({
GITHUB_ACCESS_TOKEN: '',
}))
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: { notify: (...args: unknown[]) => mockToastNotify(...args) },
}))
const mockUploadGitHub = vi.fn()
vi.mock('@/service/plugins', () => ({
uploadGitHub: (...args: unknown[]) => mockUploadGitHub(...args),
checkTaskStatus: vi.fn(),
}))
vi.mock('@/utils/semver', () => ({
compareVersion: (a: string, b: string) => {
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number)
const [aMajor, aMinor = 0, aPatch = 0] = parse(a)
const [bMajor, bMinor = 0, bPatch = 0] = parse(b)
if (aMajor !== bMajor)
return aMajor > bMajor ? 1 : -1
if (aMinor !== bMinor)
return aMinor > bMinor ? 1 : -1
if (aPatch !== bPatch)
return aPatch > bPatch ? 1 : -1
return 0
},
getLatestVersion: (versions: string[]) => {
return versions.sort((a, b) => {
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number)
const [aMaj, aMin = 0, aPat = 0] = parse(a)
const [bMaj, bMin = 0, bPat = 0] = parse(b)
if (aMaj !== bMaj)
return bMaj - aMaj
if (aMin !== bMin)
return bMin - aMin
return bPat - aPat
})[0]
},
}))
const { useGitHubReleases, useGitHubUpload } = await import(
'@/app/components/plugins/install-plugin/hooks',
)
describe('Plugin Installation Flow Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
globalThis.fetch = vi.fn()
})
describe('GitHub Release Discovery → Version Check → Upload Pipeline', () => {
it('fetches releases, checks for updates, and uploads the new version', async () => {
const mockReleases = [
{
tag_name: 'v2.0.0',
assets: [{ browser_download_url: 'https://github.com/test/v2.difypkg', name: 'plugin-v2.difypkg' }],
},
{
tag_name: 'v1.5.0',
assets: [{ browser_download_url: 'https://github.com/test/v1.5.difypkg', name: 'plugin-v1.5.difypkg' }],
},
{
tag_name: 'v1.0.0',
assets: [{ browser_download_url: 'https://github.com/test/v1.difypkg', name: 'plugin-v1.difypkg' }],
},
]
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockReleases),
})
mockUploadGitHub.mockResolvedValue({
manifest: { name: 'test-plugin', version: '2.0.0' },
unique_identifier: 'test-plugin:2.0.0',
})
const { fetchReleases, checkForUpdates } = useGitHubReleases()
const releases = await fetchReleases('test-org', 'test-repo')
expect(releases).toHaveLength(3)
expect(releases[0].tag_name).toBe('v2.0.0')
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
expect(needUpdate).toBe(true)
expect(toastProps.message).toContain('v2.0.0')
const { handleUpload } = useGitHubUpload()
const onSuccess = vi.fn()
const result = await handleUpload(
'https://github.com/test-org/test-repo',
'v2.0.0',
'plugin-v2.difypkg',
onSuccess,
)
expect(mockUploadGitHub).toHaveBeenCalledWith(
'https://github.com/test-org/test-repo',
'v2.0.0',
'plugin-v2.difypkg',
)
expect(onSuccess).toHaveBeenCalledWith({
manifest: { name: 'test-plugin', version: '2.0.0' },
unique_identifier: 'test-plugin:2.0.0',
})
expect(result).toEqual({
manifest: { name: 'test-plugin', version: '2.0.0' },
unique_identifier: 'test-plugin:2.0.0',
})
})
it('handles no new version available', async () => {
const mockReleases = [
{
tag_name: 'v1.0.0',
assets: [{ browser_download_url: 'https://github.com/test/v1.difypkg', name: 'plugin-v1.difypkg' }],
},
]
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockReleases),
})
const { fetchReleases, checkForUpdates } = useGitHubReleases()
const releases = await fetchReleases('test-org', 'test-repo')
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
expect(needUpdate).toBe(false)
expect(toastProps.type).toBe('info')
expect(toastProps.message).toBe('No new version available')
})
it('handles empty releases', async () => {
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
})
const { fetchReleases, checkForUpdates } = useGitHubReleases()
const releases = await fetchReleases('test-org', 'test-repo')
expect(releases).toHaveLength(0)
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
expect(needUpdate).toBe(false)
expect(toastProps.type).toBe('error')
expect(toastProps.message).toBe('Input releases is empty')
})
it('handles fetch failure gracefully', async () => {
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 404,
})
const { fetchReleases } = useGitHubReleases()
const releases = await fetchReleases('nonexistent-org', 'nonexistent-repo')
expect(releases).toEqual([])
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
it('handles upload failure gracefully', async () => {
mockUploadGitHub.mockRejectedValue(new Error('Upload failed'))
const { handleUpload } = useGitHubUpload()
const onSuccess = vi.fn()
await expect(
handleUpload('https://github.com/test/repo', 'v1.0.0', 'plugin.difypkg', onSuccess),
).rejects.toThrow('Upload failed')
expect(onSuccess).not.toHaveBeenCalled()
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error', message: 'Error uploading package' }),
)
})
})
describe('Task Status Polling Integration', () => {
it('polls until plugin installation succeeds', async () => {
const mockCheckTaskStatus = vi.fn()
.mockResolvedValueOnce({
task: {
plugins: [{ plugin_unique_identifier: 'test:1.0.0', status: 'running' }],
},
})
.mockResolvedValueOnce({
task: {
plugins: [{ plugin_unique_identifier: 'test:1.0.0', status: 'success' }],
},
})
const { checkTaskStatus: fetchCheckTaskStatus } = await import('@/service/plugins')
;(fetchCheckTaskStatus as ReturnType<typeof vi.fn>).mockImplementation(mockCheckTaskStatus)
await vi.doMock('@/utils', () => ({
sleep: () => Promise.resolve(),
}))
const { default: checkTaskStatus } = await import(
'@/app/components/plugins/install-plugin/base/check-task-status',
)
const checker = checkTaskStatus()
const result = await checker.check({
taskId: 'task-123',
pluginUniqueIdentifier: 'test:1.0.0',
})
expect(result.status).toBe('success')
})
it('returns failure when plugin not found in task', async () => {
const mockCheckTaskStatus = vi.fn().mockResolvedValue({
task: {
plugins: [{ plugin_unique_identifier: 'other:1.0.0', status: 'success' }],
},
})
const { checkTaskStatus: fetchCheckTaskStatus } = await import('@/service/plugins')
;(fetchCheckTaskStatus as ReturnType<typeof vi.fn>).mockImplementation(mockCheckTaskStatus)
const { default: checkTaskStatus } = await import(
'@/app/components/plugins/install-plugin/base/check-task-status',
)
const checker = checkTaskStatus()
const result = await checker.check({
taskId: 'task-123',
pluginUniqueIdentifier: 'test:1.0.0',
})
expect(result.status).toBe('failed')
expect(result.error).toBe('Plugin package not found')
})
it('stops polling when stop() is called', async () => {
const { default: checkTaskStatus } = await import(
'@/app/components/plugins/install-plugin/base/check-task-status',
)
const checker = checkTaskStatus()
checker.stop()
const result = await checker.check({
taskId: 'task-123',
pluginUniqueIdentifier: 'test:1.0.0',
})
expect(result.status).toBe('success')
})
})
})

View File

@@ -0,0 +1,97 @@
import { describe, expect, it, vi } from 'vitest'
import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit'
import { InstallationScope } from '@/types/feature'
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({
plugin_installation_permission: {
restrict_to_marketplace_only: false,
plugin_installation_scope: InstallationScope.ALL,
},
}),
}))
describe('Plugin Marketplace to Install Flow', () => {
describe('install permission validation pipeline', () => {
const systemFeaturesAll = {
plugin_installation_permission: {
restrict_to_marketplace_only: false,
plugin_installation_scope: InstallationScope.ALL,
},
}
const systemFeaturesMarketplaceOnly = {
plugin_installation_permission: {
restrict_to_marketplace_only: true,
plugin_installation_scope: InstallationScope.ALL,
},
}
const systemFeaturesOfficialOnly = {
plugin_installation_permission: {
restrict_to_marketplace_only: false,
plugin_installation_scope: InstallationScope.OFFICIAL_ONLY,
},
}
it('should allow marketplace plugin when all sources allowed', () => {
const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' } }
const result = pluginInstallLimit(plugin as never, systemFeaturesAll as never)
expect(result.canInstall).toBe(true)
})
it('should allow github plugin when all sources allowed', () => {
const plugin = { from: 'github' as const, verification: { authorized_category: 'langgenius' } }
const result = pluginInstallLimit(plugin as never, systemFeaturesAll as never)
expect(result.canInstall).toBe(true)
})
it('should block github plugin when marketplace only', () => {
const plugin = { from: 'github' as const, verification: { authorized_category: 'langgenius' } }
const result = pluginInstallLimit(plugin as never, systemFeaturesMarketplaceOnly as never)
expect(result.canInstall).toBe(false)
})
it('should allow marketplace plugin when marketplace only', () => {
const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'partner' } }
const result = pluginInstallLimit(plugin as never, systemFeaturesMarketplaceOnly as never)
expect(result.canInstall).toBe(true)
})
it('should allow official plugin when official only', () => {
const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' } }
const result = pluginInstallLimit(plugin as never, systemFeaturesOfficialOnly as never)
expect(result.canInstall).toBe(true)
})
it('should block community plugin when official only', () => {
const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'community' } }
const result = pluginInstallLimit(plugin as never, systemFeaturesOfficialOnly as never)
expect(result.canInstall).toBe(false)
})
})
describe('plugin source classification', () => {
it('should correctly classify plugin install sources', () => {
const sources = ['marketplace', 'github', 'package'] as const
const features = {
plugin_installation_permission: {
restrict_to_marketplace_only: true,
plugin_installation_scope: InstallationScope.ALL,
},
}
const results = sources.map(source => ({
source,
canInstall: pluginInstallLimit(
{ from: source, verification: { authorized_category: 'langgenius' } } as never,
features as never,
).canInstall,
}))
expect(results.find(r => r.source === 'marketplace')?.canInstall).toBe(true)
expect(results.find(r => r.source === 'github')?.canInstall).toBe(false)
expect(results.find(r => r.source === 'package')?.canInstall).toBe(false)
})
})
})

View File

@@ -0,0 +1,120 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { useStore } from '@/app/components/plugins/plugin-page/filter-management/store'
describe('Plugin Page Filter Management Integration', () => {
beforeEach(() => {
const { result } = renderHook(() => useStore())
act(() => {
result.current.setTagList([])
result.current.setCategoryList([])
result.current.setShowTagManagementModal(false)
result.current.setShowCategoryManagementModal(false)
})
})
describe('tag and category filter lifecycle', () => {
it('should manage full tag lifecycle: add -> update -> clear', () => {
const { result } = renderHook(() => useStore())
const initialTags = [
{ name: 'search', label: { en_US: 'Search' } },
{ name: 'productivity', label: { en_US: 'Productivity' } },
]
act(() => {
result.current.setTagList(initialTags as never[])
})
expect(result.current.tagList).toHaveLength(2)
const updatedTags = [
...initialTags,
{ name: 'image', label: { en_US: 'Image' } },
]
act(() => {
result.current.setTagList(updatedTags as never[])
})
expect(result.current.tagList).toHaveLength(3)
act(() => {
result.current.setTagList([])
})
expect(result.current.tagList).toHaveLength(0)
})
it('should manage full category lifecycle: add -> update -> clear', () => {
const { result } = renderHook(() => useStore())
const categories = [
{ name: 'tool', label: { en_US: 'Tool' } },
{ name: 'model', label: { en_US: 'Model' } },
]
act(() => {
result.current.setCategoryList(categories as never[])
})
expect(result.current.categoryList).toHaveLength(2)
act(() => {
result.current.setCategoryList([])
})
expect(result.current.categoryList).toHaveLength(0)
})
})
describe('modal state management', () => {
it('should manage tag management modal independently', () => {
const { result } = renderHook(() => useStore())
act(() => {
result.current.setShowTagManagementModal(true)
})
expect(result.current.showTagManagementModal).toBe(true)
expect(result.current.showCategoryManagementModal).toBe(false)
act(() => {
result.current.setShowTagManagementModal(false)
})
expect(result.current.showTagManagementModal).toBe(false)
})
it('should manage category management modal independently', () => {
const { result } = renderHook(() => useStore())
act(() => {
result.current.setShowCategoryManagementModal(true)
})
expect(result.current.showCategoryManagementModal).toBe(true)
expect(result.current.showTagManagementModal).toBe(false)
})
it('should support both modals open simultaneously', () => {
const { result } = renderHook(() => useStore())
act(() => {
result.current.setShowTagManagementModal(true)
result.current.setShowCategoryManagementModal(true)
})
expect(result.current.showTagManagementModal).toBe(true)
expect(result.current.showCategoryManagementModal).toBe(true)
})
})
describe('state persistence across renders', () => {
it('should maintain filter state when re-rendered', () => {
const { result, rerender } = renderHook(() => useStore())
act(() => {
result.current.setTagList([{ name: 'search' }] as never[])
result.current.setCategoryList([{ name: 'tool' }] as never[])
})
rerender()
expect(result.current.tagList).toHaveLength(1)
expect(result.current.categoryList).toHaveLength(1)
})
})
})

View File

@@ -0,0 +1,210 @@
/**
* Integration test: Chunk preview formatting pipeline
*
* Tests the formatPreviewChunks utility across all chunking modes
* (text, parentChild, QA) with real data structures.
*/
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/config', () => ({
RAG_PIPELINE_PREVIEW_CHUNK_NUM: 3,
}))
vi.mock('@/models/datasets', () => ({
ChunkingMode: {
text: 'text',
parentChild: 'parent-child',
qa: 'qa',
},
}))
const { formatPreviewChunks } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/result/result-preview/utils',
)
describe('Chunk Preview Formatting', () => {
describe('general text chunks', () => {
it('should format text chunks correctly', () => {
const outputs = {
chunk_structure: 'text',
preview: [
{ content: 'Chunk 1 content', summary: 'Summary 1' },
{ content: 'Chunk 2 content' },
],
}
const result = formatPreviewChunks(outputs)
expect(Array.isArray(result)).toBe(true)
const chunks = result as Array<{ content: string, summary?: string }>
expect(chunks).toHaveLength(2)
expect(chunks[0].content).toBe('Chunk 1 content')
expect(chunks[0].summary).toBe('Summary 1')
expect(chunks[1].content).toBe('Chunk 2 content')
})
it('should limit chunks to RAG_PIPELINE_PREVIEW_CHUNK_NUM', () => {
const outputs = {
chunk_structure: 'text',
preview: Array.from({ length: 10 }, (_, i) => ({
content: `Chunk ${i + 1}`,
})),
}
const result = formatPreviewChunks(outputs)
const chunks = result as Array<{ content: string }>
expect(chunks).toHaveLength(3) // Mocked limit
})
})
describe('parent-child chunks — paragraph mode', () => {
it('should format paragraph parent-child chunks', () => {
const outputs = {
chunk_structure: 'parent-child',
parent_mode: 'paragraph',
preview: [
{
content: 'Parent paragraph',
child_chunks: ['Child 1', 'Child 2'],
summary: 'Parent summary',
},
],
}
const result = formatPreviewChunks(outputs) as {
parent_child_chunks: Array<{
parent_content: string
parent_summary?: string
child_contents: string[]
parent_mode: string
}>
parent_mode: string
}
expect(result.parent_mode).toBe('paragraph')
expect(result.parent_child_chunks).toHaveLength(1)
expect(result.parent_child_chunks[0].parent_content).toBe('Parent paragraph')
expect(result.parent_child_chunks[0].parent_summary).toBe('Parent summary')
expect(result.parent_child_chunks[0].child_contents).toEqual(['Child 1', 'Child 2'])
})
it('should limit parent chunks in paragraph mode', () => {
const outputs = {
chunk_structure: 'parent-child',
parent_mode: 'paragraph',
preview: Array.from({ length: 10 }, (_, i) => ({
content: `Parent ${i + 1}`,
child_chunks: [`Child of ${i + 1}`],
})),
}
const result = formatPreviewChunks(outputs) as {
parent_child_chunks: unknown[]
}
expect(result.parent_child_chunks).toHaveLength(3) // Mocked limit
})
})
describe('parent-child chunks — full-doc mode', () => {
it('should format full-doc parent-child chunks', () => {
const outputs = {
chunk_structure: 'parent-child',
parent_mode: 'full-doc',
preview: [
{
content: 'Full document content',
child_chunks: ['Section 1', 'Section 2', 'Section 3'],
},
],
}
const result = formatPreviewChunks(outputs) as {
parent_child_chunks: Array<{
parent_content: string
child_contents: string[]
parent_mode: string
}>
}
expect(result.parent_child_chunks).toHaveLength(1)
expect(result.parent_child_chunks[0].parent_content).toBe('Full document content')
expect(result.parent_child_chunks[0].parent_mode).toBe('full-doc')
})
it('should limit child chunks in full-doc mode', () => {
const outputs = {
chunk_structure: 'parent-child',
parent_mode: 'full-doc',
preview: [
{
content: 'Document',
child_chunks: Array.from({ length: 20 }, (_, i) => `Section ${i + 1}`),
},
],
}
const result = formatPreviewChunks(outputs) as {
parent_child_chunks: Array<{ child_contents: string[] }>
}
expect(result.parent_child_chunks[0].child_contents).toHaveLength(3) // Mocked limit
})
})
describe('QA chunks', () => {
it('should format QA chunks correctly', () => {
const outputs = {
chunk_structure: 'qa',
qa_preview: [
{ question: 'What is AI?', answer: 'Artificial Intelligence is...' },
{ question: 'What is ML?', answer: 'Machine Learning is...' },
],
}
const result = formatPreviewChunks(outputs) as {
qa_chunks: Array<{ question: string, answer: string }>
}
expect(result.qa_chunks).toHaveLength(2)
expect(result.qa_chunks[0].question).toBe('What is AI?')
expect(result.qa_chunks[0].answer).toBe('Artificial Intelligence is...')
})
it('should limit QA chunks', () => {
const outputs = {
chunk_structure: 'qa',
qa_preview: Array.from({ length: 10 }, (_, i) => ({
question: `Q${i + 1}`,
answer: `A${i + 1}`,
})),
}
const result = formatPreviewChunks(outputs) as {
qa_chunks: unknown[]
}
expect(result.qa_chunks).toHaveLength(3) // Mocked limit
})
})
describe('edge cases', () => {
it('should return undefined for null outputs', () => {
expect(formatPreviewChunks(null)).toBeUndefined()
})
it('should return undefined for undefined outputs', () => {
expect(formatPreviewChunks(undefined)).toBeUndefined()
})
it('should return undefined for unknown chunk_structure', () => {
const outputs = {
chunk_structure: 'unknown-type',
preview: [],
}
expect(formatPreviewChunks(outputs)).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,179 @@
/**
* Integration test: DSL export/import flow
*
* Validates DSL export logic (sync draft → check secrets → download)
* and DSL import modal state management.
*/
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
const mockDoSyncWorkflowDraft = vi.fn().mockResolvedValue(undefined)
const mockExportPipelineConfig = vi.fn().mockResolvedValue({ data: 'yaml-content' })
const mockNotify = vi.fn()
const mockEventEmitter = { emit: vi.fn() }
const mockDownloadBlob = vi.fn()
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/app/components/workflow/constants', () => ({
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
pipelineId: 'pipeline-abc',
knowledgeName: 'My Pipeline',
}),
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: mockEventEmitter,
}),
}))
vi.mock('@/service/use-pipeline', () => ({
useExportPipelineDSL: () => ({
mutateAsync: mockExportPipelineConfig,
}),
}))
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: vi.fn(),
}))
vi.mock('@/utils/download', () => ({
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
}))
vi.mock('@/app/components/rag-pipeline/hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
}),
}))
describe('DSL Export/Import Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Export Flow', () => {
it('should sync draft then export then download', async () => {
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
pipelineId: 'pipeline-abc',
include: false,
})
expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({
fileName: 'My Pipeline.pipeline',
}))
})
it('should export with include flag when specified', async () => {
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL(true)
})
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
pipelineId: 'pipeline-abc',
include: true,
})
})
it('should notify on export error', async () => {
mockDoSyncWorkflowDraft.mockRejectedValueOnce(new Error('sync failed'))
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
describe('Export Check Flow', () => {
it('should export directly when no secret environment variables', async () => {
const { fetchWorkflowDraft } = await import('@/service/workflow')
vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({
environment_variables: [
{ value_type: 'string', key: 'API_URL', value: 'https://api.example.com' },
],
} as unknown as Awaited<ReturnType<typeof fetchWorkflowDraft>>)
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
// Should proceed to export directly (no secret vars)
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
})
it('should emit DSL_EXPORT_CHECK event when secret variables exist', async () => {
const { fetchWorkflowDraft } = await import('@/service/workflow')
vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({
environment_variables: [
{ value_type: 'secret', key: 'API_KEY', value: '***' },
],
} as unknown as Awaited<ReturnType<typeof fetchWorkflowDraft>>)
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
expect(mockEventEmitter.emit).toHaveBeenCalledWith(expect.objectContaining({
type: 'DSL_EXPORT_CHECK',
payload: expect.objectContaining({
data: expect.arrayContaining([
expect.objectContaining({ value_type: 'secret' }),
]),
}),
}))
})
it('should notify on export check error', async () => {
const { fetchWorkflowDraft } = await import('@/service/workflow')
vi.mocked(fetchWorkflowDraft).mockRejectedValueOnce(new Error('fetch failed'))
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
})

View File

@@ -0,0 +1,278 @@
/**
* Integration test: Input field CRUD complete flow
*
* Validates the full lifecycle of input fields:
* creation, editing, renaming, removal, and data conversion round-trip.
*/
import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types'
import type { InputVar } from '@/models/pipeline'
import { describe, expect, it, vi } from 'vitest'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
import { TransferMethod } from '@/types/app'
vi.mock('@/config', () => ({
VAR_ITEM_TEMPLATE_IN_PIPELINE: {
type: 'text-input',
label: '',
variable: '',
max_length: 48,
default_value: undefined,
required: true,
tooltips: undefined,
options: [],
placeholder: undefined,
unit: undefined,
allowed_file_upload_methods: undefined,
allowed_file_types: undefined,
allowed_file_extensions: undefined,
},
}))
describe('Input Field CRUD Flow', () => {
describe('Create → Edit → Convert Round-trip', () => {
it('should create a text field and roundtrip through form data', async () => {
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
)
// Create new field from template (no data passed)
const newFormData = convertToInputFieldFormData()
expect(newFormData.type).toBe('text-input')
expect(newFormData.variable).toBe('')
expect(newFormData.label).toBe('')
expect(newFormData.required).toBe(true)
// Simulate user editing form data
const editedFormData: FormData = {
...newFormData,
variable: 'user_name',
label: 'User Name',
maxLength: 100,
default: 'John',
tooltips: 'Enter your name',
placeholder: 'Type here...',
allowedTypesAndExtensions: {},
}
// Convert back to InputVar
const inputVar = convertFormDataToINputField(editedFormData)
expect(inputVar.variable).toBe('user_name')
expect(inputVar.label).toBe('User Name')
expect(inputVar.max_length).toBe(100)
expect(inputVar.default_value).toBe('John')
expect(inputVar.tooltips).toBe('Enter your name')
expect(inputVar.placeholder).toBe('Type here...')
expect(inputVar.required).toBe(true)
})
it('should handle file field with upload settings', async () => {
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
)
const fileInputVar: InputVar = {
type: PipelineInputVarType.singleFile,
label: 'Upload Document',
variable: 'doc_file',
max_length: 1,
default_value: undefined,
required: true,
tooltips: 'Upload a PDF',
options: [],
placeholder: undefined,
unit: undefined,
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_extensions: ['.pdf', '.docx'],
}
// Convert to form data
const formData = convertToInputFieldFormData(fileInputVar)
expect(formData.allowedFileUploadMethods).toEqual([TransferMethod.local_file, TransferMethod.remote_url])
expect(formData.allowedTypesAndExtensions).toEqual({
allowedFileTypes: [SupportUploadFileTypes.document],
allowedFileExtensions: ['.pdf', '.docx'],
})
// Round-trip back
const restored = convertFormDataToINputField(formData)
expect(restored.allowed_file_upload_methods).toEqual([TransferMethod.local_file, TransferMethod.remote_url])
expect(restored.allowed_file_types).toEqual([SupportUploadFileTypes.document])
expect(restored.allowed_file_extensions).toEqual(['.pdf', '.docx'])
})
it('should handle select field with options', async () => {
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
)
const selectVar: InputVar = {
type: PipelineInputVarType.select,
label: 'Priority',
variable: 'priority',
max_length: 0,
default_value: 'medium',
required: false,
tooltips: 'Select priority level',
options: ['low', 'medium', 'high'],
placeholder: 'Choose...',
unit: undefined,
allowed_file_upload_methods: undefined,
allowed_file_types: undefined,
allowed_file_extensions: undefined,
}
const formData = convertToInputFieldFormData(selectVar)
expect(formData.options).toEqual(['low', 'medium', 'high'])
expect(formData.default).toBe('medium')
const restored = convertFormDataToINputField(formData)
expect(restored.options).toEqual(['low', 'medium', 'high'])
expect(restored.default_value).toBe('medium')
})
it('should handle number field with unit', async () => {
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
)
const numberVar: InputVar = {
type: PipelineInputVarType.number,
label: 'Max Tokens',
variable: 'max_tokens',
max_length: 0,
default_value: '1024',
required: true,
tooltips: undefined,
options: [],
placeholder: undefined,
unit: 'tokens',
allowed_file_upload_methods: undefined,
allowed_file_types: undefined,
allowed_file_extensions: undefined,
}
const formData = convertToInputFieldFormData(numberVar)
expect(formData.unit).toBe('tokens')
expect(formData.default).toBe('1024')
const restored = convertFormDataToINputField(formData)
expect(restored.unit).toBe('tokens')
expect(restored.default_value).toBe('1024')
})
})
describe('Omit optional fields', () => {
it('should not include tooltips when undefined', async () => {
const { convertToInputFieldFormData } = await import(
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
)
const inputVar: InputVar = {
type: PipelineInputVarType.textInput,
label: 'Test',
variable: 'test',
max_length: 48,
default_value: undefined,
required: true,
tooltips: undefined,
options: [],
placeholder: undefined,
unit: undefined,
allowed_file_upload_methods: undefined,
allowed_file_types: undefined,
allowed_file_extensions: undefined,
}
const formData = convertToInputFieldFormData(inputVar)
// Optional fields should not be present
expect('tooltips' in formData).toBe(false)
expect('placeholder' in formData).toBe(false)
expect('unit' in formData).toBe(false)
expect('default' in formData).toBe(false)
})
it('should include optional fields when explicitly set to empty string', async () => {
const { convertToInputFieldFormData } = await import(
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
)
const inputVar: InputVar = {
type: PipelineInputVarType.textInput,
label: 'Test',
variable: 'test',
max_length: 48,
default_value: '',
required: true,
tooltips: '',
options: [],
placeholder: '',
unit: '',
allowed_file_upload_methods: undefined,
allowed_file_types: undefined,
allowed_file_extensions: undefined,
}
const formData = convertToInputFieldFormData(inputVar)
expect(formData.default).toBe('')
expect(formData.tooltips).toBe('')
expect(formData.placeholder).toBe('')
expect(formData.unit).toBe('')
})
})
describe('Multiple fields workflow', () => {
it('should process multiple fields independently', async () => {
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
)
const fields: InputVar[] = [
{
type: PipelineInputVarType.textInput,
label: 'Name',
variable: 'name',
max_length: 48,
default_value: 'Alice',
required: true,
tooltips: undefined,
options: [],
placeholder: undefined,
unit: undefined,
allowed_file_upload_methods: undefined,
allowed_file_types: undefined,
allowed_file_extensions: undefined,
},
{
type: PipelineInputVarType.number,
label: 'Count',
variable: 'count',
max_length: 0,
default_value: '10',
required: false,
tooltips: undefined,
options: [],
placeholder: undefined,
unit: 'items',
allowed_file_upload_methods: undefined,
allowed_file_types: undefined,
allowed_file_extensions: undefined,
},
]
const formDataList = fields.map(f => convertToInputFieldFormData(f))
const restoredFields = formDataList.map(fd => convertFormDataToINputField(fd))
expect(restoredFields).toHaveLength(2)
expect(restoredFields[0].variable).toBe('name')
expect(restoredFields[0].default_value).toBe('Alice')
expect(restoredFields[1].variable).toBe('count')
expect(restoredFields[1].default_value).toBe('10')
expect(restoredFields[1].unit).toBe('items')
})
})
})

View File

@@ -0,0 +1,199 @@
/**
* Integration test: Input field editor data conversion flow
*
* Tests the full pipeline: InputVar -> FormData -> InputVar roundtrip
* and schema validation for various input types.
*/
import type { InputVar } from '@/models/pipeline'
import { describe, expect, it, vi } from 'vitest'
import { PipelineInputVarType } from '@/models/pipeline'
// Mock the config module for VAR_ITEM_TEMPLATE_IN_PIPELINE
vi.mock('@/config', () => ({
VAR_ITEM_TEMPLATE_IN_PIPELINE: {
type: 'text-input',
label: '',
variable: '',
max_length: 48,
required: false,
options: [],
allowed_file_upload_methods: [],
allowed_file_types: [],
allowed_file_extensions: [],
},
MAX_VAR_KEY_LENGTH: 30,
RAG_PIPELINE_PREVIEW_CHUNK_NUM: 10,
}))
// Import real functions (not mocked)
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
)
describe('Input Field Editor Data Flow', () => {
describe('convertToInputFieldFormData', () => {
it('should convert a text input InputVar to FormData', () => {
const inputVar: InputVar = {
type: 'text-input',
label: 'Name',
variable: 'user_name',
max_length: 100,
required: true,
default_value: 'John',
tooltips: 'Enter your name',
placeholder: 'Type here...',
options: [],
} as InputVar
const formData = convertToInputFieldFormData(inputVar)
expect(formData.type).toBe('text-input')
expect(formData.label).toBe('Name')
expect(formData.variable).toBe('user_name')
expect(formData.maxLength).toBe(100)
expect(formData.required).toBe(true)
expect(formData.default).toBe('John')
expect(formData.tooltips).toBe('Enter your name')
expect(formData.placeholder).toBe('Type here...')
})
it('should handle file input with upload settings', () => {
const inputVar: InputVar = {
type: 'file',
label: 'Document',
variable: 'doc',
required: false,
allowed_file_upload_methods: ['local_file', 'remote_url'],
allowed_file_types: ['document', 'image'],
allowed_file_extensions: ['.pdf', '.jpg'],
options: [],
} as InputVar
const formData = convertToInputFieldFormData(inputVar)
expect(formData.allowedFileUploadMethods).toEqual(['local_file', 'remote_url'])
expect(formData.allowedTypesAndExtensions).toEqual({
allowedFileTypes: ['document', 'image'],
allowedFileExtensions: ['.pdf', '.jpg'],
})
})
it('should use template defaults when no data provided', () => {
const formData = convertToInputFieldFormData(undefined)
expect(formData.type).toBe('text-input')
expect(formData.maxLength).toBe(48)
expect(formData.required).toBe(false)
})
it('should omit undefined/null optional fields', () => {
const inputVar: InputVar = {
type: 'text-input',
label: 'Simple',
variable: 'simple_var',
max_length: 50,
required: false,
options: [],
} as InputVar
const formData = convertToInputFieldFormData(inputVar)
expect(formData.default).toBeUndefined()
expect(formData.tooltips).toBeUndefined()
expect(formData.placeholder).toBeUndefined()
expect(formData.unit).toBeUndefined()
})
})
describe('convertFormDataToINputField', () => {
it('should convert FormData back to InputVar', () => {
const formData = {
type: PipelineInputVarType.textInput,
label: 'Name',
variable: 'user_name',
maxLength: 100,
required: true,
default: 'John',
tooltips: 'Enter your name',
options: [],
placeholder: 'Type here...',
allowedTypesAndExtensions: {
allowedFileTypes: undefined,
allowedFileExtensions: undefined,
},
}
const inputVar = convertFormDataToINputField(formData)
expect(inputVar.type).toBe('text-input')
expect(inputVar.label).toBe('Name')
expect(inputVar.variable).toBe('user_name')
expect(inputVar.max_length).toBe(100)
expect(inputVar.required).toBe(true)
expect(inputVar.default_value).toBe('John')
expect(inputVar.tooltips).toBe('Enter your name')
})
})
describe('roundtrip conversion', () => {
it('should preserve text input data through roundtrip', () => {
const original: InputVar = {
type: 'text-input',
label: 'Question',
variable: 'question',
max_length: 200,
required: true,
default_value: 'What is AI?',
tooltips: 'Enter your question',
placeholder: 'Ask something...',
options: [],
} as InputVar
const formData = convertToInputFieldFormData(original)
const restored = convertFormDataToINputField(formData)
expect(restored.type).toBe(original.type)
expect(restored.label).toBe(original.label)
expect(restored.variable).toBe(original.variable)
expect(restored.max_length).toBe(original.max_length)
expect(restored.required).toBe(original.required)
expect(restored.default_value).toBe(original.default_value)
expect(restored.tooltips).toBe(original.tooltips)
expect(restored.placeholder).toBe(original.placeholder)
})
it('should preserve number input data through roundtrip', () => {
const original = {
type: 'number',
label: 'Temperature',
variable: 'temp',
required: false,
default_value: '0.7',
unit: '°C',
options: [],
} as InputVar
const formData = convertToInputFieldFormData(original)
const restored = convertFormDataToINputField(formData)
expect(restored.type).toBe('number')
expect(restored.unit).toBe('°C')
expect(restored.default_value).toBe('0.7')
})
it('should preserve select options through roundtrip', () => {
const original: InputVar = {
type: 'select',
label: 'Mode',
variable: 'mode',
required: true,
options: ['fast', 'balanced', 'quality'],
} as InputVar
const formData = convertToInputFieldFormData(original)
const restored = convertFormDataToINputField(formData)
expect(restored.options).toEqual(['fast', 'balanced', 'quality'])
})
})
})

View File

@@ -0,0 +1,277 @@
/**
* Integration test: Test run end-to-end flow
*
* Validates the data flow through test-run preparation hooks:
* step navigation, datasource filtering, and data clearing.
*/
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mutable holder so mock data can reference BlockEnum after imports
const mockNodesHolder = vi.hoisted(() => ({ value: [] as Record<string, unknown>[] }))
vi.mock('reactflow', () => ({
useNodes: () => mockNodesHolder.value,
}))
mockNodesHolder.value = [
{
id: 'ds-1',
data: {
type: BlockEnum.DataSource,
title: 'Local Files',
datasource_type: 'upload_file',
datasource_configurations: { datasource_label: 'Upload', upload_file_config: {} },
},
},
{
id: 'ds-2',
data: {
type: BlockEnum.DataSource,
title: 'Web Crawl',
datasource_type: 'website_crawl',
datasource_configurations: { datasource_label: 'Crawl' },
},
},
{
id: 'kb-1',
data: {
type: BlockEnum.KnowledgeBase,
title: 'Knowledge Base',
},
},
]
// Mock the Zustand store used by the hooks
const mockSetDocumentsData = vi.fn()
const mockSetSearchValue = vi.fn()
const mockSetSelectedPagesId = vi.fn()
const mockSetOnlineDocuments = vi.fn()
const mockSetCurrentDocument = vi.fn()
const mockSetStep = vi.fn()
const mockSetCrawlResult = vi.fn()
const mockSetWebsitePages = vi.fn()
const mockSetPreviewIndex = vi.fn()
const mockSetCurrentWebsite = vi.fn()
const mockSetOnlineDriveFileList = vi.fn()
const mockSetBucket = vi.fn()
const mockSetPrefix = vi.fn()
const mockSetKeywords = vi.fn()
const mockSetSelectedFileIds = vi.fn()
vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store', () => ({
useDataSourceStore: () => ({
getState: () => ({
setDocumentsData: mockSetDocumentsData,
setSearchValue: mockSetSearchValue,
setSelectedPagesId: mockSetSelectedPagesId,
setOnlineDocuments: mockSetOnlineDocuments,
setCurrentDocument: mockSetCurrentDocument,
setStep: mockSetStep,
setCrawlResult: mockSetCrawlResult,
setWebsitePages: mockSetWebsitePages,
setPreviewIndex: mockSetPreviewIndex,
setCurrentWebsite: mockSetCurrentWebsite,
setOnlineDriveFileList: mockSetOnlineDriveFileList,
setBucket: mockSetBucket,
setPrefix: mockSetPrefix,
setKeywords: mockSetKeywords,
setSelectedFileIds: mockSetSelectedFileIds,
}),
}),
}))
vi.mock('@/models/datasets', () => ({
CrawlStep: {
init: 'init',
},
}))
describe('Test Run Flow Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Step Navigation', () => {
it('should start at step 1 and navigate forward', async () => {
const { useTestRunSteps } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result } = renderHook(() => useTestRunSteps())
expect(result.current.currentStep).toBe(1)
act(() => {
result.current.handleNextStep()
})
expect(result.current.currentStep).toBe(2)
})
it('should navigate back from step 2 to step 1', async () => {
const { useTestRunSteps } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result } = renderHook(() => useTestRunSteps())
act(() => {
result.current.handleNextStep()
})
expect(result.current.currentStep).toBe(2)
act(() => {
result.current.handleBackStep()
})
expect(result.current.currentStep).toBe(1)
})
it('should provide labeled steps', async () => {
const { useTestRunSteps } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result } = renderHook(() => useTestRunSteps())
expect(result.current.steps).toHaveLength(2)
expect(result.current.steps[0].value).toBe('dataSource')
expect(result.current.steps[1].value).toBe('documentProcessing')
})
})
describe('Datasource Options', () => {
it('should filter nodes to only DataSource type', async () => {
const { useDatasourceOptions } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result } = renderHook(() => useDatasourceOptions())
// Should only include DataSource nodes, not KnowledgeBase
expect(result.current).toHaveLength(2)
expect(result.current[0].value).toBe('ds-1')
expect(result.current[1].value).toBe('ds-2')
})
it('should include node data in options', async () => {
const { useDatasourceOptions } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result } = renderHook(() => useDatasourceOptions())
expect(result.current[0].label).toBe('Local Files')
expect(result.current[0].data.type).toBe(BlockEnum.DataSource)
})
})
describe('Data Clearing Flow', () => {
it('should clear online document data', async () => {
const { useOnlineDocument } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result } = renderHook(() => useOnlineDocument())
act(() => {
result.current.clearOnlineDocumentData()
})
expect(mockSetDocumentsData).toHaveBeenCalledWith([])
expect(mockSetSearchValue).toHaveBeenCalledWith('')
expect(mockSetSelectedPagesId).toHaveBeenCalledWith(expect.any(Set))
expect(mockSetOnlineDocuments).toHaveBeenCalledWith([])
expect(mockSetCurrentDocument).toHaveBeenCalledWith(undefined)
})
it('should clear website crawl data', async () => {
const { useWebsiteCrawl } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result } = renderHook(() => useWebsiteCrawl())
act(() => {
result.current.clearWebsiteCrawlData()
})
expect(mockSetStep).toHaveBeenCalledWith('init')
expect(mockSetCrawlResult).toHaveBeenCalledWith(undefined)
expect(mockSetCurrentWebsite).toHaveBeenCalledWith(undefined)
expect(mockSetWebsitePages).toHaveBeenCalledWith([])
expect(mockSetPreviewIndex).toHaveBeenCalledWith(-1)
})
it('should clear online drive data', async () => {
const { useOnlineDrive } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result } = renderHook(() => useOnlineDrive())
act(() => {
result.current.clearOnlineDriveData()
})
expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([])
expect(mockSetBucket).toHaveBeenCalledWith('')
expect(mockSetPrefix).toHaveBeenCalledWith([])
expect(mockSetKeywords).toHaveBeenCalledWith('')
expect(mockSetSelectedFileIds).toHaveBeenCalledWith([])
})
})
describe('Full Flow Simulation', () => {
it('should support complete step navigation cycle', async () => {
const { useTestRunSteps } = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result } = renderHook(() => useTestRunSteps())
// Start at step 1
expect(result.current.currentStep).toBe(1)
// Move to step 2
act(() => {
result.current.handleNextStep()
})
expect(result.current.currentStep).toBe(2)
// Go back to step 1
act(() => {
result.current.handleBackStep()
})
expect(result.current.currentStep).toBe(1)
// Move forward again
act(() => {
result.current.handleNextStep()
})
expect(result.current.currentStep).toBe(2)
})
it('should not regress when clearing all data sources in sequence', async () => {
const {
useOnlineDocument,
useWebsiteCrawl,
useOnlineDrive,
} = await import(
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
)
const { result: docResult } = renderHook(() => useOnlineDocument())
const { result: crawlResult } = renderHook(() => useWebsiteCrawl())
const { result: driveResult } = renderHook(() => useOnlineDrive())
// Clear all data sources
act(() => {
docResult.current.clearOnlineDocumentData()
crawlResult.current.clearWebsiteCrawlData()
driveResult.current.clearOnlineDriveData()
})
expect(mockSetDocumentsData).toHaveBeenCalledWith([])
expect(mockSetStep).toHaveBeenCalledWith('init')
expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([])
})
})
})

View File

@@ -0,0 +1,121 @@
/**
* Integration test: RunBatch CSV upload → Run flow
*
* Tests the complete user journey:
* Upload CSV → parse → enable run → click run → results finish → run again
*/
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import RunBatch from '@/app/components/share/text-generation/run-batch'
vi.mock('@/hooks/use-breakpoints', () => ({
default: vi.fn(() => 'pc'),
MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' },
}))
// Capture the onParsed callback from CSVReader to simulate CSV uploads
let capturedOnParsed: ((data: string[][]) => void) | undefined
vi.mock('@/app/components/share/text-generation/run-batch/csv-reader', () => ({
default: ({ onParsed }: { onParsed: (data: string[][]) => void }) => {
capturedOnParsed = onParsed
return <div data-testid="csv-reader">CSV Reader</div>
},
}))
vi.mock('@/app/components/share/text-generation/run-batch/csv-download', () => ({
default: ({ vars }: { vars: { name: string }[] }) => (
<div data-testid="csv-download">
{vars.map(v => v.name).join(', ')}
</div>
),
}))
describe('RunBatch integration flow', () => {
const vars = [{ name: 'prompt' }, { name: 'context' }]
beforeEach(() => {
capturedOnParsed = undefined
vi.clearAllMocks()
})
it('full lifecycle: upload CSV → run → finish → run again', async () => {
const onSend = vi.fn()
const { rerender } = render(
<RunBatch vars={vars} onSend={onSend} isAllFinished />,
)
// Phase 1 verify child components rendered
expect(screen.getByTestId('csv-reader')).toBeInTheDocument()
expect(screen.getByTestId('csv-download')).toHaveTextContent('prompt, context')
// Run button should be disabled before CSV is parsed
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
expect(runButton).toBeDisabled()
// Phase 2 simulate CSV upload
const csvData = [
['prompt', 'context'],
['Hello', 'World'],
['Goodbye', 'Moon'],
]
await act(async () => {
capturedOnParsed?.(csvData)
})
// Run button should now be enabled
await waitFor(() => {
expect(runButton).not.toBeDisabled()
})
// Phase 3 click run
fireEvent.click(runButton)
expect(onSend).toHaveBeenCalledTimes(1)
expect(onSend).toHaveBeenCalledWith(csvData)
// Phase 4 simulate results still running
rerender(<RunBatch vars={vars} onSend={onSend} isAllFinished={false} />)
expect(runButton).toBeDisabled()
// Phase 5 results finish → can run again
rerender(<RunBatch vars={vars} onSend={onSend} isAllFinished />)
await waitFor(() => {
expect(runButton).not.toBeDisabled()
})
onSend.mockClear()
fireEvent.click(runButton)
expect(onSend).toHaveBeenCalledTimes(1)
})
it('should remain disabled when CSV not uploaded even if all finished', () => {
const onSend = vi.fn()
render(<RunBatch vars={vars} onSend={onSend} isAllFinished />)
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
expect(runButton).toBeDisabled()
fireEvent.click(runButton)
expect(onSend).not.toHaveBeenCalled()
})
it('should show spinner icon when results are still running', async () => {
const onSend = vi.fn()
const { container } = render(
<RunBatch vars={vars} onSend={onSend} isAllFinished={false} />,
)
// Upload CSV first
await act(async () => {
capturedOnParsed?.([['data']])
})
// Button disabled + spinning icon
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
expect(runButton).toBeDisabled()
const icon = container.querySelector('svg')
expect(icon).toHaveClass('animate-spin')
})
})

View File

@@ -0,0 +1,218 @@
/**
* Integration test: RunOnce form lifecycle
*
* Tests the complete user journey:
* Init defaults → edit fields → submit → running state → stop
*/
import type { InputValueTypes } from '@/app/components/share/text-generation/types'
import type { PromptConfig, PromptVariable } from '@/models/debug'
import type { SiteInfo } from '@/models/share'
import type { VisionSettings } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useRef, useState } from 'react'
import RunOnce from '@/app/components/share/text-generation/run-once'
import { Resolution, TransferMethod } from '@/types/app'
vi.mock('@/hooks/use-breakpoints', () => ({
default: vi.fn(() => 'pc'),
MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' },
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ value, onChange }: { value?: string, onChange?: (val: string) => void }) => (
<textarea data-testid="code-editor" value={value ?? ''} onChange={e => onChange?.(e.target.value)} />
),
}))
vi.mock('@/app/components/base/image-uploader/text-generation-image-uploader', () => ({
default: () => <div data-testid="vision-uploader" />,
}))
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: () => <div data-testid="file-uploader" />,
}))
// ----- helpers -----
const variable = (overrides: Partial<PromptVariable>): PromptVariable => ({
key: 'k',
name: 'Name',
type: 'string',
required: true,
...overrides,
})
const visionOff: VisionSettings = {
enabled: false,
number_limits: 0,
detail: Resolution.low,
transfer_methods: [TransferMethod.local_file],
image_file_size_limit: 5,
}
const siteInfo: SiteInfo = { title: 'Test' }
/**
* Stateful wrapper that mirrors what text-generation/index.tsx does:
* owns `inputs` state and passes an `inputsRef`.
*/
function Harness({
promptConfig,
visionConfig = visionOff,
onSendSpy,
runControl = null,
}: {
promptConfig: PromptConfig
visionConfig?: VisionSettings
onSendSpy: () => void
runControl?: React.ComponentProps<typeof RunOnce>['runControl']
}) {
const [inputs, setInputs] = useState<Record<string, InputValueTypes>>({})
const inputsRef = useRef<Record<string, InputValueTypes>>({})
return (
<RunOnce
siteInfo={siteInfo}
promptConfig={promptConfig}
inputs={inputs}
inputsRef={inputsRef}
onInputsChange={(updated) => {
inputsRef.current = updated
setInputs(updated)
}}
onSend={onSendSpy}
visionConfig={visionConfig}
onVisionFilesChange={vi.fn()}
runControl={runControl}
/>
)
}
// ----- tests -----
describe('RunOnce integration flow', () => {
it('full lifecycle: init → edit → submit → running → stop', async () => {
const onSend = vi.fn()
const config: PromptConfig = {
prompt_template: 'tpl',
prompt_variables: [
variable({ key: 'name', name: 'Name', type: 'string', default: '' }),
variable({ key: 'age', name: 'Age', type: 'number', default: '' }),
variable({ key: 'bio', name: 'Bio', type: 'paragraph', default: '' }),
],
}
// Phase 1 render, wait for initialisation
const { rerender } = render(
<Harness promptConfig={config} onSendSpy={onSend} />,
)
await waitFor(() => {
expect(screen.getByPlaceholderText('Name')).toBeInTheDocument()
})
// Phase 2 fill fields
fireEvent.change(screen.getByPlaceholderText('Name'), { target: { value: 'Alice' } })
fireEvent.change(screen.getByPlaceholderText('Age'), { target: { value: '30' } })
fireEvent.change(screen.getByPlaceholderText('Bio'), { target: { value: 'Hello' } })
// Phase 3 submit
fireEvent.click(screen.getByTestId('run-button'))
expect(onSend).toHaveBeenCalledTimes(1)
// Phase 4 simulate "running" state
const onStop = vi.fn()
rerender(
<Harness
promptConfig={config}
onSendSpy={onSend}
runControl={{ onStop, isStopping: false }}
/>,
)
const stopBtn = screen.getByTestId('stop-button')
expect(stopBtn).toBeInTheDocument()
fireEvent.click(stopBtn)
expect(onStop).toHaveBeenCalledTimes(1)
// Phase 5 simulate "stopping" state
rerender(
<Harness
promptConfig={config}
onSendSpy={onSend}
runControl={{ onStop, isStopping: true }}
/>,
)
expect(screen.getByTestId('stop-button')).toBeDisabled()
})
it('clear resets all field types and allows re-submit', async () => {
const onSend = vi.fn()
const config: PromptConfig = {
prompt_template: 'tpl',
prompt_variables: [
variable({ key: 'q', name: 'Question', type: 'string', default: 'Hi' }),
variable({ key: 'flag', name: 'Flag', type: 'checkbox' }),
],
}
render(<Harness promptConfig={config} onSendSpy={onSend} />)
await waitFor(() => {
expect(screen.getByPlaceholderText('Question')).toHaveValue('Hi')
})
// Clear all
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
await waitFor(() => {
expect(screen.getByPlaceholderText('Question')).toHaveValue('')
})
// Re-fill and submit
fireEvent.change(screen.getByPlaceholderText('Question'), { target: { value: 'New' } })
fireEvent.click(screen.getByTestId('run-button'))
expect(onSend).toHaveBeenCalledTimes(1)
})
it('mixed input types: string + select + json_object', async () => {
const onSend = vi.fn()
const config: PromptConfig = {
prompt_template: 'tpl',
prompt_variables: [
variable({ key: 'txt', name: 'Text', type: 'string', default: '' }),
variable({
key: 'sel',
name: 'Dropdown',
type: 'select',
options: ['A', 'B'],
default: 'A',
}),
variable({
key: 'json',
name: 'JSON',
type: 'json_object' as PromptVariable['type'],
}),
],
}
render(<Harness promptConfig={config} onSendSpy={onSend} />)
await waitFor(() => {
expect(screen.getByText('Text')).toBeInTheDocument()
expect(screen.getByText('Dropdown')).toBeInTheDocument()
expect(screen.getByText('JSON')).toBeInTheDocument()
})
// Edit text & json
fireEvent.change(screen.getByPlaceholderText('Text'), { target: { value: 'hello' } })
fireEvent.change(screen.getByTestId('code-editor'), { target: { value: '{"a":1}' } })
fireEvent.click(screen.getByTestId('run-button'))
expect(onSend).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,369 @@
import type { Collection } from '@/app/components/tools/types'
/**
* Integration Test: Tool Browsing & Filtering Flow
*
* Tests the integration between ProviderList, TabSliderNew, LabelFilter,
* Input (search), and card rendering. Verifies that tab switching, keyword
* filtering, and label filtering work together correctly.
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CollectionType } from '@/app/components/tools/types'
// ---- Mocks ----
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const map: Record<string, string> = {
'type.builtIn': 'Built-in',
'type.custom': 'Custom',
'type.workflow': 'Workflow',
'noTools': 'No tools found',
}
return map[key] ?? key
},
}),
}))
vi.mock('nuqs', () => ({
useQueryState: () => ['builtin', vi.fn()],
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({ enable_marketplace: false }),
}))
vi.mock('@/app/components/plugins/hooks', () => ({
useTags: () => ({
getTagLabel: (key: string) => key,
tags: [],
}),
}))
vi.mock('@/service/use-plugins', () => ({
useCheckInstalled: () => ({ data: null }),
useInvalidateInstalledPluginList: () => vi.fn(),
}))
const mockCollections: Collection[] = [
{
id: 'google-search',
name: 'google_search',
author: 'Dify',
description: { en_US: 'Google Search Tool', zh_Hans: 'Google搜索工具' },
icon: 'https://example.com/google.png',
label: { en_US: 'Google Search', zh_Hans: 'Google搜索' },
type: CollectionType.builtIn,
team_credentials: {},
is_team_authorization: true,
allow_delete: false,
labels: ['search'],
},
{
id: 'weather-api',
name: 'weather_api',
author: 'Dify',
description: { en_US: 'Weather API Tool', zh_Hans: '天气API工具' },
icon: 'https://example.com/weather.png',
label: { en_US: 'Weather API', zh_Hans: '天气API' },
type: CollectionType.builtIn,
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: ['utility'],
},
{
id: 'my-custom-tool',
name: 'my_custom_tool',
author: 'User',
description: { en_US: 'My Custom Tool', zh_Hans: '我的自定义工具' },
icon: 'https://example.com/custom.png',
label: { en_US: 'My Custom Tool', zh_Hans: '我的自定义工具' },
type: CollectionType.custom,
team_credentials: {},
is_team_authorization: false,
allow_delete: true,
labels: [],
},
{
id: 'workflow-tool-1',
name: 'workflow_tool_1',
author: 'User',
description: { en_US: 'Workflow Tool', zh_Hans: '工作流工具' },
icon: 'https://example.com/workflow.png',
label: { en_US: 'Workflow Tool', zh_Hans: '工作流工具' },
type: CollectionType.workflow,
team_credentials: {},
is_team_authorization: false,
allow_delete: true,
labels: [],
},
]
const mockRefetch = vi.fn()
vi.mock('@/service/use-tools', () => ({
useAllToolProviders: () => ({
data: mockCollections,
refetch: mockRefetch,
isSuccess: true,
}),
}))
vi.mock('@/app/components/base/tab-slider-new', () => ({
default: ({ value, onChange, options }: { value: string, onChange: (v: string) => void, options: Array<{ value: string, text: string }> }) => (
<div data-testid="tab-slider">
{options.map((opt: { value: string, text: string }) => (
<button
key={opt.value}
data-testid={`tab-${opt.value}`}
data-active={value === opt.value ? 'true' : 'false'}
onClick={() => onChange(opt.value)}
>
{opt.text}
</button>
))}
</div>
),
}))
vi.mock('@/app/components/base/input', () => ({
default: ({ value, onChange, onClear, showLeftIcon, showClearIcon, wrapperClassName }: {
value: string
onChange: (e: { target: { value: string } }) => void
onClear: () => void
showLeftIcon?: boolean
showClearIcon?: boolean
wrapperClassName?: string
}) => (
<div data-testid="search-input-wrapper" className={wrapperClassName}>
<input
data-testid="search-input"
value={value}
onChange={onChange}
data-left-icon={showLeftIcon ? 'true' : 'false'}
data-clear-icon={showClearIcon ? 'true' : 'false'}
/>
{showClearIcon && value && (
<button data-testid="clear-search" onClick={onClear}>Clear</button>
)}
</div>
),
}))
vi.mock('@/app/components/plugins/card', () => ({
default: ({ payload, className }: { payload: { brief: Record<string, string> | string, name: string }, className?: string }) => {
const briefText = typeof payload.brief === 'object' ? payload.brief?.en_US || '' : payload.brief
return (
<div data-testid={`card-${payload.name}`} className={className}>
<span>{payload.name}</span>
<span>{briefText}</span>
</div>
)
},
}))
vi.mock('@/app/components/plugins/card/card-more-info', () => ({
default: ({ tags }: { tags: string[] }) => (
<div data-testid="card-more-info">{tags.join(', ')}</div>
),
}))
vi.mock('@/app/components/tools/labels/filter', () => ({
default: ({ value: _value, onChange }: { value: string[], onChange: (v: string[]) => void }) => (
<div data-testid="label-filter">
<button data-testid="filter-search" onClick={() => onChange(['search'])}>Filter: search</button>
<button data-testid="filter-utility" onClick={() => onChange(['utility'])}>Filter: utility</button>
<button data-testid="filter-clear" onClick={() => onChange([])}>Clear filter</button>
</div>
),
}))
vi.mock('@/app/components/tools/provider/custom-create-card', () => ({
default: () => <div data-testid="custom-create-card">Create Custom Tool</div>,
}))
vi.mock('@/app/components/tools/provider/detail', () => ({
default: ({ collection, onHide }: { collection: Collection, onHide: () => void }) => (
<div data-testid="provider-detail">
<span data-testid="detail-name">{collection.name}</span>
<button data-testid="detail-close" onClick={onHide}>Close</button>
</div>
),
}))
vi.mock('@/app/components/tools/provider/empty', () => ({
default: () => <div data-testid="workflow-empty">No workflow tools</div>,
}))
vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({
default: ({ detail, onHide }: { detail: unknown, onHide: () => void }) => (
detail ? <div data-testid="plugin-detail-panel"><button onClick={onHide}>Close</button></div> : null
),
}))
vi.mock('@/app/components/plugins/marketplace/empty', () => ({
default: ({ text }: { text: string }) => <div data-testid="empty-state">{text}</div>,
}))
vi.mock('@/app/components/tools/marketplace', () => ({
default: () => null,
}))
vi.mock('@/app/components/tools/mcp', () => ({
default: () => <div data-testid="mcp-list">MCP List</div>,
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}))
vi.mock('@/app/components/workflow/block-selector/types', () => ({
ToolTypeEnum: { BuiltIn: 'builtin', Custom: 'api', Workflow: 'workflow', MCP: 'mcp' },
}))
const { default: ProviderList } = await import('@/app/components/tools/provider-list')
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
describe('Tool Browsing & Filtering Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
cleanup()
})
it('renders tab options and built-in tools by default', () => {
render(<ProviderList />, { wrapper: createWrapper() })
expect(screen.getByTestId('tab-slider')).toBeInTheDocument()
expect(screen.getByTestId('tab-builtin')).toBeInTheDocument()
expect(screen.getByTestId('tab-api')).toBeInTheDocument()
expect(screen.getByTestId('tab-workflow')).toBeInTheDocument()
expect(screen.getByTestId('tab-mcp')).toBeInTheDocument()
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
expect(screen.queryByTestId('card-my_custom_tool')).not.toBeInTheDocument()
expect(screen.queryByTestId('card-workflow_tool_1')).not.toBeInTheDocument()
})
it('filters tools by keyword search', async () => {
render(<ProviderList />, { wrapper: createWrapper() })
const searchInput = screen.getByTestId('search-input')
fireEvent.change(searchInput, { target: { value: 'Google' } })
await waitFor(() => {
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
})
})
it('clears search keyword and shows all tools again', async () => {
render(<ProviderList />, { wrapper: createWrapper() })
const searchInput = screen.getByTestId('search-input')
fireEvent.change(searchInput, { target: { value: 'Google' } })
await waitFor(() => {
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
})
fireEvent.change(searchInput, { target: { value: '' } })
await waitFor(() => {
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
})
})
it('filters tools by label tags', async () => {
render(<ProviderList />, { wrapper: createWrapper() })
fireEvent.click(screen.getByTestId('filter-search'))
await waitFor(() => {
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
})
})
it('clears label filter and shows all tools', async () => {
render(<ProviderList />, { wrapper: createWrapper() })
fireEvent.click(screen.getByTestId('filter-utility'))
await waitFor(() => {
expect(screen.queryByTestId('card-google_search')).not.toBeInTheDocument()
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('filter-clear'))
await waitFor(() => {
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
})
})
it('combines keyword search and label filter', async () => {
render(<ProviderList />, { wrapper: createWrapper() })
fireEvent.click(screen.getByTestId('filter-search'))
await waitFor(() => {
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
})
const searchInput = screen.getByTestId('search-input')
fireEvent.change(searchInput, { target: { value: 'Weather' } })
await waitFor(() => {
expect(screen.queryByTestId('card-google_search')).not.toBeInTheDocument()
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
})
})
it('opens provider detail when clicking a non-plugin collection card', async () => {
render(<ProviderList />, { wrapper: createWrapper() })
const card = screen.getByTestId('card-google_search')
fireEvent.click(card.parentElement!)
await waitFor(() => {
expect(screen.getByTestId('provider-detail')).toBeInTheDocument()
expect(screen.getByTestId('detail-name')).toHaveTextContent('google_search')
})
})
it('closes provider detail and deselects current provider', async () => {
render(<ProviderList />, { wrapper: createWrapper() })
const card = screen.getByTestId('card-google_search')
fireEvent.click(card.parentElement!)
await waitFor(() => {
expect(screen.getByTestId('provider-detail')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('detail-close'))
await waitFor(() => {
expect(screen.queryByTestId('provider-detail')).not.toBeInTheDocument()
})
})
it('shows label filter for non-MCP tabs', () => {
render(<ProviderList />, { wrapper: createWrapper() })
expect(screen.getByTestId('label-filter')).toBeInTheDocument()
})
it('shows search input on all tabs', () => {
render(<ProviderList />, { wrapper: createWrapper() })
expect(screen.getByTestId('search-input')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,239 @@
/**
* Integration Test: Tool Data Processing Pipeline
*
* Tests the integration between tool utility functions and type conversions.
* Verifies that data flows correctly through the processing pipeline:
* raw API data → form schemas → form values → configured values.
*/
import { describe, expect, it } from 'vitest'
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils/index'
import {
addDefaultValue,
generateFormValue,
getConfiguredValue,
getPlainValue,
getStructureValue,
toolCredentialToFormSchemas,
toolParametersToFormSchemas,
toType,
triggerEventParametersToFormSchemas,
} from '@/app/components/tools/utils/to-form-schema'
describe('Tool Data Processing Pipeline Integration', () => {
describe('End-to-end: API schema → form schema → form value', () => {
it('processes tool parameters through the full pipeline', () => {
const rawParameters = [
{
name: 'query',
label: { en_US: 'Search Query', zh_Hans: '搜索查询' },
type: 'string',
required: true,
default: 'hello',
form: 'llm',
human_description: { en_US: 'Enter your search query', zh_Hans: '输入搜索查询' },
llm_description: 'The search query string',
options: [],
},
{
name: 'limit',
label: { en_US: 'Result Limit', zh_Hans: '结果限制' },
type: 'number',
required: false,
default: '10',
form: 'form',
human_description: { en_US: 'Maximum results', zh_Hans: '最大结果数' },
llm_description: 'Limit for results',
options: [],
},
]
const formSchemas = toolParametersToFormSchemas(rawParameters as unknown as Parameters<typeof toolParametersToFormSchemas>[0])
expect(formSchemas).toHaveLength(2)
expect(formSchemas[0].variable).toBe('query')
expect(formSchemas[0].required).toBe(true)
expect(formSchemas[0].type).toBe('text-input')
expect(formSchemas[1].variable).toBe('limit')
expect(formSchemas[1].type).toBe('number-input')
const withDefaults = addDefaultValue({}, formSchemas)
expect(withDefaults.query).toBe('hello')
expect(withDefaults.limit).toBe('10')
const formValues = generateFormValue({}, formSchemas, false)
expect(formValues).toBeDefined()
expect(formValues.query).toBeDefined()
expect(formValues.limit).toBeDefined()
})
it('processes tool credentials through the pipeline', () => {
const rawCredentials = [
{
name: 'api_key',
label: { en_US: 'API Key', zh_Hans: 'API 密钥' },
type: 'secret-input',
required: true,
default: '',
placeholder: { en_US: 'Enter API key', zh_Hans: '输入 API 密钥' },
help: { en_US: 'Your API key', zh_Hans: '你的 API 密钥' },
url: 'https://example.com/get-key',
options: [],
},
]
const credentialSchemas = toolCredentialToFormSchemas(rawCredentials as Parameters<typeof toolCredentialToFormSchemas>[0])
expect(credentialSchemas).toHaveLength(1)
expect(credentialSchemas[0].variable).toBe('api_key')
expect(credentialSchemas[0].required).toBe(true)
expect(credentialSchemas[0].type).toBe('secret-input')
})
it('processes trigger event parameters through the pipeline', () => {
const rawParams = [
{
name: 'event_type',
label: { en_US: 'Event Type', zh_Hans: '事件类型' },
type: 'select',
required: true,
default: 'push',
form: 'form',
description: { en_US: 'Type of event', zh_Hans: '事件类型' },
options: [
{ value: 'push', label: { en_US: 'Push', zh_Hans: '推送' } },
{ value: 'pull', label: { en_US: 'Pull', zh_Hans: '拉取' } },
],
},
]
const schemas = triggerEventParametersToFormSchemas(rawParams as unknown as Parameters<typeof triggerEventParametersToFormSchemas>[0])
expect(schemas).toHaveLength(1)
expect(schemas[0].name).toBe('event_type')
expect(schemas[0].type).toBe('select')
expect(schemas[0].options).toHaveLength(2)
})
})
describe('Type conversion integration', () => {
it('converts all supported types correctly', () => {
const typeConversions = [
{ input: 'string', expected: 'text-input' },
{ input: 'number', expected: 'number-input' },
{ input: 'boolean', expected: 'checkbox' },
{ input: 'select', expected: 'select' },
{ input: 'secret-input', expected: 'secret-input' },
{ input: 'file', expected: 'file' },
{ input: 'files', expected: 'files' },
]
typeConversions.forEach(({ input, expected }) => {
expect(toType(input)).toBe(expected)
})
})
it('returns the original type for unrecognized types', () => {
expect(toType('unknown-type')).toBe('unknown-type')
expect(toType('app-selector')).toBe('app-selector')
})
})
describe('Value extraction integration', () => {
it('wraps values with getStructureValue and extracts inner value with getPlainValue', () => {
const plainInput = { query: 'test', limit: 10 }
const structured = getStructureValue(plainInput)
expect(structured.query).toEqual({ value: 'test' })
expect(structured.limit).toEqual({ value: 10 })
const objectStructured = {
query: { value: { type: 'constant', content: 'test search' } },
limit: { value: { type: 'constant', content: 10 } },
}
const extracted = getPlainValue(objectStructured)
expect(extracted.query).toEqual({ type: 'constant', content: 'test search' })
expect(extracted.limit).toEqual({ type: 'constant', content: 10 })
})
it('handles getConfiguredValue for workflow tool configurations', () => {
const formSchemas = [
{ variable: 'query', type: 'text-input', default: 'default-query' },
{ variable: 'format', type: 'select', default: 'json' },
]
const configured = getConfiguredValue({}, formSchemas)
expect(configured).toBeDefined()
expect(configured.query).toBeDefined()
expect(configured.format).toBeDefined()
})
it('preserves existing values in getConfiguredValue', () => {
const formSchemas = [
{ variable: 'query', type: 'text-input', default: 'default-query' },
]
const configured = getConfiguredValue({ query: 'my-existing-query' }, formSchemas)
expect(configured.query).toBe('my-existing-query')
})
})
describe('Agent utilities integration', () => {
it('sorts agent thoughts and enriches with file infos end-to-end', () => {
const thoughts = [
{ id: 't3', position: 3, tool: 'search', files: ['f1'] },
{ id: 't1', position: 1, tool: 'analyze', files: [] },
{ id: 't2', position: 2, tool: 'summarize', files: ['f2'] },
] as Parameters<typeof sortAgentSorts>[0]
const messageFiles = [
{ id: 'f1', name: 'result.txt', type: 'document' },
{ id: 'f2', name: 'summary.pdf', type: 'document' },
] as Parameters<typeof addFileInfos>[1]
const sorted = sortAgentSorts(thoughts)
expect(sorted[0].id).toBe('t1')
expect(sorted[1].id).toBe('t2')
expect(sorted[2].id).toBe('t3')
const enriched = addFileInfos(sorted, messageFiles)
expect(enriched[0].message_files).toBeUndefined()
expect(enriched[1].message_files).toHaveLength(1)
expect(enriched[1].message_files![0].id).toBe('f2')
expect(enriched[2].message_files).toHaveLength(1)
expect(enriched[2].message_files![0].id).toBe('f1')
})
it('handles null inputs gracefully in the pipeline', () => {
const sortedNull = sortAgentSorts(null as never)
expect(sortedNull).toBeNull()
const enrichedNull = addFileInfos(null as never, [])
expect(enrichedNull).toBeNull()
// addFileInfos with empty list and null files returns the mapped (empty) list
const enrichedEmptyList = addFileInfos([], null as never)
expect(enrichedEmptyList).toEqual([])
})
})
describe('Default value application', () => {
it('applies defaults only to empty fields, preserving user values', () => {
const userValues = { api_key: 'user-provided-key' }
const schemas = [
{ variable: 'api_key', type: 'text-input', default: 'default-key', name: 'api_key' },
{ variable: 'secret', type: 'secret-input', default: 'default-secret', name: 'secret' },
]
const result = addDefaultValue(userValues, schemas)
expect(result.api_key).toBe('user-provided-key')
expect(result.secret).toBe('default-secret')
})
it('handles boolean type conversion in defaults', () => {
const schemas = [
{ variable: 'enabled', type: 'boolean', default: 'true', name: 'enabled' },
]
const result = addDefaultValue({ enabled: 'true' }, schemas)
expect(result.enabled).toBe(true)
})
})
})

View File

@@ -0,0 +1,548 @@
import type { Collection } from '@/app/components/tools/types'
/**
* Integration Test: Tool Provider Detail Flow
*
* Tests the integration between ProviderDetail, ConfigCredential,
* EditCustomToolModal, WorkflowToolModal, and service APIs.
* Verifies that different provider types render correctly and
* handle auth/edit/delete flows.
*/
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CollectionType } from '@/app/components/tools/types'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) => {
const map: Record<string, string> = {
'auth.authorized': 'Authorized',
'auth.unauthorized': 'Set up credentials',
'auth.setup': 'NEEDS SETUP',
'createTool.editAction': 'Edit',
'createTool.deleteToolConfirmTitle': 'Delete Tool',
'createTool.deleteToolConfirmContent': 'Are you sure?',
'createTool.toolInput.title': 'Tool Input',
'createTool.toolInput.required': 'Required',
'openInStudio': 'Open in Studio',
'api.actionSuccess': 'Action succeeded',
}
if (key === 'detailPanel.actionNum')
return `${opts?.num ?? 0} actions`
if (key === 'includeToolNum')
return `${opts?.num ?? 0} actions`
return map[key] ?? key
},
}),
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en',
}))
vi.mock('@/i18n-config/language', () => ({
getLanguage: () => 'en_US',
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: true,
}),
}))
const mockSetShowModelModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowModelModal: mockSetShowModelModal,
}),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: [
{ provider: 'model-provider-1', name: 'Model Provider 1' },
],
}),
}))
const mockFetchBuiltInToolList = vi.fn().mockResolvedValue([
{ name: 'tool-1', description: { en_US: 'Tool 1' }, parameters: [] },
{ name: 'tool-2', description: { en_US: 'Tool 2' }, parameters: [] },
])
const mockFetchModelToolList = vi.fn().mockResolvedValue([])
const mockFetchCustomToolList = vi.fn().mockResolvedValue([])
const mockFetchCustomCollection = vi.fn().mockResolvedValue({
credentials: { auth_type: 'none' },
schema: '',
schema_type: 'openapi',
})
const mockFetchWorkflowToolDetail = vi.fn().mockResolvedValue({
workflow_app_id: 'app-123',
tool: {
parameters: [
{ name: 'query', llm_description: 'Search query', form: 'text', required: true, type: 'string' },
],
labels: ['search'],
},
})
const mockUpdateBuiltInToolCredential = vi.fn().mockResolvedValue({})
const mockRemoveBuiltInToolCredential = vi.fn().mockResolvedValue({})
const mockUpdateCustomCollection = vi.fn().mockResolvedValue({})
const mockRemoveCustomCollection = vi.fn().mockResolvedValue({})
const mockDeleteWorkflowTool = vi.fn().mockResolvedValue({})
const mockSaveWorkflowToolProvider = vi.fn().mockResolvedValue({})
vi.mock('@/service/tools', () => ({
fetchBuiltInToolList: (...args: unknown[]) => mockFetchBuiltInToolList(...args),
fetchModelToolList: (...args: unknown[]) => mockFetchModelToolList(...args),
fetchCustomToolList: (...args: unknown[]) => mockFetchCustomToolList(...args),
fetchCustomCollection: (...args: unknown[]) => mockFetchCustomCollection(...args),
fetchWorkflowToolDetail: (...args: unknown[]) => mockFetchWorkflowToolDetail(...args),
updateBuiltInToolCredential: (...args: unknown[]) => mockUpdateBuiltInToolCredential(...args),
removeBuiltInToolCredential: (...args: unknown[]) => mockRemoveBuiltInToolCredential(...args),
updateCustomCollection: (...args: unknown[]) => mockUpdateCustomCollection(...args),
removeCustomCollection: (...args: unknown[]) => mockRemoveCustomCollection(...args),
deleteWorkflowTool: (...args: unknown[]) => mockDeleteWorkflowTool(...args),
saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args),
fetchBuiltInToolCredential: vi.fn().mockResolvedValue({}),
fetchBuiltInToolCredentialSchema: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/service/use-tools', () => ({
useInvalidateAllWorkflowTools: () => vi.fn(),
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}))
vi.mock('@/utils/var', () => ({
basePath: '',
}))
vi.mock('@/app/components/base/drawer', () => ({
default: ({ isOpen, children, onClose }: { isOpen: boolean, children: React.ReactNode, onClose: () => void }) => (
isOpen
? (
<div data-testid="drawer">
{children}
<button data-testid="drawer-close" onClick={onClose}>Close Drawer</button>
</div>
)
: null
),
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ title, isShow, onConfirm, onCancel }: {
title: string
content: string
isShow: boolean
onConfirm: () => void
onCancel: () => void
}) => (
isShow
? (
<div data-testid="confirm-dialog">
<span>{title}</span>
<button data-testid="confirm-ok" onClick={onConfirm}>Confirm</button>
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
</div>
)
: null
),
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
}))
vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({
LinkExternal02: () => <span data-testid="link-icon" />,
Settings01: () => <span data-testid="settings-icon" />,
}))
vi.mock('@remixicon/react', () => ({
RiCloseLine: () => <span data-testid="close-icon" />,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
ConfigurationMethodEnum: { predefinedModel: 'predefined-model' },
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <span data-testid={`indicator-${color}`} />,
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: ({ src }: { src: string }) => <div data-testid="card-icon" data-src={typeof src === 'string' ? src : 'emoji'} />,
}))
vi.mock('@/app/components/plugins/card/base/description', () => ({
default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>,
}))
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
default: ({ orgName, packageName }: { orgName: string, packageName: string }) => (
<div data-testid="org-info">
{orgName}
{' '}
/
{' '}
{packageName}
</div>
),
}))
vi.mock('@/app/components/plugins/card/base/title', () => ({
default: ({ title }: { title: string }) => <div data-testid="title">{title}</div>,
}))
vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({
default: ({ onHide, onEdit, onRemove }: { onHide: () => void, onEdit: (data: unknown) => void, onRemove: () => void, payload: unknown }) => (
<div data-testid="edit-custom-modal">
<button data-testid="custom-modal-hide" onClick={onHide}>Hide</button>
<button data-testid="custom-modal-save" onClick={() => onEdit({ name: 'updated', labels: [] })}>Save</button>
<button data-testid="custom-modal-remove" onClick={onRemove}>Remove</button>
</div>
),
}))
vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({
default: ({ onCancel, onSaved, onRemove }: { collection: Collection, onCancel: () => void, onSaved: (v: Record<string, unknown>) => void, onRemove: () => void }) => (
<div data-testid="config-credential">
<button data-testid="cred-cancel" onClick={onCancel}>Cancel</button>
<button data-testid="cred-save" onClick={() => onSaved({ api_key: 'test-key' })}>Save</button>
<button data-testid="cred-remove" onClick={onRemove}>Remove</button>
</div>
),
}))
vi.mock('@/app/components/tools/workflow-tool', () => ({
default: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => (
<div data-testid="workflow-tool-modal">
<button data-testid="wf-modal-hide" onClick={onHide}>Hide</button>
<button data-testid="wf-modal-save" onClick={() => onSave({ name: 'updated-wf' })}>Save</button>
<button data-testid="wf-modal-remove" onClick={onRemove}>Remove</button>
</div>
),
}))
vi.mock('@/app/components/tools/provider/tool-item', () => ({
default: ({ tool }: { tool: { name: string } }) => (
<div data-testid={`tool-item-${tool.name}`}>{tool.name}</div>
),
}))
const { default: ProviderDetail } = await import('@/app/components/tools/provider/detail')
const makeCollection = (overrides: Partial<Collection> = {}): Collection => ({
id: 'test-collection',
name: 'test_collection',
author: 'Dify',
description: { en_US: 'Test collection description', zh_Hans: '测试集合描述' },
icon: 'https://example.com/icon.png',
label: { en_US: 'Test Collection', zh_Hans: '测试集合' },
type: CollectionType.builtIn,
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: [],
...overrides,
})
const mockOnHide = vi.fn()
const mockOnRefreshData = vi.fn()
describe('Tool Provider Detail Flow Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
cleanup()
})
describe('Built-in Provider', () => {
it('renders provider detail with title, author, and description', async () => {
const collection = makeCollection()
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
await waitFor(() => {
expect(screen.getByTestId('title')).toHaveTextContent('Test Collection')
expect(screen.getByTestId('org-info')).toHaveTextContent('Dify')
expect(screen.getByTestId('description')).toHaveTextContent('Test collection description')
})
})
it('loads tool list from API on mount', async () => {
const collection = makeCollection()
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
await waitFor(() => {
expect(mockFetchBuiltInToolList).toHaveBeenCalledWith('test_collection')
})
await waitFor(() => {
expect(screen.getByTestId('tool-item-tool-1')).toBeInTheDocument()
expect(screen.getByTestId('tool-item-tool-2')).toBeInTheDocument()
})
})
it('shows "Set up credentials" button when not authorized and needs auth', async () => {
const collection = makeCollection({
allow_delete: true,
is_team_authorization: false,
})
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
await waitFor(() => {
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
})
})
it('shows "Authorized" button when authorized', async () => {
const collection = makeCollection({
allow_delete: true,
is_team_authorization: true,
})
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
await waitFor(() => {
expect(screen.getByText('Authorized')).toBeInTheDocument()
expect(screen.getByTestId('indicator-green')).toBeInTheDocument()
})
})
it('opens ConfigCredential when clicking auth button (built-in type)', async () => {
const collection = makeCollection({
allow_delete: true,
is_team_authorization: false,
})
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
await waitFor(() => {
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('Set up credentials'))
await waitFor(() => {
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
})
})
it('saves credential and refreshes data', async () => {
const collection = makeCollection({
allow_delete: true,
is_team_authorization: false,
})
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
await waitFor(() => {
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('Set up credentials'))
await waitFor(() => {
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('cred-save'))
await waitFor(() => {
expect(mockUpdateBuiltInToolCredential).toHaveBeenCalledWith('test_collection', { api_key: 'test-key' })
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
it('removes credential and refreshes data', async () => {
const collection = makeCollection({
allow_delete: true,
is_team_authorization: false,
})
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
await waitFor(() => {
fireEvent.click(screen.getByText('Set up credentials'))
})
await waitFor(() => {
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('cred-remove'))
await waitFor(() => {
expect(mockRemoveBuiltInToolCredential).toHaveBeenCalledWith('test_collection')
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
})
describe('Model Provider', () => {
it('opens model modal when clicking auth button for model type', async () => {
const collection = makeCollection({
id: 'model-provider-1',
type: CollectionType.model,
allow_delete: true,
is_team_authorization: false,
})
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
await waitFor(() => {
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('Set up credentials'))
await waitFor(() => {
expect(mockSetShowModelModal).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
currentProvider: expect.objectContaining({ provider: 'model-provider-1' }),
}),
}),
)
})
})
})
describe('Custom Provider', () => {
it('fetches custom collection details and shows edit button', async () => {
const collection = makeCollection({
type: CollectionType.custom,
allow_delete: true,
})
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
await waitFor(() => {
expect(mockFetchCustomCollection).toHaveBeenCalledWith('test_collection')
})
await waitFor(() => {
expect(screen.getByText('Edit')).toBeInTheDocument()
})
})
it('opens edit modal and saves changes', async () => {
const collection = makeCollection({
type: CollectionType.custom,
allow_delete: true,
})
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
await waitFor(() => {
expect(screen.getByText('Edit')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('Edit'))
await waitFor(() => {
expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('custom-modal-save'))
await waitFor(() => {
expect(mockUpdateCustomCollection).toHaveBeenCalled()
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
it('shows delete confirmation and removes collection', async () => {
const collection = makeCollection({
type: CollectionType.custom,
allow_delete: true,
})
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
await waitFor(() => {
expect(screen.getByText('Edit')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('Edit'))
await waitFor(() => {
expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('custom-modal-remove'))
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
expect(screen.getByText('Delete Tool')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-ok'))
await waitFor(() => {
expect(mockRemoveCustomCollection).toHaveBeenCalledWith('test_collection')
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
})
describe('Workflow Provider', () => {
it('fetches workflow tool detail and shows "Open in Studio" and "Edit" buttons', async () => {
const collection = makeCollection({
type: CollectionType.workflow,
allow_delete: true,
})
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
await waitFor(() => {
expect(mockFetchWorkflowToolDetail).toHaveBeenCalledWith('test-collection')
})
await waitFor(() => {
expect(screen.getByText('Open in Studio')).toBeInTheDocument()
expect(screen.getByText('Edit')).toBeInTheDocument()
})
})
it('shows workflow tool parameters', async () => {
const collection = makeCollection({
type: CollectionType.workflow,
allow_delete: true,
})
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
await waitFor(() => {
expect(screen.getByText('query')).toBeInTheDocument()
expect(screen.getByText('string')).toBeInTheDocument()
expect(screen.getByText('Search query')).toBeInTheDocument()
})
})
it('deletes workflow tool through confirmation dialog', async () => {
const collection = makeCollection({
type: CollectionType.workflow,
allow_delete: true,
})
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
await waitFor(() => {
expect(screen.getByText('Edit')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('Edit'))
await waitFor(() => {
expect(screen.getByTestId('workflow-tool-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('wf-modal-remove'))
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-ok'))
await waitFor(() => {
expect(mockDeleteWorkflowTool).toHaveBeenCalledWith('test-collection')
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
})
describe('Drawer Interaction', () => {
it('calls onHide when closing the drawer', async () => {
const collection = makeCollection()
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
await waitFor(() => {
expect(screen.getByTestId('drawer')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('drawer-close'))
expect(mockOnHide).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,34 @@
import { render, screen } from '@testing-library/react'
import AnswerIcon from '.'
describe('AnswerIcon', () => {
it('renders default emoji when no icon or image is provided', () => {
const { container } = render(<AnswerIcon />)
const emojiElement = container.querySelector('em-emoji')
expect(emojiElement).toBeInTheDocument()
expect(emojiElement).toHaveAttribute('id', '🤖')
})
it('renders with custom emoji when icon is provided', () => {
const { container } = render(<AnswerIcon icon="smile" />)
const emojiElement = container.querySelector('em-emoji')
expect(emojiElement).toBeInTheDocument()
expect(emojiElement).toHaveAttribute('id', 'smile')
})
it('renders image when iconType is image and imageUrl is provided', () => {
render(<AnswerIcon iconType="image" imageUrl="test-image.jpg" />)
const imgElement = screen.getByAltText('answer icon')
expect(imgElement).toBeInTheDocument()
expect(imgElement).toHaveAttribute('src', 'test-image.jpg')
})
it('applies custom background color', () => {
const { container } = render(<AnswerIcon background="#FF5500" />)
expect(container.firstChild).toHaveStyle('background: #FF5500')
})
it('uses default background color when no background is provided for non-image icons', () => {
const { container } = render(<AnswerIcon />)
expect(container.firstChild).toHaveStyle('background: #D5F5F6')
})
})

View File

@@ -162,8 +162,10 @@ describe('useEmbeddedChatbot', () => {
await waitFor(() => {
expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', AppSourceType.webApp, 'app-1')
})
expect(result.current.pinnedConversationList).toEqual(pinnedData.data)
expect(result.current.conversationList).toEqual(listData.data)
await waitFor(() => {
expect(result.current.pinnedConversationList).toEqual(pinnedData.data)
expect(result.current.conversationList).toEqual(listData.data)
})
})
})

View File

@@ -0,0 +1,54 @@
import { fireEvent, render } from '@testing-library/react'
import CopyIcon from '.'
const copy = vi.fn()
const reset = vi.fn()
let copied = false
vi.mock('foxact/use-clipboard', () => ({
useClipboard: () => ({
copy,
reset,
copied,
}),
}))
describe('copy icon component', () => {
beforeEach(() => {
vi.resetAllMocks()
copied = false
})
it('renders normally', () => {
const { container } = render(<CopyIcon content="this is some test content for the copy icon component" />)
expect(container.querySelector('svg')).not.toBeNull()
})
it('shows copy icon initially', () => {
const { container } = render(<CopyIcon content="this is some test content for the copy icon component" />)
const icon = container.querySelector('[data-icon="Copy"]')
expect(icon).toBeInTheDocument()
})
it('shows copy check icon when copied', () => {
copied = true
const { container } = render(<CopyIcon content="this is some test content for the copy icon component" />)
const icon = container.querySelector('[data-icon="CopyCheck"]')
expect(icon).toBeInTheDocument()
})
it('handles copy when clicked', () => {
const { container } = render(<CopyIcon content="this is some test content for the copy icon component" />)
const icon = container.querySelector('[data-icon="Copy"]')
fireEvent.click(icon as Element)
expect(copy).toBeCalledTimes(1)
})
it('resets on mouse leave', () => {
const { container } = render(<CopyIcon content="this is some test content for the copy icon component" />)
const icon = container.querySelector('[data-icon="Copy"]')
const div = icon?.parentElement as HTMLElement
fireEvent.mouseLeave(div)
expect(reset).toBeCalledTimes(1)
})
})

View File

@@ -0,0 +1,16 @@
import { render, screen } from '@testing-library/react'
import CornerLabel from '.'
describe('CornerLabel', () => {
it('renders the label correctly', () => {
render(<CornerLabel label="Test Label" />)
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
it('applies custom class names', () => {
const { container } = render(<CornerLabel label="Test Label" className="custom-class" labelClassName="custom-label-class" />)
expect(container.querySelector('.custom-class')).toBeInTheDocument()
expect(container.querySelector('.custom-label-class')).toBeInTheDocument()
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,447 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import DrawerPlus from '.'
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
MediaType: { mobile: 'mobile', desktop: 'desktop', tablet: 'tablet' },
}))
describe('DrawerPlus', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should not render when isShow is false', () => {
render(
<DrawerPlus
isShow={false}
onHide={() => {}}
title="Test Drawer"
body={<div>Content</div>}
/>,
)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('should render when isShow is true', () => {
const bodyContent = <div>Body Content</div>
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test Drawer"
body={bodyContent}
/>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('Test Drawer')).toBeInTheDocument()
expect(screen.getByText('Body Content')).toBeInTheDocument()
})
it('should render footer when provided', () => {
const footerContent = <div>Footer Content</div>
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test Drawer"
body={<div>Body</div>}
foot={footerContent}
/>,
)
expect(screen.getByText('Footer Content')).toBeInTheDocument()
})
it('should render JSX element as title', () => {
const titleElement = <h1 data-testid="custom-title">Custom Title</h1>
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title={titleElement}
body={<div>Body</div>}
/>,
)
expect(screen.getByTestId('custom-title')).toBeInTheDocument()
})
it('should render titleDescription when provided', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test Drawer"
titleDescription="Description text"
body={<div>Body</div>}
/>,
)
expect(screen.getByText('Description text')).toBeInTheDocument()
})
it('should not render titleDescription when not provided', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test Drawer"
body={<div>Body</div>}
/>,
)
expect(screen.queryByText(/Description/)).not.toBeInTheDocument()
})
it('should render JSX element as titleDescription', () => {
const descElement = <span data-testid="custom-desc">Custom Description</span>
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
titleDescription={descElement}
body={<div>Body</div>}
/>,
)
expect(screen.getByTestId('custom-desc')).toBeInTheDocument()
})
})
describe('Props - Display Options', () => {
it('should apply default maxWidthClassName', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
/>,
)
const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg')
const outerPanel = innerPanel?.parentElement
expect(outerPanel?.className).toContain('!max-w-[640px]')
})
it('should apply custom maxWidthClassName', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
maxWidthClassName="!max-w-[800px]"
/>,
)
const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg')
const outerPanel = innerPanel?.parentElement
expect(outerPanel?.className).toContain('!max-w-[800px]')
})
it('should apply custom panelClassName', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
panelClassName="custom-panel"
/>,
)
const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg')
const outerPanel = innerPanel?.parentElement
expect(outerPanel?.className).toContain('custom-panel')
})
it('should apply custom dialogClassName', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
dialogClassName="custom-dialog"
/>,
)
const dialog = screen.getByRole('dialog')
expect(dialog.className).toContain('custom-dialog')
})
it('should apply custom contentClassName', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
contentClassName="custom-content"
/>,
)
const title = screen.getByText('Test')
const header = title.closest('.shrink-0.border-b.border-divider-subtle')
const content = header?.parentElement
expect(content?.className).toContain('custom-content')
})
it('should apply custom headerClassName', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
headerClassName="custom-header"
/>,
)
const title = screen.getByText('Test')
const header = title.closest('.shrink-0.border-b.border-divider-subtle')
expect(header?.className).toContain('custom-header')
})
it('should apply custom height', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
height="500px"
/>,
)
const title = screen.getByText('Test')
const header = title.closest('.shrink-0.border-b.border-divider-subtle')
const content = header?.parentElement
expect(content?.getAttribute('style')).toContain('height: 500px')
})
it('should use default height', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
/>,
)
const title = screen.getByText('Test')
const header = title.closest('.shrink-0.border-b.border-divider-subtle')
const content = header?.parentElement
expect(content?.getAttribute('style')).toContain('calc(100vh - 72px)')
})
})
describe('Event Handlers', () => {
it('should call onHide when close button is clicked', () => {
const handleHide = vi.fn()
render(
<DrawerPlus
isShow={true}
onHide={handleHide}
title="Test"
body={<div>Body</div>}
/>,
)
const title = screen.getByText('Test')
const headerRight = title.nextElementSibling // .flex items-center
const closeDiv = headerRight?.querySelector('.cursor-pointer') as HTMLElement
fireEvent.click(closeDiv)
expect(handleHide).toHaveBeenCalledTimes(1)
})
})
describe('Complex Content', () => {
it('should render complex JSX elements in body', () => {
const complexBody = (
<div>
<h2>Header</h2>
<p>Paragraph</p>
<button>Action Button</button>
</div>
)
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={complexBody}
/>,
)
expect(screen.getByText('Header')).toBeInTheDocument()
expect(screen.getByText('Paragraph')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Action Button' })).toBeInTheDocument()
})
it('should render complex footer', () => {
const complexFooter = (
<div className="footer-actions">
<button>Cancel</button>
<button>Save</button>
</div>
)
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
foot={complexFooter}
/>,
)
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty title', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title=""
body={<div>Body</div>}
/>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should handle undefined titleDescription', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
titleDescription={undefined}
body={<div>Body</div>}
/>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should handle rapid isShow toggle', () => {
const { rerender } = render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
/>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
rerender(
<DrawerPlus
isShow={false}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
/>,
)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
rerender(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
/>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should handle special characters in title', () => {
const specialTitle = 'Test <> & " \' | Drawer'
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title={specialTitle}
body={<div>Body</div>}
/>,
)
expect(screen.getByText(specialTitle)).toBeInTheDocument()
})
it('should handle empty body content', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div></div>}
/>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should apply both custom maxWidth and panel classNames', () => {
render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
maxWidthClassName="!max-w-[500px]"
panelClassName="custom-style"
/>,
)
const innerPanel = screen.getByText('Test').closest('.bg-components-panel-bg')
const outerPanel = innerPanel?.parentElement
expect(outerPanel?.className).toContain('!max-w-[500px]')
expect(outerPanel?.className).toContain('custom-style')
})
})
describe('Memoization', () => {
it('should be memoized and not re-render on parent changes', () => {
const { rerender } = render(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
/>,
)
const dialog = screen.getByRole('dialog')
rerender(
<DrawerPlus
isShow={true}
onHide={() => {}}
title="Test"
body={<div>Body</div>}
/>,
)
expect(dialog).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,225 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import Dropdown from './index'
describe('Dropdown Component', () => {
const mockItems = [
{ value: 'option1', text: 'Option 1' },
{ value: 'option2', text: 'Option 2' },
]
const mockSecondItems = [
{ value: 'option3', text: 'Option 3' },
]
const onSelect = vi.fn()
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('renders default trigger properly', () => {
const { container } = render(
<Dropdown items={mockItems} onSelect={onSelect} />,
)
const trigger = container.querySelector('button')
expect(trigger).toBeInTheDocument()
})
it('renders custom trigger when provided', () => {
render(
<Dropdown
items={mockItems}
onSelect={onSelect}
renderTrigger={open => <button data-testid="custom-trigger">{open ? 'Open' : 'Closed'}</button>}
/>,
)
const trigger = screen.getByTestId('custom-trigger')
expect(trigger).toBeInTheDocument()
expect(trigger).toHaveTextContent('Closed')
})
it('opens dropdown menu on trigger click and shows items', async () => {
render(
<Dropdown items={mockItems} onSelect={onSelect} />,
)
const trigger = screen.getByRole('button')
await act(async () => {
fireEvent.click(trigger)
})
// Dropdown items are rendered in a portal (document.body)
expect(screen.getByText('Option 1')).toBeInTheDocument()
expect(screen.getByText('Option 2')).toBeInTheDocument()
})
it('calls onSelect and closes dropdown when an item is clicked', async () => {
render(
<Dropdown items={mockItems} onSelect={onSelect} />,
)
const trigger = screen.getByRole('button')
await act(async () => {
fireEvent.click(trigger)
})
const option1 = screen.getByText('Option 1')
await act(async () => {
fireEvent.click(option1)
})
expect(onSelect).toHaveBeenCalledWith(mockItems[0])
expect(screen.queryByText('Option 1')).not.toBeInTheDocument()
})
it('calls onSelect and closes dropdown when a second item is clicked', async () => {
render(
<Dropdown items={mockItems} secondItems={mockSecondItems} onSelect={onSelect} />,
)
await act(async () => {
fireEvent.click(screen.getByRole('button'))
})
const option3 = screen.getByText('Option 3')
await act(async () => {
fireEvent.click(option3)
})
expect(onSelect).toHaveBeenCalledWith(mockSecondItems[0])
expect(screen.queryByText('Option 3')).not.toBeInTheDocument()
})
it('renders second items and divider when provided', async () => {
render(
<Dropdown
items={mockItems}
secondItems={mockSecondItems}
onSelect={onSelect}
/>,
)
const trigger = screen.getByRole('button')
await act(async () => {
fireEvent.click(trigger)
})
expect(screen.getByText('Option 1')).toBeInTheDocument()
expect(screen.getByText('Option 3')).toBeInTheDocument()
// Check for divider (h-px bg-divider-regular)
const divider = document.body.querySelector('.bg-divider-regular.h-px')
expect(divider).toBeInTheDocument()
})
it('applies custom classNames', async () => {
const popupClass = 'custom-popup'
const itemClass = 'custom-item'
const secondItemClass = 'custom-second-item'
render(
<Dropdown
items={mockItems}
secondItems={mockSecondItems}
onSelect={onSelect}
popupClassName={popupClass}
itemClassName={itemClass}
secondItemClassName={secondItemClass}
/>,
)
await act(async () => {
fireEvent.click(screen.getByRole('button'))
})
const popup = document.body.querySelector(`.${popupClass}`)
expect(popup).toBeInTheDocument()
const items = screen.getAllByText('Option 1')
expect(items[0]).toHaveClass(itemClass)
const secondItems = screen.getAllByText('Option 3')
expect(secondItems[0]).toHaveClass(secondItemClass)
})
it('applies open class to trigger when menu is open', async () => {
render(<Dropdown items={mockItems} onSelect={onSelect} />)
const trigger = screen.getByRole('button')
await act(async () => {
fireEvent.click(trigger)
})
expect(trigger).toHaveClass('bg-divider-regular')
})
it('handles JSX elements as item text', async () => {
const itemsWithJSX = [
{ value: 'jsx', text: <span data-testid="jsx-item">JSX Content</span> },
]
render(
<Dropdown items={itemsWithJSX} onSelect={onSelect} />,
)
await act(async () => {
fireEvent.click(screen.getByRole('button'))
})
expect(screen.getByTestId('jsx-item')).toBeInTheDocument()
expect(screen.getByText('JSX Content')).toBeInTheDocument()
})
it('does not render items section if items list is empty', async () => {
render(
<Dropdown items={[]} secondItems={mockSecondItems} onSelect={onSelect} />,
)
await act(async () => {
fireEvent.click(screen.getByRole('button'))
})
const p1Divs = document.body.querySelectorAll('.p-1')
expect(p1Divs.length).toBe(1)
expect(screen.queryByText('Option 1')).not.toBeInTheDocument()
expect(screen.getByText('Option 3')).toBeInTheDocument()
})
it('does not render divider if only one section is provided', async () => {
const { rerender } = render(
<Dropdown items={mockItems} onSelect={onSelect} />,
)
await act(async () => {
fireEvent.click(screen.getByRole('button'))
})
expect(document.body.querySelector('.bg-divider-regular.h-px')).not.toBeInTheDocument()
await act(async () => {
rerender(
<Dropdown items={[]} secondItems={mockSecondItems} onSelect={onSelect} />,
)
})
expect(document.body.querySelector('.bg-divider-regular.h-px')).not.toBeInTheDocument()
})
it('renders nothing if both item lists are empty', async () => {
render(<Dropdown items={[]} secondItems={[]} onSelect={onSelect} />)
await act(async () => {
fireEvent.click(screen.getByRole('button'))
})
const popup = document.body.querySelector('.bg-components-panel-bg')
expect(popup?.children.length).toBe(0)
})
it('passes triggerProps to ActionButton and applies custom className', () => {
render(
<Dropdown
items={mockItems}
onSelect={onSelect}
triggerProps={{
'disabled': true,
'aria-label': 'dropdown-trigger',
'className': 'custom-trigger-class',
}}
/>,
)
const trigger = screen.getByLabelText('dropdown-trigger')
expect(trigger).toBeDisabled()
expect(trigger).toHaveClass('custom-trigger-class')
})
})

View File

@@ -0,0 +1,9 @@
import { render } from '@testing-library/react'
import Effect from '.'
describe('Effect', () => {
it('applies custom class names', () => {
const { container } = render(<Effect className="custom-class" />)
expect(container.firstChild).toHaveClass('custom-class')
})
})

View File

@@ -0,0 +1,15 @@
import { render, screen } from '@testing-library/react'
import { EncryptedBottom } from '.'
describe('EncryptedBottom', () => {
it('applies custom class names', () => {
const { container } = render(<EncryptedBottom className="custom-class" />)
expect(container.firstChild).toHaveClass('custom-class')
})
it('passes keys', async () => {
render(<EncryptedBottom frontTextKey="provider.encrypted.front" backTextKey="provider.encrypted.back" />)
expect(await screen.findByText(/provider.encrypted.front/i)).toBeInTheDocument()
expect(await screen.findByText(/provider.encrypted.back/i)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,28 @@
import { render } from '@testing-library/react'
import FileIcon from '.'
describe('File icon component', () => {
const testCases = [
{ type: 'csv', icon: 'Csv' },
{ type: 'doc', icon: 'Doc' },
{ type: 'docx', icon: 'Docx' },
{ type: 'htm', icon: 'Html' },
{ type: 'html', icon: 'Html' },
{ type: 'md', icon: 'Md' },
{ type: 'mdx', icon: 'Md' },
{ type: 'markdown', icon: 'Md' },
{ type: 'pdf', icon: 'Pdf' },
{ type: 'xls', icon: 'Xlsx' },
{ type: 'xlsx', icon: 'Xlsx' },
{ type: 'notion', icon: 'Notion' },
{ type: 'something-else', icon: 'Unknown' },
{ type: 'txt', icon: 'Txt' },
{ type: 'json', icon: 'Json' },
]
it.each(testCases)('renders $icon icon for type $type', ({ type, icon }) => {
const { container } = render(<FileIcon type={type} />)
const iconElement = container.querySelector(`[data-icon="${icon}"]`)
expect(iconElement).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react'
import NodeStatus, { NodeStatusEnum } from '.'
describe('NodeStatus', () => {
it('renders with default status (warning) and default message', () => {
const { container } = render(<NodeStatus />)
expect(screen.getByText('Warning')).toBeInTheDocument()
// Default warning class
expect(container.firstChild).toHaveClass('bg-state-warning-hover')
expect(container.firstChild).toHaveClass('text-text-warning')
})
it('renders with error status and default message', () => {
const { container } = render(<NodeStatus status={NodeStatusEnum.error} />)
expect(screen.getByText('Error')).toBeInTheDocument()
expect(container.firstChild).toHaveClass('bg-state-destructive-hover')
expect(container.firstChild).toHaveClass('text-text-destructive')
})
it('renders with custom message', () => {
render(<NodeStatus message="Custom Message" />)
expect(screen.getByText('Custom Message')).toBeInTheDocument()
})
it('renders children correctly', () => {
render(
<NodeStatus>
<span data-testid="child">Child Element</span>
</NodeStatus>,
)
expect(screen.getByTestId('child')).toBeInTheDocument()
expect(screen.getByText('Child Element')).toBeInTheDocument()
})
it('applies custom className', () => {
const { container } = render(<NodeStatus className="custom-test-class" />)
expect(container.firstChild).toHaveClass('custom-test-class')
})
it('applies styleCss correctly', () => {
const { container } = render(<NodeStatus styleCss={{ color: 'red' }} />)
expect(container.firstChild).toHaveStyle({ color: 'rgb(255, 0, 0)' })
})
it('applies iconClassName to the icon', () => {
const { container } = render(<NodeStatus iconClassName="custom-icon-class" />)
// The icon is the first child of the div
const icon = container.querySelector('.custom-icon-class')
expect(icon).toBeInTheDocument()
expect(icon).toHaveClass('h-3.5')
expect(icon).toHaveClass('w-3.5')
})
it('passes additional HTML attributes to the container', () => {
render(<NodeStatus data-testid="node-status-container" id="my-id" />)
const container = screen.getByTestId('node-status-container')
expect(container).toHaveAttribute('id', 'my-id')
})
})

View File

@@ -0,0 +1,49 @@
import { render, screen } from '@testing-library/react'
import NotionIcon from '.'
describe('Notion Icon', () => {
it('applies custom class names', () => {
const { container } = render(<NotionIcon className="custom-class" />)
expect(container.firstChild).toHaveClass('custom-class')
})
it('renders image on http url', () => {
render(<NotionIcon src="http://example.com/image.png" />)
expect(screen.getByAltText('workspace icon')).toHaveAttribute('src', 'http://example.com/image.png')
})
it('renders image on https url', () => {
render(<NotionIcon src="https://example.com/image.png" />)
expect(screen.getByAltText('workspace icon')).toHaveAttribute('src', 'https://example.com/image.png')
})
it('renders div on non-http url', () => {
render(<NotionIcon src="example.com/image.png" />)
expect(screen.getByText('example.com/image.png')).toBeInTheDocument()
})
it('renders name when no url is provided', () => {
render(<NotionIcon name="test-name" />)
expect(screen.getByText('T')).toBeInTheDocument()
})
it('renders image on type url for page', () => {
render(<NotionIcon type="page" src={{ type: 'url', url: 'https://example.com/image.png', emoji: null }} />)
expect(screen.getByAltText('page icon')).toHaveAttribute('src', 'https://example.com/image.png')
})
it('renders blank image on type url if no url is passed for page', () => {
render(<NotionIcon type="page" src={{ type: 'url', url: null, emoji: null }} />)
expect(screen.getByAltText('page icon')).not.toHaveAttribute('src')
})
it('renders emoji on type emoji for page', () => {
render(<NotionIcon type="page" src={{ type: 'emoji', url: null, emoji: '🚀' }} />)
expect(screen.getByText('🚀')).toBeInTheDocument()
})
it('renders icon on url for page', () => {
const { container } = render(<NotionIcon type="page" src="https://example.com/image.png" />)
expect(container.querySelector('svg')).not.toBeNull()
})
})

View File

@@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react'
import PremiumBadge from './index'
describe('PremiumBadge', () => {
it('renders with default props', () => {
render(<PremiumBadge>Premium</PremiumBadge>)
const badge = screen.getByText('Premium')
expect(badge).toBeInTheDocument()
expect(badge).toHaveClass('premium-badge-m')
expect(badge).toHaveClass('premium-badge-blue')
})
it('renders with custom size and color', () => {
render(
<PremiumBadge size="s" color="indigo">
Premium
</PremiumBadge>,
)
const badge = screen.getByText('Premium')
expect(badge).toBeInTheDocument()
expect(badge).toHaveClass('premium-badge-s')
expect(badge).toHaveClass('premium-badge-indigo')
})
it('applies allowHover class when allowHover is true', () => {
render(
<PremiumBadge allowHover>
Premium
</PremiumBadge>,
)
const badge = screen.getByText('Premium')
expect(badge).toBeInTheDocument()
expect(badge).toHaveClass('allowHover')
})
it('applies custom styles', () => {
render(
<PremiumBadge styleCss={{ backgroundColor: 'red' }}>
Premium
</PremiumBadge>,
)
const badge = screen.getByText('Premium')
expect(badge).toBeInTheDocument()
expect(badge).toHaveStyle('background-color: rgb(255, 0, 0)') // Note: React converts 'red' to 'rgb(255, 0, 0)'
})
})

View File

@@ -0,0 +1,141 @@
import { ALL_PLANS, contactSalesUrl, contractSales, defaultPlan, getStartedWithCommunityUrl, getWithPremiumUrl, NUM_INFINITE, unAvailable } from '../config'
import { Priority } from '../type'
describe('Billing Config', () => {
describe('Constants', () => {
it('should define NUM_INFINITE as -1', () => {
expect(NUM_INFINITE).toBe(-1)
})
it('should define contractSales string', () => {
expect(contractSales).toBe('contractSales')
})
it('should define unAvailable string', () => {
expect(unAvailable).toBe('unAvailable')
})
it('should define valid URL constants', () => {
expect(contactSalesUrl).toMatch(/^https:\/\//)
expect(getStartedWithCommunityUrl).toMatch(/^https:\/\//)
expect(getWithPremiumUrl).toMatch(/^https:\/\//)
})
})
describe('ALL_PLANS', () => {
const requiredFields: (keyof typeof ALL_PLANS.sandbox)[] = [
'level',
'price',
'modelProviders',
'teamWorkspace',
'teamMembers',
'buildApps',
'documents',
'vectorSpace',
'documentsUploadQuota',
'documentsRequestQuota',
'apiRateLimit',
'documentProcessingPriority',
'messageRequest',
'triggerEvents',
'annotatedResponse',
'logHistory',
]
it.each(['sandbox', 'professional', 'team'] as const)('should have all required fields for %s plan', (planKey) => {
const plan = ALL_PLANS[planKey]
for (const field of requiredFields)
expect(plan[field]).toBeDefined()
})
it('should have ascending plan levels: sandbox < professional < team', () => {
expect(ALL_PLANS.sandbox.level).toBeLessThan(ALL_PLANS.professional.level)
expect(ALL_PLANS.professional.level).toBeLessThan(ALL_PLANS.team.level)
})
it('should have ascending plan prices: sandbox < professional < team', () => {
expect(ALL_PLANS.sandbox.price).toBeLessThan(ALL_PLANS.professional.price)
expect(ALL_PLANS.professional.price).toBeLessThan(ALL_PLANS.team.price)
})
it('should have sandbox as the free plan', () => {
expect(ALL_PLANS.sandbox.price).toBe(0)
})
it('should have ascending team member limits', () => {
expect(ALL_PLANS.sandbox.teamMembers).toBeLessThan(ALL_PLANS.professional.teamMembers)
expect(ALL_PLANS.professional.teamMembers).toBeLessThan(ALL_PLANS.team.teamMembers)
})
it('should have ascending document processing priority', () => {
expect(ALL_PLANS.sandbox.documentProcessingPriority).toBe(Priority.standard)
expect(ALL_PLANS.professional.documentProcessingPriority).toBe(Priority.priority)
expect(ALL_PLANS.team.documentProcessingPriority).toBe(Priority.topPriority)
})
it('should have unlimited API rate limit for professional and team plans', () => {
expect(ALL_PLANS.sandbox.apiRateLimit).not.toBe(NUM_INFINITE)
expect(ALL_PLANS.professional.apiRateLimit).toBe(NUM_INFINITE)
expect(ALL_PLANS.team.apiRateLimit).toBe(NUM_INFINITE)
})
it('should have unlimited log history for professional and team plans', () => {
expect(ALL_PLANS.professional.logHistory).toBe(NUM_INFINITE)
expect(ALL_PLANS.team.logHistory).toBe(NUM_INFINITE)
})
it('should have unlimited trigger events only for team plan', () => {
expect(ALL_PLANS.sandbox.triggerEvents).not.toBe(NUM_INFINITE)
expect(ALL_PLANS.professional.triggerEvents).not.toBe(NUM_INFINITE)
expect(ALL_PLANS.team.triggerEvents).toBe(NUM_INFINITE)
})
})
describe('defaultPlan', () => {
it('should default to sandbox plan type', () => {
expect(defaultPlan.type).toBe('sandbox')
})
it('should have usage object with all required fields', () => {
const { usage } = defaultPlan
expect(usage).toHaveProperty('documents')
expect(usage).toHaveProperty('vectorSpace')
expect(usage).toHaveProperty('buildApps')
expect(usage).toHaveProperty('teamMembers')
expect(usage).toHaveProperty('annotatedResponse')
expect(usage).toHaveProperty('documentsUploadQuota')
expect(usage).toHaveProperty('apiRateLimit')
expect(usage).toHaveProperty('triggerEvents')
})
it('should have total object with all required fields', () => {
const { total } = defaultPlan
expect(total).toHaveProperty('documents')
expect(total).toHaveProperty('vectorSpace')
expect(total).toHaveProperty('buildApps')
expect(total).toHaveProperty('teamMembers')
expect(total).toHaveProperty('annotatedResponse')
expect(total).toHaveProperty('documentsUploadQuota')
expect(total).toHaveProperty('apiRateLimit')
expect(total).toHaveProperty('triggerEvents')
})
it('should use sandbox plan API rate limit and trigger events in total', () => {
expect(defaultPlan.total.apiRateLimit).toBe(ALL_PLANS.sandbox.apiRateLimit)
expect(defaultPlan.total.triggerEvents).toBe(ALL_PLANS.sandbox.triggerEvents)
})
it('should have reset info with null values', () => {
expect(defaultPlan.reset.apiRateLimit).toBeNull()
expect(defaultPlan.reset.triggerEvents).toBeNull()
})
it('should have usage values not exceeding totals', () => {
expect(defaultPlan.usage.documents).toBeLessThanOrEqual(defaultPlan.total.documents)
expect(defaultPlan.usage.vectorSpace).toBeLessThanOrEqual(defaultPlan.total.vectorSpace)
expect(defaultPlan.usage.buildApps).toBeLessThanOrEqual(defaultPlan.total.buildApps)
expect(defaultPlan.usage.teamMembers).toBeLessThanOrEqual(defaultPlan.total.teamMembers)
expect(defaultPlan.usage.annotatedResponse).toBeLessThanOrEqual(defaultPlan.total.annotatedResponse)
})
})
})

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react'
import AnnotationFull from './index'
import AnnotationFull from '../index'
vi.mock('./usage', () => ({
vi.mock('../usage', () => ({
default: (props: { className?: string }) => {
return (
<div data-testid="usage-component" data-classname={props.className ?? ''}>
@@ -11,7 +11,7 @@ vi.mock('./usage', () => ({
},
}))
vi.mock('../upgrade-btn', () => ({
vi.mock('../../upgrade-btn', () => ({
default: (props: { loc?: string }) => {
return (
<button type="button" data-testid="upgrade-btn">
@@ -29,27 +29,21 @@ describe('AnnotationFull', () => {
// Rendering marketing copy with action button
describe('Rendering', () => {
it('should render tips when rendered', () => {
// Act
render(<AnnotationFull />)
// Assert
expect(screen.getByText('billing.annotatedResponse.fullTipLine1')).toBeInTheDocument()
expect(screen.getByText('billing.annotatedResponse.fullTipLine2')).toBeInTheDocument()
})
it('should render upgrade button when rendered', () => {
// Act
render(<AnnotationFull />)
// Assert
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
})
it('should render Usage component when rendered', () => {
// Act
render(<AnnotationFull />)
// Assert
const usageComponent = screen.getByTestId('usage-component')
expect(usageComponent).toBeInTheDocument()
})

View File

@@ -1,7 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react'
import AnnotationFullModal from './modal'
import AnnotationFullModal from '../modal'
vi.mock('./usage', () => ({
vi.mock('../usage', () => ({
default: (props: { className?: string }) => {
return (
<div data-testid="usage-component" data-classname={props.className ?? ''}>
@@ -12,7 +12,7 @@ vi.mock('./usage', () => ({
}))
let mockUpgradeBtnProps: { loc?: string } | null = null
vi.mock('../upgrade-btn', () => ({
vi.mock('../../upgrade-btn', () => ({
default: (props: { loc?: string }) => {
mockUpgradeBtnProps = props
return (
@@ -29,7 +29,7 @@ type ModalSnapshot = {
className?: string
}
let mockModalProps: ModalSnapshot | null = null
vi.mock('../../base/modal', () => ({
vi.mock('../../../base/modal', () => ({
default: ({ isShow, children, onClose, closable, className }: { isShow: boolean, children: React.ReactNode, onClose: () => void, closable?: boolean, className?: string }) => {
mockModalProps = {
isShow,
@@ -61,10 +61,8 @@ describe('AnnotationFullModal', () => {
// Rendering marketing copy inside modal
describe('Rendering', () => {
it('should display main info when visible', () => {
// Act
render(<AnnotationFullModal show onHide={vi.fn()} />)
// Assert
expect(screen.getByText('billing.annotatedResponse.fullTipLine1')).toBeInTheDocument()
expect(screen.getByText('billing.annotatedResponse.fullTipLine2')).toBeInTheDocument()
expect(screen.getByTestId('usage-component')).toHaveAttribute('data-classname', 'mt-4')
@@ -81,10 +79,8 @@ describe('AnnotationFullModal', () => {
// Controlling modal visibility
describe('Visibility', () => {
it('should not render content when hidden', () => {
// Act
const { container } = render(<AnnotationFullModal show={false} onHide={vi.fn()} />)
// Assert
expect(container).toBeEmptyDOMElement()
expect(mockModalProps).toEqual(expect.objectContaining({ isShow: false }))
})
@@ -93,14 +89,11 @@ describe('AnnotationFullModal', () => {
// Handling close interactions
describe('Close handling', () => {
it('should trigger onHide when close control is clicked', () => {
// Arrange
const onHide = vi.fn()
// Act
render(<AnnotationFullModal show onHide={onHide} />)
fireEvent.click(screen.getByTestId('mock-modal-close'))
// Assert
expect(onHide).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,11 +1,5 @@
import { render, screen } from '@testing-library/react'
import Usage from './usage'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
import Usage from '../usage'
const mockPlan = {
usage: {
@@ -23,33 +17,25 @@ vi.mock('@/context/provider-context', () => ({
}))
describe('Usage', () => {
// Rendering: renders UsageInfo with correct props from context
describe('Rendering', () => {
it('should render usage info with data from provider context', () => {
// Arrange & Act
render(<Usage />)
// Assert
expect(screen.getByText('annotatedResponse.quotaTitle')).toBeInTheDocument()
expect(screen.getByText('billing.annotatedResponse.quotaTitle')).toBeInTheDocument()
})
it('should pass className to UsageInfo component', () => {
// Arrange
const testClassName = 'mt-4'
// Act
const { container } = render(<Usage className={testClassName} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass(testClassName)
})
it('should display usage and total values from context', () => {
// Arrange & Act
render(<Usage />)
// Assert
expect(screen.getByText('50')).toBeInTheDocument()
expect(screen.getByText('100')).toBeInTheDocument()
})

View File

@@ -8,7 +8,7 @@ import { Plan } from '@/app/components/billing/type'
import { mailToSupport } from '@/app/components/header/utils/util'
import { useAppContext } from '@/context/app-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import AppsFull from './index'
import AppsFull from '../index'
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
@@ -96,8 +96,10 @@ const buildAppContext = (overrides: Partial<AppContextValue> = {}): AppContextVa
isCurrentWorkspaceEditor: false,
isCurrentWorkspaceDatasetOperator: false,
mutateUserProfile: vi.fn(),
mutateCurrentWorkspace: vi.fn(),
langGeniusVersionInfo,
isLoadingCurrentWorkspace: false,
isValidatingCurrentWorkspace: false,
}
const useSelector: AppContextValue['useSelector'] = selector => selector({ ...base, useSelector })
return {
@@ -118,10 +120,8 @@ describe('AppsFull', () => {
// Rendering behavior for non-team plans.
describe('Rendering', () => {
it('should render the sandbox messaging and upgrade button', () => {
// Act
render(<AppsFull loc="billing_dialog" />)
// Assert
expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument()
expect(screen.getByText('billing.apps.fullTip1des')).toBeInTheDocument()
expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument()
@@ -129,10 +129,8 @@ describe('AppsFull', () => {
})
})
// Prop-driven behavior for team plans and contact CTA.
describe('Props', () => {
it('should render team messaging and contact button for non-sandbox plans', () => {
// Arrange
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
@@ -147,7 +145,6 @@ describe('AppsFull', () => {
}))
render(<AppsFull loc="billing_dialog" />)
// Assert
expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument()
expect(screen.getByText('billing.apps.fullTip2des')).toBeInTheDocument()
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
@@ -156,7 +153,6 @@ describe('AppsFull', () => {
})
it('should render upgrade button for professional plans', () => {
// Arrange
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
@@ -170,17 +166,14 @@ describe('AppsFull', () => {
},
}))
// Act
render(<AppsFull loc="billing_dialog" />)
// Assert
expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument()
expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument()
expect(screen.queryByText('billing.apps.contactUs')).not.toBeInTheDocument()
})
it('should render contact button for enterprise plans', () => {
// Arrange
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
@@ -194,10 +187,8 @@ describe('AppsFull', () => {
},
}))
// Act
render(<AppsFull loc="billing_dialog" />)
// Assert
expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument()
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'billing.apps.contactUs' })).toHaveAttribute('href', 'mailto:support@example.com')
@@ -205,72 +196,31 @@ describe('AppsFull', () => {
})
})
// Edge cases for progress color thresholds.
describe('Edge Cases', () => {
it('should use the success color when usage is below 50%', () => {
// Arrange
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
usage: buildUsage({ buildApps: 2 }),
total: buildUsage({ buildApps: 5 }),
reset: {
apiRateLimit: null,
triggerEvents: null,
it('should apply distinct progress bar styling at different usage levels', () => {
const renderWithUsage = (used: number, total: number) => {
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
usage: buildUsage({ buildApps: used }),
total: buildUsage({ buildApps: total }),
reset: { apiRateLimit: null, triggerEvents: null },
},
},
}))
}))
const { unmount } = render(<AppsFull loc="billing_dialog" />)
const className = screen.getByTestId('billing-progress-bar').className
unmount()
return className
}
// Act
render(<AppsFull loc="billing_dialog" />)
const normalClass = renderWithUsage(2, 10)
const warningClass = renderWithUsage(6, 10)
const errorClass = renderWithUsage(8, 10)
// Assert
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-bar-progress-solid')
})
it('should use the warning color when usage is between 50% and 80%', () => {
// Arrange
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
usage: buildUsage({ buildApps: 6 }),
total: buildUsage({ buildApps: 10 }),
reset: {
apiRateLimit: null,
triggerEvents: null,
},
},
}))
// Act
render(<AppsFull loc="billing_dialog" />)
// Assert
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-warning-progress')
})
it('should use the error color when usage is 80% or higher', () => {
// Arrange
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
usage: buildUsage({ buildApps: 8 }),
total: buildUsage({ buildApps: 10 }),
reset: {
apiRateLimit: null,
triggerEvents: null,
},
},
}))
// Act
render(<AppsFull loc="billing_dialog" />)
// Assert
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-error-progress')
expect(normalClass).not.toBe(warningClass)
expect(warningClass).not.toBe(errorClass)
expect(normalClass).not.toBe(errorClass)
})
})
})

View File

@@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import Billing from './index'
import Billing from '../index'
let currentBillingUrl: string | null = 'https://billing'
let fetching = false
@@ -33,7 +33,7 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
vi.mock('../plan', () => ({
vi.mock('../../plan', () => ({
default: ({ loc }: { loc: string }) => <div data-testid="plan-component" data-loc={loc} />,
}))

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { Plan } from '../type'
import HeaderBillingBtn from './index'
import { Plan } from '../../type'
import HeaderBillingBtn from '../index'
type HeaderGlobal = typeof globalThis & {
__mockProviderContext?: ReturnType<typeof vi.fn>
@@ -26,7 +26,7 @@ vi.mock('@/context/provider-context', () => {
}
})
vi.mock('../upgrade-btn', () => ({
vi.mock('../../upgrade-btn', () => ({
default: () => <button data-testid="upgrade-btn" type="button">Upgrade</button>,
}))
@@ -70,21 +70,50 @@ describe('HeaderBillingBtn', () => {
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
})
it('renders team badge for team plan', () => {
ensureProviderContextMock().mockReturnValueOnce({
plan: { type: Plan.team },
enableBilling: true,
isFetchedPlan: true,
})
render(<HeaderBillingBtn />)
expect(screen.getByText('team')).toBeInTheDocument()
})
it('renders nothing when plan is not fetched', () => {
ensureProviderContextMock().mockReturnValueOnce({
plan: { type: Plan.professional },
enableBilling: true,
isFetchedPlan: false,
})
const { container } = render(<HeaderBillingBtn />)
expect(container.firstChild).toBeNull()
})
it('renders sandbox upgrade btn with undefined onClick in display-only mode', () => {
ensureProviderContextMock().mockReturnValueOnce({
plan: { type: Plan.sandbox },
enableBilling: true,
isFetchedPlan: true,
})
render(<HeaderBillingBtn isDisplayOnly />)
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
})
it('renders plan badge and forwards clicks when not display-only', () => {
const onClick = vi.fn()
const { rerender } = render(<HeaderBillingBtn onClick={onClick} />)
const badge = screen.getByText('pro').closest('div')
expect(badge).toHaveClass('cursor-pointer')
fireEvent.click(badge!)
const badge = screen.getByText('pro').closest('div')!
fireEvent.click(badge)
expect(onClick).toHaveBeenCalledTimes(1)
rerender(<HeaderBillingBtn onClick={onClick} isDisplayOnly />)
expect(screen.getByText('pro').closest('div')).toHaveClass('cursor-default')
fireEvent.click(screen.getByText('pro').closest('div')!)
expect(onClick).toHaveBeenCalledTimes(1)
})

View File

@@ -1,5 +1,5 @@
import { render } from '@testing-library/react'
import PartnerStack from './index'
import PartnerStack from '../index'
let isCloudEdition = true
@@ -12,7 +12,7 @@ vi.mock('@/config', () => ({
},
}))
vi.mock('./use-ps-info', () => ({
vi.mock('../use-ps-info', () => ({
default: () => ({
saveOrUpdate,
bind,
@@ -40,4 +40,23 @@ describe('PartnerStack', () => {
expect(saveOrUpdate).toHaveBeenCalledTimes(1)
expect(bind).toHaveBeenCalledTimes(1)
})
it('renders null (no visible DOM)', () => {
const { container } = render(<PartnerStack />)
expect(container.innerHTML).toBe('')
})
it('does not call helpers again on rerender', () => {
const { rerender } = render(<PartnerStack />)
expect(saveOrUpdate).toHaveBeenCalledTimes(1)
expect(bind).toHaveBeenCalledTimes(1)
rerender(<PartnerStack />)
// useEffect with [] should not run again on rerender
expect(saveOrUpdate).toHaveBeenCalledTimes(1)
expect(bind).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,6 +1,6 @@
import { act, renderHook } from '@testing-library/react'
import { PARTNER_STACK_CONFIG } from '@/config'
import usePSInfo from './use-ps-info'
import usePSInfo from '../use-ps-info'
let searchParamsValues: Record<string, string | null> = {}
const setSearchParams = (values: Record<string, string | null>) => {
@@ -193,4 +193,107 @@ describe('usePSInfo', () => {
domain: '.dify.ai',
})
})
// Cookie parse failure: covers catch block (L14-16)
it('should fall back to empty object when cookie contains invalid JSON', () => {
const { get } = ensureCookieMocks()
get.mockReturnValue('not-valid-json{{{')
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
setSearchParams({
ps_partner_key: 'from-url',
ps_xid: 'click-url',
})
const { result } = renderHook(() => usePSInfo())
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to parse partner stack info from cookie:',
expect.any(SyntaxError),
)
// Should still pick up values from search params
expect(result.current.psPartnerKey).toBe('from-url')
expect(result.current.psClickId).toBe('click-url')
consoleSpy.mockRestore()
})
// No keys at all: covers saveOrUpdate early return (L30) and bind no-op (L45 false branch)
it('should not save or bind when neither search params nor cookie have keys', () => {
const { get, set } = ensureCookieMocks()
get.mockReturnValue('{}')
setSearchParams({})
const { result } = renderHook(() => usePSInfo())
expect(result.current.psPartnerKey).toBeUndefined()
expect(result.current.psClickId).toBeUndefined()
act(() => {
result.current.saveOrUpdate()
})
expect(set).not.toHaveBeenCalled()
})
it('should not call mutateAsync when keys are missing during bind', async () => {
const { get } = ensureCookieMocks()
get.mockReturnValue('{}')
setSearchParams({})
const { result } = renderHook(() => usePSInfo())
const mutate = ensureMutateAsync()
await act(async () => {
await result.current.bind()
})
expect(mutate).not.toHaveBeenCalled()
})
// Non-400 error: covers L55 false branch (shouldRemoveCookie stays false)
it('should not remove cookie when bind fails with non-400 error', async () => {
const mutate = ensureMutateAsync()
mutate.mockRejectedValueOnce({ status: 500 })
setSearchParams({
ps_partner_key: 'bind-partner',
ps_xid: 'bind-click',
})
const { result } = renderHook(() => usePSInfo())
await act(async () => {
await result.current.bind()
})
const { remove } = ensureCookieMocks()
expect(remove).not.toHaveBeenCalled()
})
// Fallback to cookie values: covers L19-20 right side of || operator
it('should use cookie values when search params are absent', () => {
const { get } = ensureCookieMocks()
get.mockReturnValue(JSON.stringify({
partnerKey: 'cookie-partner',
clickId: 'cookie-click',
}))
setSearchParams({})
const { result } = renderHook(() => usePSInfo())
expect(result.current.psPartnerKey).toBe('cookie-partner')
expect(result.current.psClickId).toBe('cookie-click')
})
// Partial key missing: only partnerKey present, no clickId
it('should not save when only one key is available', () => {
const { get, set } = ensureCookieMocks()
get.mockReturnValue('{}')
setSearchParams({ ps_partner_key: 'partial-key' })
const { result } = renderHook(() => usePSInfo())
act(() => {
result.current.saveOrUpdate()
})
expect(set).not.toHaveBeenCalled()
})
})

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import PlanUpgradeModal from './index'
import PlanUpgradeModal from '../index'
const mockSetShowPricingModal = vi.fn()
@@ -39,13 +39,11 @@ describe('PlanUpgradeModal', () => {
// Rendering and props-driven content
it('should render modal with provided content when visible', () => {
// Arrange
const extraInfoText = 'Additional upgrade details'
renderComponent({
extraInfo: <div>{extraInfoText}</div>,
})
// Assert
expect(screen.getByText(baseProps.title)).toBeInTheDocument()
expect(screen.getByText(baseProps.description)).toBeInTheDocument()
expect(screen.getByText(extraInfoText)).toBeInTheDocument()
@@ -55,40 +53,32 @@ describe('PlanUpgradeModal', () => {
// Guard against rendering when modal is hidden
it('should not render content when show is false', () => {
// Act
renderComponent({ show: false })
// Assert
expect(screen.queryByText(baseProps.title)).not.toBeInTheDocument()
expect(screen.queryByText(baseProps.description)).not.toBeInTheDocument()
})
// User closes the modal from dismiss button
it('should call onClose when dismiss button is clicked', async () => {
// Arrange
const user = userEvent.setup()
const onClose = vi.fn()
renderComponent({ onClose })
// Act
await user.click(screen.getByText('billing.triggerLimitModal.dismiss'))
// Assert
expect(onClose).toHaveBeenCalledTimes(1)
})
// Upgrade path uses provided callback over pricing modal
it('should call onUpgrade and onClose when upgrade button is clicked with onUpgrade provided', async () => {
// Arrange
const user = userEvent.setup()
const onClose = vi.fn()
const onUpgrade = vi.fn()
renderComponent({ onClose, onUpgrade })
// Act
await user.click(screen.getByText('billing.triggerLimitModal.upgrade'))
// Assert
expect(onClose).toHaveBeenCalledTimes(1)
expect(onUpgrade).toHaveBeenCalledTimes(1)
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
@@ -96,15 +86,12 @@ describe('PlanUpgradeModal', () => {
// Fallback upgrade path opens pricing modal when no onUpgrade is supplied
it('should open pricing modal when upgrade button is clicked without onUpgrade', async () => {
// Arrange
const user = userEvent.setup()
const onClose = vi.fn()
renderComponent({ onClose, onUpgrade: undefined })
// Act
await user.click(screen.getByText('billing.triggerLimitModal.upgrade'))
// Assert
expect(onClose).toHaveBeenCalledTimes(1)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})

View File

@@ -1,7 +1,7 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
import { Plan } from '../type'
import PlanComp from './index'
import { Plan, SelfHostedPlan } from '../../type'
import PlanComp from '../index'
let currentPath = '/billing'
@@ -14,8 +14,7 @@ vi.mock('next/navigation', () => ({
const setShowAccountSettingModalMock = vi.fn()
vi.mock('@/context/modal-context', () => ({
// eslint-disable-next-line ts/no-explicit-any
useModalContextSelector: (selector: any) => selector({
useModalContextSelector: (selector: (state: { setShowAccountSettingModal: typeof setShowAccountSettingModalMock }) => unknown) => selector({
setShowAccountSettingModal: setShowAccountSettingModalMock,
}),
}))
@@ -47,11 +46,10 @@ const verifyStateModalMock = vi.fn(props => (
</div>
))
vi.mock('@/app/education-apply/verify-state-modal', () => ({
// eslint-disable-next-line ts/no-explicit-any
default: (props: any) => verifyStateModalMock(props),
default: (props: { isShow: boolean, title?: string, content?: string, email?: string, showLink?: boolean, onConfirm?: () => void, onCancel?: () => void }) => verifyStateModalMock(props),
}))
vi.mock('../upgrade-btn', () => ({
vi.mock('../../upgrade-btn', () => ({
default: () => <button data-testid="plan-upgrade-btn" type="button">Upgrade</button>,
}))
@@ -172,6 +170,66 @@ describe('PlanComp', () => {
expect(screen.getByText('education.toVerified')).toBeInTheDocument()
})
it('renders enterprise plan without upgrade button', () => {
providerContextMock.mockReturnValue({
plan: { ...planMock, type: SelfHostedPlan.enterprise },
enableEducationPlan: false,
allowRefreshEducationVerify: false,
isEducationAccount: false,
})
render(<PlanComp loc="billing-page" />)
expect(screen.getByText('billing.plans.enterprise.name')).toBeInTheDocument()
expect(screen.queryByTestId('plan-upgrade-btn')).not.toBeInTheDocument()
})
it('shows apiRateLimit reset info for sandbox plan', () => {
providerContextMock.mockReturnValue({
plan: {
...planMock,
type: Plan.sandbox,
total: { ...planMock.total, apiRateLimit: 5000 },
reset: { ...planMock.reset, apiRateLimit: null },
},
enableEducationPlan: false,
allowRefreshEducationVerify: false,
isEducationAccount: false,
})
render(<PlanComp loc="billing-page" />)
// Sandbox plan with finite apiRateLimit and null reset uses getDaysUntilEndOfMonth()
expect(screen.getByText('billing.plans.sandbox.name')).toBeInTheDocument()
})
it('shows apiRateLimit reset info when reset is a number', () => {
providerContextMock.mockReturnValue({
plan: {
...planMock,
type: Plan.professional,
total: { ...planMock.total, apiRateLimit: 5000 },
reset: { ...planMock.reset, apiRateLimit: 3 },
},
enableEducationPlan: false,
allowRefreshEducationVerify: false,
isEducationAccount: false,
})
render(<PlanComp loc="billing-page" />)
expect(screen.getByText('billing.plans.professional.name')).toBeInTheDocument()
})
it('does not show education verify when enableEducationPlan is false', () => {
providerContextMock.mockReturnValue({
plan: planMock,
enableEducationPlan: false,
allowRefreshEducationVerify: false,
isEducationAccount: false,
})
render(<PlanComp loc="billing-page" />)
expect(screen.queryByText('education.toVerified')).not.toBeInTheDocument()
})
it('handles modal onConfirm and onCancel callbacks', async () => {
mutateAsyncMock.mockRejectedValueOnce(new Error('boom'))
render(<PlanComp loc="billing-page" />)

View File

@@ -1,5 +1,5 @@
import { render } from '@testing-library/react'
import Enterprise from './enterprise'
import Enterprise from '../enterprise'
describe('Enterprise Icon Component', () => {
describe('Rendering', () => {

View File

@@ -1,11 +1,11 @@
import { render } from '@testing-library/react'
import EnterpriseDirect from './enterprise'
import EnterpriseDirect from '../enterprise'
import { Enterprise, Professional, Sandbox, Team } from './index'
import ProfessionalDirect from './professional'
import { Enterprise, Professional, Sandbox, Team } from '../index'
import ProfessionalDirect from '../professional'
// Import real components for comparison
import SandboxDirect from './sandbox'
import TeamDirect from './team'
import SandboxDirect from '../sandbox'
import TeamDirect from '../team'
describe('Billing Plan Assets - Integration Tests', () => {
describe('Exports', () => {

View File

@@ -1,5 +1,5 @@
import { render } from '@testing-library/react'
import Professional from './professional'
import Professional from '../professional'
describe('Professional Icon Component', () => {
describe('Rendering', () => {

View File

@@ -1,6 +1,6 @@
import { render } from '@testing-library/react'
import * as React from 'react'
import Sandbox from './sandbox'
import Sandbox from '../sandbox'
describe('Sandbox Icon Component', () => {
describe('Rendering', () => {

View File

@@ -1,5 +1,5 @@
import { render } from '@testing-library/react'
import Team from './team'
import Team from '../team'
describe('Team Icon Component', () => {
describe('Rendering', () => {

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { CategoryEnum } from '.'
import Footer from './footer'
import { CategoryEnum } from '..'
import Footer from '../footer'
vi.mock('next/link', () => ({
default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => (
@@ -16,13 +16,10 @@ describe('Footer', () => {
vi.clearAllMocks()
})
// Rendering behavior
describe('Rendering', () => {
it('should render tax tips and comparison link when in cloud category', () => {
// Arrange
render(<Footer pricingPageURL="https://dify.ai/pricing#plans-and-features" currentCategory={CategoryEnum.CLOUD} />)
// Assert
expect(screen.getByText('billing.plansCommon.taxTip')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.taxTipSecond')).toBeInTheDocument()
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/pricing#plans-and-features')
@@ -30,25 +27,19 @@ describe('Footer', () => {
})
})
// Prop-driven behavior
describe('Props', () => {
it('should hide tax tips when category is self-hosted', () => {
// Arrange
render(<Footer pricingPageURL="https://dify.ai/pricing#plans-and-features" currentCategory={CategoryEnum.SELF} />)
// Assert
expect(screen.queryByText('billing.plansCommon.taxTip')).not.toBeInTheDocument()
expect(screen.queryByText('billing.plansCommon.taxTipSecond')).not.toBeInTheDocument()
})
})
// Edge case rendering behavior
describe('Edge Cases', () => {
it('should render link even when pricing URL is empty', () => {
// Arrange
render(<Footer pricingPageURL="" currentCategory={CategoryEnum.CLOUD} />)
// Assert
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', '')
})
})

View File

@@ -1,74 +1,39 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import Header from './header'
let mockTranslations: Record<string, string> = {}
vi.mock('react-i18next', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-i18next')>()
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => {
if (mockTranslations[key])
return mockTranslations[key]
const prefix = options?.ns ? `${options.ns}.` : ''
return `${prefix}${key}`
},
}),
}
})
import Header from '../header'
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTranslations = {}
})
// Rendering behavior
describe('Rendering', () => {
it('should render title and description translations', () => {
// Arrange
const handleClose = vi.fn()
// Act
render(<Header onClose={handleClose} />)
// Assert
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.title.description')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
// Prop-driven behavior
describe('Props', () => {
it('should invoke onClose when close button is clicked', () => {
// Arrange
const handleClose = vi.fn()
render(<Header onClose={handleClose} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(handleClose).toHaveBeenCalledTimes(1)
})
})
// Edge case rendering behavior
describe('Edge Cases', () => {
it('should render structure when translations are empty strings', () => {
// Arrange
mockTranslations = {
'billing.plansCommon.title.plans': '',
'billing.plansCommon.title.description': '',
}
// Act
it('should render structural elements with translation keys', () => {
const { container } = render(<Header onClose={vi.fn()} />)
// Assert
expect(container.querySelector('span')).toBeInTheDocument()
expect(container.querySelector('p')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument()

View File

@@ -1,17 +1,24 @@
import type { Mock } from 'vitest'
import type { UsagePlanInfo } from '../type'
import type { UsagePlanInfo } from '../../type'
import { fireEvent, render, screen } from '@testing-library/react'
import { useKeyPress } from 'ahooks'
import * as React from 'react'
import { useAppContext } from '@/context/app-context'
import { useGetPricingPageLanguage } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
import { Plan } from '../type'
import Pricing from './index'
import { Plan } from '../../type'
import Pricing from '../index'
let mockTranslations: Record<string, string> = {}
let mockLanguage: string | null = 'en'
vi.mock('../plans/self-hosted-plan-item/list', () => ({
default: ({ plan }: { plan: string }) => (
<div data-testid={`list-${plan}`}>
List for
{plan}
</div>
),
}))
vi.mock('next/link', () => ({
default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => (
<a href={href} className={className} target={target} data-testid="pricing-link">
@@ -20,10 +27,6 @@ vi.mock('next/link', () => ({
),
}))
vi.mock('ahooks', () => ({
useKeyPress: vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
@@ -36,24 +39,6 @@ vi.mock('@/context/i18n', () => ({
useGetPricingPageLanguage: vi.fn(),
}))
vi.mock('react-i18next', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-i18next')>()
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: { returnObjects?: boolean, ns?: string }) => {
if (options?.returnObjects)
return mockTranslations[key] ?? []
if (mockTranslations[key])
return mockTranslations[key]
const prefix = options?.ns ? `${options.ns}.` : ''
return `${prefix}${key}`
},
}),
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
}
})
const buildUsage = (): UsagePlanInfo => ({
buildApps: 0,
teamMembers: 0,
@@ -67,7 +52,6 @@ const buildUsage = (): UsagePlanInfo => ({
describe('Pricing', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTranslations = {}
mockLanguage = 'en'
;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceManager: true })
;(useProviderContext as Mock).mockReturnValue({
@@ -80,42 +64,33 @@ describe('Pricing', () => {
;(useGetPricingPageLanguage as Mock).mockImplementation(() => mockLanguage)
})
// Rendering behavior
describe('Rendering', () => {
it('should render pricing header and localized footer link', () => {
// Arrange
render(<Pricing onCancel={vi.fn()} />)
// Assert
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/en/pricing#plans-and-features')
})
})
// Prop-driven behavior
describe('Props', () => {
it('should register esc key handler and allow switching categories', () => {
// Arrange
it('should allow switching categories and handle esc key', () => {
const handleCancel = vi.fn()
render(<Pricing onCancel={handleCancel} />)
// Act
fireEvent.click(screen.getByText('billing.plansCommon.self'))
// Assert
expect(useKeyPress).toHaveBeenCalledWith(['esc'], handleCancel)
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
expect(handleCancel).toHaveBeenCalled()
})
})
// Edge case rendering behavior
describe('Edge Cases', () => {
it('should fall back to default pricing URL when language is empty', () => {
// Arrange
mockLanguage = ''
render(<Pricing onCancel={vi.fn()} />)
// Assert
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/pricing#plans-and-features')
})
})

View File

@@ -0,0 +1,81 @@
import { render } from '@testing-library/react'
import {
Cloud,
Community,
Enterprise,
EnterpriseNoise,
NoiseBottom,
NoiseTop,
Premium,
PremiumNoise,
Professional,
Sandbox,
SelfHosted,
Team,
} from '../index'
// Static SVG components (no props)
describe('Static Pricing Asset Components', () => {
const staticComponents = [
{ name: 'Community', Component: Community },
{ name: 'Enterprise', Component: Enterprise },
{ name: 'EnterpriseNoise', Component: EnterpriseNoise },
{ name: 'NoiseBottom', Component: NoiseBottom },
{ name: 'NoiseTop', Component: NoiseTop },
{ name: 'Premium', Component: Premium },
{ name: 'PremiumNoise', Component: PremiumNoise },
{ name: 'Professional', Component: Professional },
{ name: 'Sandbox', Component: Sandbox },
{ name: 'Team', Component: Team },
]
it.each(staticComponents)('$name should render an SVG element', ({ Component }) => {
const { container } = render(<Component />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
it.each(staticComponents)('$name should render without errors on rerender', ({ Component }) => {
const { container, rerender } = render(<Component />)
rerender(<Component />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
})
// Interactive SVG components with isActive prop
describe('Cloud', () => {
it('should render an SVG element', () => {
const { container } = render(<Cloud isActive={false} />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should use primary color when inactive', () => {
const { container } = render(<Cloud isActive={false} />)
const rects = container.querySelectorAll('rect[fill="var(--color-text-primary)"]')
expect(rects.length).toBeGreaterThan(0)
})
it('should use accent color when active', () => {
const { container } = render(<Cloud isActive={true} />)
const rects = container.querySelectorAll('rect[fill="var(--color-saas-dify-blue-accessible)"]')
expect(rects.length).toBeGreaterThan(0)
})
})
describe('SelfHosted', () => {
it('should render an SVG element', () => {
const { container } = render(<SelfHosted isActive={false} />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should use primary color when inactive', () => {
const { container } = render(<SelfHosted isActive={false} />)
const rects = container.querySelectorAll('rect[fill="var(--color-text-primary)"]')
expect(rects.length).toBeGreaterThan(0)
})
it('should use accent color when active', () => {
const { container } = render(<SelfHosted isActive={true} />)
const rects = container.querySelectorAll('rect[fill="var(--color-saas-dify-blue-accessible)"]')
expect(rects.length).toBeGreaterThan(0)
})
})

View File

@@ -12,13 +12,11 @@ import {
Sandbox,
SelfHosted,
Team,
} from './index'
} from '../index'
describe('Pricing Assets', () => {
// Rendering: each asset should render an svg.
describe('Rendering', () => {
it('should render static assets without crashing', () => {
// Arrange
const assets = [
<Community key="community" />,
<Enterprise key="enterprise" />,
@@ -44,37 +42,29 @@ describe('Pricing Assets', () => {
// Props: active state should change fill color for selectable assets.
describe('Props', () => {
it('should render active state for Cloud', () => {
// Arrange
const { container } = render(<Cloud isActive />)
// Assert
const rects = Array.from(container.querySelectorAll('rect'))
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-saas-dify-blue-accessible)')).toBe(true)
})
it('should render inactive state for Cloud', () => {
// Arrange
const { container } = render(<Cloud isActive={false} />)
// Assert
const rects = Array.from(container.querySelectorAll('rect'))
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-text-primary)')).toBe(true)
})
it('should render active state for SelfHosted', () => {
// Arrange
const { container } = render(<SelfHosted isActive />)
// Assert
const rects = Array.from(container.querySelectorAll('rect'))
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-saas-dify-blue-accessible)')).toBe(true)
})
it('should render inactive state for SelfHosted', () => {
// Arrange
const { container } = render(<SelfHosted isActive={false} />)
// Assert
const rects = Array.from(container.querySelectorAll('rect'))
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-text-primary)')).toBe(true)
})

View File

@@ -1,36 +1,16 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { CategoryEnum } from '../index'
import PlanSwitcher from './index'
import { PlanRange } from './plan-range-switcher'
let mockTranslations: Record<string, string> = {}
vi.mock('react-i18next', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-i18next')>()
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => {
if (key in mockTranslations)
return mockTranslations[key]
const prefix = options?.ns ? `${options.ns}.` : ''
return `${prefix}${key}`
},
}),
}
})
import { CategoryEnum } from '../../index'
import PlanSwitcher from '../index'
import { PlanRange } from '../plan-range-switcher'
describe('PlanSwitcher', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTranslations = {}
})
// Rendering behavior
describe('Rendering', () => {
it('should render category tabs and plan range switcher for cloud', () => {
// Arrange
render(
<PlanSwitcher
currentCategory={CategoryEnum.CLOUD}
@@ -40,17 +20,14 @@ describe('PlanSwitcher', () => {
/>,
)
// Assert
expect(screen.getByText('billing.plansCommon.cloud')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.self')).toBeInTheDocument()
expect(screen.getByRole('switch')).toBeInTheDocument()
})
})
// Prop-driven behavior
describe('Props', () => {
it('should call onChangeCategory when selecting a tab', () => {
// Arrange
const handleChangeCategory = vi.fn()
render(
<PlanSwitcher
@@ -61,16 +38,13 @@ describe('PlanSwitcher', () => {
/>,
)
// Act
fireEvent.click(screen.getByText('billing.plansCommon.self'))
// Assert
expect(handleChangeCategory).toHaveBeenCalledTimes(1)
expect(handleChangeCategory).toHaveBeenCalledWith(CategoryEnum.SELF)
})
it('should hide plan range switcher when category is self-hosted', () => {
// Arrange
render(
<PlanSwitcher
currentCategory={CategoryEnum.SELF}
@@ -80,21 +54,12 @@ describe('PlanSwitcher', () => {
/>,
)
// Assert
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
})
// Edge case rendering behavior
describe('Edge Cases', () => {
it('should render tabs when translation strings are empty', () => {
// Arrange
mockTranslations = {
'plansCommon.cloud': '',
'plansCommon.self': '',
}
// Act
it('should render tabs with translation keys', () => {
const { container } = render(
<PlanSwitcher
currentCategory={CategoryEnum.SELF}
@@ -104,11 +69,10 @@ describe('PlanSwitcher', () => {
/>,
)
// Assert
const labels = container.querySelectorAll('span')
expect(labels).toHaveLength(2)
expect(labels[0]?.textContent).toBe('')
expect(labels[1]?.textContent).toBe('')
expect(labels[0]?.textContent).toBe('billing.plansCommon.cloud')
expect(labels[1]?.textContent).toBe('billing.plansCommon.self')
})
})
})

View File

@@ -1,86 +1,50 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import PlanRangeSwitcher, { PlanRange } from './plan-range-switcher'
let mockTranslations: Record<string, string> = {}
vi.mock('react-i18next', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-i18next')>()
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => {
if (mockTranslations[key])
return mockTranslations[key]
const prefix = options?.ns ? `${options.ns}.` : ''
return `${prefix}${key}`
},
}),
}
})
import PlanRangeSwitcher, { PlanRange } from '../plan-range-switcher'
describe('PlanRangeSwitcher', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTranslations = {}
})
// Rendering behavior
describe('Rendering', () => {
it('should render the annual billing label', () => {
// Arrange
render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={vi.fn()} />)
// Assert
expect(screen.getByText('billing.plansCommon.annualBilling')).toBeInTheDocument()
expect(screen.getByText(/billing\.plansCommon\.annualBilling/)).toBeInTheDocument()
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
})
})
// Prop-driven behavior
describe('Props', () => {
it('should switch to yearly when toggled from monthly', () => {
// Arrange
const handleChange = vi.fn()
render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={handleChange} />)
// Act
fireEvent.click(screen.getByRole('switch'))
// Assert
expect(handleChange).toHaveBeenCalledTimes(1)
expect(handleChange).toHaveBeenCalledWith(PlanRange.yearly)
})
it('should switch to monthly when toggled from yearly', () => {
// Arrange
const handleChange = vi.fn()
render(<PlanRangeSwitcher value={PlanRange.yearly} onChange={handleChange} />)
// Act
fireEvent.click(screen.getByRole('switch'))
// Assert
expect(handleChange).toHaveBeenCalledTimes(1)
expect(handleChange).toHaveBeenCalledWith(PlanRange.monthly)
})
})
// Edge case rendering behavior
describe('Edge Cases', () => {
it('should render when the translation string is empty', () => {
// Arrange
mockTranslations = {
'billing.plansCommon.annualBilling': '',
}
it('should render label with translation key and params', () => {
render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={vi.fn()} />)
// Act
const { container } = render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={vi.fn()} />)
// Assert
const label = container.querySelector('span')
const label = screen.getByText(/billing\.plansCommon\.annualBilling/)
expect(label).toBeInTheDocument()
expect(label?.textContent).toBe('')
expect(label.textContent).toContain('percent')
})
})
})

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import Tab from './tab'
import Tab from '../tab'
const Icon = ({ isActive }: { isActive: boolean }) => (
<svg data-testid="tab-icon" data-active={isActive ? 'true' : 'false'} />
@@ -11,10 +11,8 @@ describe('PlanSwitcherTab', () => {
vi.clearAllMocks()
})
// Rendering behavior
describe('Rendering', () => {
it('should render label and icon', () => {
// Arrange
render(
<Tab
Icon={Icon}
@@ -25,16 +23,13 @@ describe('PlanSwitcherTab', () => {
/>,
)
// Assert
expect(screen.getByText('Cloud')).toBeInTheDocument()
expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'false')
})
})
// Prop-driven behavior
describe('Props', () => {
it('should call onClick with the provided value', () => {
// Arrange
const handleClick = vi.fn()
render(
<Tab
@@ -46,17 +41,26 @@ describe('PlanSwitcherTab', () => {
/>,
)
// Act
fireEvent.click(screen.getByText('Self'))
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
expect(handleClick).toHaveBeenCalledWith('self')
})
it('should apply active text class when isActive is true', () => {
// Arrange
render(
it('should apply distinct styling when isActive is true', () => {
const { rerender } = render(
<Tab
Icon={Icon}
value="cloud"
label="Cloud"
isActive={false}
onClick={vi.fn()}
/>,
)
const inactiveClassName = screen.getByText('Cloud').className
rerender(
<Tab
Icon={Icon}
value="cloud"
@@ -66,16 +70,14 @@ describe('PlanSwitcherTab', () => {
/>,
)
// Assert
expect(screen.getByText('Cloud')).toHaveClass('text-saas-dify-blue-accessible')
const activeClassName = screen.getByText('Cloud').className
expect(activeClassName).not.toBe(inactiveClassName)
expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'true')
})
})
// Edge case rendering behavior
describe('Edge Cases', () => {
it('should render when label is empty', () => {
// Arrange
const { container } = render(
<Tab
Icon={Icon}
@@ -86,7 +88,6 @@ describe('PlanSwitcherTab', () => {
/>,
)
// Assert
const label = container.querySelector('span')
expect(label).toBeInTheDocument()
expect(label?.textContent).toBe('')

View File

@@ -1,14 +1,14 @@
import type { Mock } from 'vitest'
import type { UsagePlanInfo } from '../../type'
import type { UsagePlanInfo } from '../../../type'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { Plan } from '../../type'
import { PlanRange } from '../plan-switcher/plan-range-switcher'
import cloudPlanItem from './cloud-plan-item'
import Plans from './index'
import selfHostedPlanItem from './self-hosted-plan-item'
import { Plan } from '../../../type'
import { PlanRange } from '../../plan-switcher/plan-range-switcher'
import cloudPlanItem from '../cloud-plan-item'
import Plans from '../index'
import selfHostedPlanItem from '../self-hosted-plan-item'
vi.mock('./cloud-plan-item', () => ({
vi.mock('../cloud-plan-item', () => ({
default: vi.fn(props => (
<div data-testid={`cloud-plan-${props.plan}`} data-current-plan={props.currentPlan}>
Cloud
@@ -18,7 +18,7 @@ vi.mock('./cloud-plan-item', () => ({
)),
}))
vi.mock('./self-hosted-plan-item', () => ({
vi.mock('../self-hosted-plan-item', () => ({
default: vi.fn(props => (
<div data-testid={`self-plan-${props.plan}`}>
Self

View File

@@ -1,13 +1,12 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { Plan } from '../../../type'
import Button from './button'
import { Plan } from '../../../../type'
import Button from '../button'
describe('CloudPlanButton', () => {
describe('Disabled state', () => {
it('should disable button and hide arrow when plan is not available', () => {
const handleGetPayUrl = vi.fn()
// Arrange
render(
<Button
plan={Plan.team}
@@ -18,7 +17,6 @@ describe('CloudPlanButton', () => {
)
const button = screen.getByRole('button', { name: /Get started/i })
// Assert
expect(button).toBeDisabled()
expect(button.className).toContain('cursor-not-allowed')
expect(handleGetPayUrl).not.toHaveBeenCalled()
@@ -28,7 +26,6 @@ describe('CloudPlanButton', () => {
describe('Enabled state', () => {
it('should invoke handler and render arrow when plan is available', () => {
const handleGetPayUrl = vi.fn()
// Arrange
render(
<Button
plan={Plan.sandbox}
@@ -39,10 +36,8 @@ describe('CloudPlanButton', () => {
)
const button = screen.getByRole('button', { name: /Start now/i })
// Act
fireEvent.click(button)
// Assert
expect(handleGetPayUrl).toHaveBeenCalledTimes(1)
expect(button).not.toBeDisabled()
})

View File

@@ -5,13 +5,13 @@ import { useAppContext } from '@/context/app-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { fetchSubscriptionUrls } from '@/service/billing'
import { consoleClient } from '@/service/client'
import Toast from '../../../../base/toast'
import { ALL_PLANS } from '../../../config'
import { Plan } from '../../../type'
import { PlanRange } from '../../plan-switcher/plan-range-switcher'
import CloudPlanItem from './index'
import Toast from '../../../../../base/toast'
import { ALL_PLANS } from '../../../../config'
import { Plan } from '../../../../type'
import { PlanRange } from '../../../plan-switcher/plan-range-switcher'
import CloudPlanItem from '../index'
vi.mock('../../../../base/toast', () => ({
vi.mock('../../../../../base/toast', () => ({
default: {
notify: vi.fn(),
},
@@ -37,7 +37,7 @@ vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: vi.fn(),
}))
vi.mock('../../assets', () => ({
vi.mock('../../../assets', () => ({
Sandbox: () => <div>Sandbox Icon</div>,
Professional: () => <div>Professional Icon</div>,
Team: () => <div>Team Icon</div>,
@@ -66,13 +66,6 @@ beforeAll(() => {
})
})
afterAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
})
})
beforeEach(() => {
vi.clearAllMocks()
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
@@ -82,6 +75,13 @@ beforeEach(() => {
assignedHref = ''
})
afterAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
})
})
describe('CloudPlanItem', () => {
// Static content for each plan
describe('Rendering', () => {
@@ -117,6 +117,32 @@ describe('CloudPlanItem', () => {
expect(screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.year/)).toBeInTheDocument()
})
it('should show "most popular" badge for professional plan', () => {
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
expect(screen.getByText('billing.plansCommon.mostPopular')).toBeInTheDocument()
})
it('should not show "most popular" badge for non-professional plans', () => {
render(
<CloudPlanItem
plan={Plan.team}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
expect(screen.queryByText('billing.plansCommon.mostPopular')).not.toBeInTheDocument()
})
it('should disable CTA when workspace already on higher tier', () => {
render(
<CloudPlanItem
@@ -192,5 +218,128 @@ describe('CloudPlanItem', () => {
expect(assignedHref).toBe('https://subscription.example')
})
})
// Covers L92-93: isFreePlan guard inside handleGetPayUrl
it('should do nothing when clicking sandbox plan CTA that is not the current plan', async () => {
render(
<CloudPlanItem
plan={Plan.sandbox}
currentPlan={Plan.professional}
planRange={PlanRange.monthly}
canPay
/>,
)
// Sandbox viewed from a higher plan is disabled, but let's verify no API calls
const button = screen.getByRole('button')
fireEvent.click(button)
await waitFor(() => {
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
expect(mockBillingInvoices).not.toHaveBeenCalled()
expect(assignedHref).toBe('')
})
})
// Covers L95: yearly subscription URL ('year' parameter)
it('should fetch yearly subscription url when planRange is yearly', async () => {
render(
<CloudPlanItem
plan={Plan.team}
currentPlan={Plan.sandbox}
planRange={PlanRange.yearly}
canPay
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' }))
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year')
expect(assignedHref).toBe('https://subscription.example')
})
})
// Covers L62-63: loading guard prevents double click
it('should ignore second click while loading', async () => {
// Make the first fetch hang until we resolve it
let resolveFirst!: (v: { url: string }) => void
mockFetchSubscriptionUrls.mockImplementationOnce(
() => new Promise((resolve) => { resolveFirst = resolve }),
)
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
const button = screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' })
// First click starts loading
fireEvent.click(button)
// Second click while loading should be ignored
fireEvent.click(button)
// Resolve first request
resolveFirst({ url: 'https://first.example' })
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledTimes(1)
})
})
// Covers L82-83, L85-87: openAsyncWindow error path when invoices returns no url
it('should invoke onError when billing invoices returns empty url', async () => {
mockBillingInvoices.mockResolvedValue({ url: '' })
const openWindow = vi.fn(async (cb: () => Promise<string>, opts: { onError?: (e: Error) => void }) => {
try {
await cb()
}
catch (e) {
opts.onError?.(e as Error)
}
})
mockUseAsyncWindowOpen.mockReturnValue(openWindow)
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.professional}
planRange={PlanRange.monthly}
canPay
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' }))
await waitFor(() => {
expect(openWindow).toHaveBeenCalledTimes(1)
// The onError callback should have been passed to openAsyncWindow
const callArgs = openWindow.mock.calls[0]
expect(callArgs[1]).toHaveProperty('onError')
})
})
// Covers monthly price display (L139 !isYear branch for price)
it('should display monthly pricing without discount', () => {
render(
<CloudPlanItem
plan={Plan.team}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
const teamPlan = ALL_PLANS[Plan.team]
expect(screen.getByText(`$${teamPlan.price}`)).toBeInTheDocument()
expect(screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.month/)).toBeInTheDocument()
// Should NOT show crossed-out yearly price
expect(screen.queryByText(`$${teamPlan.price * 12}`)).not.toBeInTheDocument()
})
})
})

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { Plan } from '../../../../type'
import List from './index'
import { Plan } from '../../../../../type'
import List from '../index'
describe('CloudPlanItem/List', () => {
it('should show sandbox specific quotas', () => {

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import Item from './index'
import Item from '../index'
describe('Item', () => {
beforeEach(() => {
@@ -9,13 +9,10 @@ describe('Item', () => {
// Rendering the plan item row
describe('Rendering', () => {
it('should render the provided label when tooltip is absent', () => {
// Arrange
const label = 'Monthly credits'
// Act
const { container } = render(<Item label={label} />)
// Assert
expect(screen.getByText(label)).toBeInTheDocument()
expect(container.querySelector('.group')).toBeNull()
})
@@ -24,27 +21,21 @@ describe('Item', () => {
// Toggling the optional tooltip indicator
describe('Tooltip behavior', () => {
it('should render tooltip content when tooltip text is provided', () => {
// Arrange
const label = 'Workspace seats'
const tooltip = 'Seats define how many teammates can join the workspace.'
// Act
const { container } = render(<Item label={label} tooltip={tooltip} />)
// Assert
expect(screen.getByText(label)).toBeInTheDocument()
expect(screen.getByText(tooltip)).toBeInTheDocument()
expect(container.querySelector('.group')).not.toBeNull()
})
it('should treat an empty tooltip string as absent', () => {
// Arrange
const label = 'Vector storage'
// Act
const { container } = render(<Item label={label} tooltip="" />)
// Assert
expect(screen.getByText(label)).toBeInTheDocument()
expect(container.querySelector('.group')).toBeNull()
})

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import Tooltip from './tooltip'
import Tooltip from '../tooltip'
describe('Tooltip', () => {
beforeEach(() => {
@@ -9,26 +9,20 @@ describe('Tooltip', () => {
// Rendering the info tooltip container
describe('Rendering', () => {
it('should render the content panel when provide with text', () => {
// Arrange
const content = 'Usage resets on the first day of every month.'
// Act
render(<Tooltip content={content} />)
// Assert
expect(() => screen.getByText(content)).not.toThrow()
})
})
describe('Icon rendering', () => {
it('should render the icon when provided with content', () => {
// Arrange
const content = 'Tooltips explain each plan detail.'
// Act
render(<Tooltip content={content} />)
// Assert
expect(screen.getByTestId('tooltip-icon')).toBeInTheDocument()
})
})
@@ -36,7 +30,6 @@ describe('Tooltip', () => {
// Handling empty strings while keeping structure consistent
describe('Edge cases', () => {
it('should render without crashing when passed empty content', () => {
// Arrange
const content = ''
// Act and Assert

View File

@@ -3,8 +3,8 @@ import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { SelfHostedPlan } from '../../../type'
import Button from './button'
import { SelfHostedPlan } from '../../../../type'
import Button from '../button'
vi.mock('@/hooks/use-theme')

View File

@@ -2,30 +2,21 @@ import type { Mock } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { useAppContext } from '@/context/app-context'
import Toast from '../../../../base/toast'
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../config'
import { SelfHostedPlan } from '../../../type'
import SelfHostedPlanItem from './index'
import Toast from '../../../../../base/toast'
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../../config'
import { SelfHostedPlan } from '../../../../type'
import SelfHostedPlanItem from '../index'
const featuresTranslations: Record<string, string[]> = {
'billing.plans.community.features': ['community-feature-1', 'community-feature-2'],
'billing.plans.premium.features': ['premium-feature-1'],
'billing.plans.enterprise.features': ['enterprise-feature-1'],
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
const prefix = options?.ns ? `${options.ns}.` : ''
if (options?.returnObjects)
return featuresTranslations[`${prefix}${key}`] || []
return `${prefix}${key}`
},
}),
Trans: ({ i18nKey, ns }: { i18nKey: string, ns?: string }) => <span>{ns ? `${ns}.${i18nKey}` : i18nKey}</span>,
vi.mock('../list', () => ({
default: ({ plan }: { plan: string }) => (
<div data-testid={`list-${plan}`}>
List for
{plan}
</div>
),
}))
vi.mock('../../../../base/toast', () => ({
vi.mock('../../../../../base/toast', () => ({
default: {
notify: vi.fn(),
},
@@ -35,7 +26,7 @@ vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('../../assets', () => ({
vi.mock('../../../assets', () => ({
Community: () => <div>Community Icon</div>,
Premium: () => <div>Premium Icon</div>,
Enterprise: () => <div>Enterprise Icon</div>,
@@ -63,6 +54,12 @@ beforeAll(() => {
})
})
beforeEach(() => {
vi.clearAllMocks()
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
assignedHref = ''
})
afterAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
@@ -70,14 +67,7 @@ afterAll(() => {
})
})
beforeEach(() => {
vi.clearAllMocks()
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
assignedHref = ''
})
describe('SelfHostedPlanItem', () => {
// Copy rendering for each plan
describe('Rendering', () => {
it('should display community plan info', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
@@ -85,8 +75,7 @@ describe('SelfHostedPlanItem', () => {
expect(screen.getByText('billing.plans.community.name')).toBeInTheDocument()
expect(screen.getByText('billing.plans.community.description')).toBeInTheDocument()
expect(screen.getByText('billing.plans.community.price')).toBeInTheDocument()
expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument()
expect(screen.getByText('community-feature-1')).toBeInTheDocument()
expect(screen.getByTestId('list-community')).toBeInTheDocument()
})
it('should show premium extras such as cloud provider notice', () => {
@@ -97,7 +86,6 @@ describe('SelfHostedPlanItem', () => {
})
})
// CTA behavior for each plan
describe('CTA interactions', () => {
it('should show toast when non-manager tries to proceed', () => {
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })

View File

@@ -0,0 +1,20 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { SelfHostedPlan } from '@/app/components/billing/type'
import { createReactI18nextMock } from '@/test/i18n-mock'
import List from '../index'
// Override global i18n mock to support returnObjects: true for feature arrays
vi.mock('react-i18next', () => createReactI18nextMock({
'billing.plans.community.features': ['Feature A', 'Feature B'],
}))
describe('SelfHostedPlanItem/List', () => {
it('should render plan info', () => {
render(<List plan={SelfHostedPlan.community} />)
expect(screen.getByText('plans.community.includesTitle')).toBeInTheDocument()
expect(screen.getByText('Feature A')).toBeInTheDocument()
expect(screen.getByText('Feature B')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import Item from '../item'
describe('SelfHostedPlanItem/List/Item', () => {
it('should display provided feature label', () => {
const { container } = render(<Item label="Dedicated support" />)
expect(screen.getByText('Dedicated support')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeNull()
})
it('should render the check icon', () => {
const { container } = render(<Item label="Custom branding" />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
expect(svg).toHaveClass('size-4')
})
it('should render different labels correctly', () => {
const { rerender } = render(<Item label="Feature A" />)
expect(screen.getByText('Feature A')).toBeInTheDocument()
rerender(<Item label="Feature B" />)
expect(screen.getByText('Feature B')).toBeInTheDocument()
expect(screen.queryByText('Feature A')).not.toBeInTheDocument()
})
it('should render with empty label', () => {
const { container } = render(<Item label="" />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
})

View File

@@ -1,26 +0,0 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { SelfHostedPlan } from '@/app/components/billing/type'
import List from './index'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.returnObjects)
return ['Feature A', 'Feature B']
const prefix = options?.ns ? `${options.ns}.` : ''
return `${prefix}${key}`
},
}),
Trans: ({ i18nKey, ns }: { i18nKey: string, ns?: string }) => <span>{ns ? `${ns}.${i18nKey}` : i18nKey}</span>,
}))
describe('SelfHostedPlanItem/List', () => {
it('should render plan info', () => {
render(<List plan={SelfHostedPlan.community} />)
expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument()
expect(screen.getByText('Feature A')).toBeInTheDocument()
expect(screen.getByText('Feature B')).toBeInTheDocument()
})
})

View File

@@ -1,12 +0,0 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import Item from './item'
describe('SelfHostedPlanItem/List/Item', () => {
it('should display provided feature label', () => {
const { container } = render(<Item label="Dedicated support" />)
expect(screen.getByText('Dedicated support')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeNull()
})
})

View File

@@ -2,8 +2,8 @@ import type { Mock } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { createMockPlan } from '@/__mocks__/provider-context'
import { useProviderContext } from '@/context/provider-context'
import { Plan } from '../type'
import PriorityLabel from './index'
import { Plan } from '../../type'
import PriorityLabel from '../index'
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
@@ -20,16 +20,12 @@ describe('PriorityLabel', () => {
vi.clearAllMocks()
})
// Rendering: basic label output for sandbox plan.
describe('Rendering', () => {
it('should render the standard priority label when plan is sandbox', () => {
// Arrange
setupPlan(Plan.sandbox)
// Act
render(<PriorityLabel />)
// Assert
expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
})
})
@@ -37,13 +33,10 @@ describe('PriorityLabel', () => {
// Props: custom class name applied to the label container.
describe('Props', () => {
it('should apply custom className to the label container', () => {
// Arrange
setupPlan(Plan.sandbox)
// Act
render(<PriorityLabel className="custom-class" />)
// Assert
const label = screen.getByText('billing.plansCommon.priority.standard').closest('div')
expect(label).toHaveClass('custom-class')
})
@@ -52,54 +45,53 @@ describe('PriorityLabel', () => {
// Plan types: label text and icon visibility for different plans.
describe('Plan Types', () => {
it('should render priority label and icon when plan is professional', () => {
// Arrange
setupPlan(Plan.professional)
// Act
const { container } = render(<PriorityLabel />)
// Assert
expect(screen.getByText('billing.plansCommon.priority.priority')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should render top priority label and icon when plan is team', () => {
// Arrange
setupPlan(Plan.team)
// Act
const { container } = render(<PriorityLabel />)
// Assert
expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should render standard label without icon when plan is sandbox', () => {
// Arrange
setupPlan(Plan.sandbox)
// Act
const { container } = render(<PriorityLabel />)
// Assert
expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeInTheDocument()
})
})
// Edge cases: tooltip content varies by priority level.
// Enterprise plan tests
describe('Enterprise Plan', () => {
it('should render top-priority label with icon for enterprise plan', () => {
setupPlan(Plan.enterprise)
const { container } = render(<PriorityLabel />)
expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should show the tip text when priority is not top priority', async () => {
// Arrange
setupPlan(Plan.sandbox)
// Act
render(<PriorityLabel />)
const label = screen.getByText('billing.plansCommon.priority.standard').closest('div')
fireEvent.mouseEnter(label as HTMLElement)
// Assert
expect(await screen.findByText(
'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.standard',
)).toBeInTheDocument()
@@ -107,15 +99,12 @@ describe('PriorityLabel', () => {
})
it('should hide the tip text when priority is top priority', async () => {
// Arrange
setupPlan(Plan.enterprise)
// Act
render(<PriorityLabel />)
const label = screen.getByText('billing.plansCommon.priority.top-priority').closest('div')
fireEvent.mouseEnter(label as HTMLElement)
// Assert
expect(await screen.findByText(
'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.top-priority',
)).toBeInTheDocument()

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import ProgressBar from './index'
import ProgressBar from '../index'
describe('ProgressBar', () => {
describe('Normal Mode (determinate)', () => {
@@ -7,7 +7,6 @@ describe('ProgressBar', () => {
render(<ProgressBar percent={42} color="bg-test-color" />)
const bar = screen.getByTestId('billing-progress-bar')
expect(bar).toHaveClass('bg-test-color')
expect(bar.getAttribute('style')).toContain('width: 42%')
})
@@ -18,11 +17,10 @@ describe('ProgressBar', () => {
expect(bar.getAttribute('style')).toContain('width: 100%')
})
it('uses the default color when no color prop is provided', () => {
it('renders with default color when no color prop is provided', () => {
render(<ProgressBar percent={20} color={undefined as unknown as string} />)
const bar = screen.getByTestId('billing-progress-bar')
expect(bar).toHaveClass('bg-components-progress-bar-progress-solid')
expect(bar.getAttribute('style')).toContain('width: 20%')
})
})
@@ -31,9 +29,7 @@ describe('ProgressBar', () => {
it('should render indeterminate progress bar when indeterminate is true', () => {
render(<ProgressBar percent={0} color="bg-test-color" indeterminate />)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toBeInTheDocument()
expect(bar).toHaveClass('bg-progress-bar-indeterminate-stripe')
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should not render normal progress bar when indeterminate is true', () => {
@@ -43,20 +39,20 @@ describe('ProgressBar', () => {
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should render with default width (w-[30px]) when indeterminateFull is false', () => {
render(<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull={false} />)
it('should render with different width based on indeterminateFull prop', () => {
const { rerender } = render(
<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull={false} />,
)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-[30px]')
expect(bar).not.toHaveClass('w-full')
})
const partialClassName = bar.className
it('should render with full width (w-full) when indeterminateFull is true', () => {
render(<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull />)
rerender(
<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull />,
)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-full')
expect(bar).not.toHaveClass('w-[30px]')
const fullClassName = screen.getByTestId('billing-progress-bar-indeterminate').className
expect(partialClassName).not.toBe(fullClassName)
})
})
})

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import TriggerEventsLimitModal from './index'
import TriggerEventsLimitModal from '../index'
const mockOnClose = vi.fn()
const mockOnUpgrade = vi.fn()
@@ -16,8 +16,7 @@ const planUpgradeModalMock = vi.fn((props: { show: boolean, title: string, descr
))
vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
// eslint-disable-next-line ts/no-explicit-any
default: (props: any) => planUpgradeModalMock(props),
default: (props: { show: boolean, title: string, description: string, extraInfo?: React.ReactNode, onClose: () => void, onUpgrade: () => void }) => planUpgradeModalMock(props),
}))
describe('TriggerEventsLimitModal', () => {
@@ -66,4 +65,53 @@ describe('TriggerEventsLimitModal', () => {
expect(planUpgradeModalMock).toHaveBeenCalled()
expect(screen.getByTestId('plan-upgrade-modal').getAttribute('data-show')).toBe('false')
})
it('renders reset info when resetInDays is provided', () => {
render(
<TriggerEventsLimitModal
show
onClose={mockOnClose}
onUpgrade={mockOnUpgrade}
usage={18000}
total={20000}
resetInDays={7}
/>,
)
expect(screen.getByText('billing.triggerLimitModal.usageTitle')).toBeInTheDocument()
expect(screen.getByText('18000')).toBeInTheDocument()
expect(screen.getByText('20000')).toBeInTheDocument()
})
it('passes correct title and description translations', () => {
render(
<TriggerEventsLimitModal
show
onClose={mockOnClose}
onUpgrade={mockOnUpgrade}
usage={0}
total={0}
/>,
)
const modal = screen.getByTestId('plan-upgrade-modal')
expect(modal.getAttribute('data-title')).toBe('billing.triggerLimitModal.title')
expect(modal.getAttribute('data-description')).toBe('billing.triggerLimitModal.description')
})
it('passes onClose and onUpgrade callbacks to PlanUpgradeModal', () => {
render(
<TriggerEventsLimitModal
show
onClose={mockOnClose}
onUpgrade={mockOnUpgrade}
usage={0}
total={0}
/>,
)
const passedProps = planUpgradeModalMock.mock.calls[0][0]
expect(passedProps.onClose).toBe(mockOnClose)
expect(passedProps.onUpgrade).toBe(mockOnUpgrade)
})
})

View File

@@ -1,7 +1,7 @@
import type { Mock } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import UpgradeBtn from './index'
import UpgradeBtn from '../index'
// ✅ Import real project components (DO NOT mock these)
// PremiumBadge, Button, SparklesSoft are all base components
@@ -14,146 +14,117 @@ vi.mock('@/context/modal-context', () => ({
}),
}))
// Mock gtag for tracking tests
// Typed window accessor for gtag tracking tests
const gtagWindow = window as unknown as Record<string, Mock | undefined>
let mockGtag: Mock | undefined
describe('UpgradeBtn', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGtag = vi.fn()
;(window as any).gtag = mockGtag
gtagWindow.gtag = mockGtag
})
afterEach(() => {
delete (window as any).gtag
delete gtagWindow.gtag
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should render without crashing with default props', () => {
// Act
render(<UpgradeBtn />)
// Assert - should render with default text
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
})
it('should render premium badge by default', () => {
// Act
render(<UpgradeBtn />)
// Assert - PremiumBadge renders with text content
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
})
it('should render plain button when isPlain is true', () => {
// Act
render(<UpgradeBtn isPlain />)
// Assert - Button should be rendered with plain text
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
expect(screen.getByText(/billing\.upgradeBtn\.plain/i)).toBeInTheDocument()
})
it('should render short text when isShort is true', () => {
// Act
render(<UpgradeBtn isShort />)
// Assert
expect(screen.getByText(/billing\.upgradeBtn\.encourageShort/i)).toBeInTheDocument()
})
it('should render custom label when labelKey is provided', () => {
// Act
render(<UpgradeBtn labelKey={'custom.label.key' as any} />)
render(<UpgradeBtn labelKey="triggerLimitModal.upgrade" />)
// Assert
expect(screen.getByText(/custom\.label\.key/i)).toBeInTheDocument()
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
})
it('should render custom label in plain button when labelKey is provided with isPlain', () => {
// Act
render(<UpgradeBtn isPlain labelKey={'custom.label.key' as any} />)
render(<UpgradeBtn isPlain labelKey="triggerLimitModal.upgrade" />)
// Assert
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
expect(screen.getByText(/custom\.label\.key/i)).toBeInTheDocument()
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
})
})
// Props tests (REQUIRED)
describe('Props', () => {
it('should apply custom className to premium badge', () => {
// Arrange
const customClass = 'custom-upgrade-btn'
// Act
const { container } = render(<UpgradeBtn className={customClass} />)
// Assert - Check the root element has the custom class
const rootElement = container.firstChild as HTMLElement
expect(rootElement).toHaveClass(customClass)
})
it('should apply custom className to plain button', () => {
// Arrange
const customClass = 'custom-button-class'
// Act
render(<UpgradeBtn isPlain className={customClass} />)
// Assert
const button = screen.getByRole('button')
expect(button).toHaveClass(customClass)
})
it('should apply custom style to premium badge', () => {
// Arrange
const customStyle = { padding: '10px' }
// Act
const { container } = render(<UpgradeBtn style={customStyle} />)
// Assert
const rootElement = container.firstChild as HTMLElement
expect(rootElement).toHaveStyle(customStyle)
})
it('should apply custom style to plain button', () => {
// Arrange
const customStyle = { margin: '5px' }
// Act
render(<UpgradeBtn isPlain style={customStyle} />)
// Assert
const button = screen.getByRole('button')
expect(button).toHaveStyle(customStyle)
})
it('should render with size "s"', () => {
// Act
render(<UpgradeBtn size="s" />)
// Assert - Component renders successfully with size prop
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
})
it('should render with size "m" by default', () => {
// Act
render(<UpgradeBtn />)
// Assert - Component renders successfully
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
})
it('should render with size "custom"', () => {
// Act
render(<UpgradeBtn size="custom" />)
// Assert - Component renders successfully with custom size
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
})
})
@@ -161,72 +132,57 @@ describe('UpgradeBtn', () => {
// User Interactions
describe('User Interactions', () => {
it('should call custom onClick when provided and premium badge is clicked', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = vi.fn()
// Act
render(<UpgradeBtn onClick={handleClick} />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
})
it('should call custom onClick when provided and plain button is clicked', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = vi.fn()
// Act
render(<UpgradeBtn isPlain onClick={handleClick} />)
const button = screen.getByRole('button')
await user.click(button)
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
})
it('should open pricing modal when no custom onClick is provided and premium badge is clicked', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should open pricing modal when no custom onClick is provided and plain button is clicked', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn isPlain />)
const button = screen.getByRole('button')
await user.click(button)
// Assert
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should track gtag event when loc is provided and badge is clicked', async () => {
// Arrange
const user = userEvent.setup()
const loc = 'header-navigation'
// Act
render(<UpgradeBtn loc={loc} />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert
expect(mockGtag).toHaveBeenCalledTimes(1)
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
loc,
@@ -234,16 +190,13 @@ describe('UpgradeBtn', () => {
})
it('should track gtag event when loc is provided and plain button is clicked', async () => {
// Arrange
const user = userEvent.setup()
const loc = 'footer-section'
// Act
render(<UpgradeBtn isPlain loc={loc} />)
const button = screen.getByRole('button')
await user.click(button)
// Assert
expect(mockGtag).toHaveBeenCalledTimes(1)
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
loc,
@@ -251,44 +204,35 @@ describe('UpgradeBtn', () => {
})
it('should not track gtag event when loc is not provided', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert
expect(mockGtag).not.toHaveBeenCalled()
})
it('should not track gtag event when gtag is not available', async () => {
// Arrange
const user = userEvent.setup()
delete (window as any).gtag
delete gtagWindow.gtag
// Act
render(<UpgradeBtn loc="test-location" />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert - should not throw error
expect(mockGtag).not.toHaveBeenCalled()
})
it('should call both custom onClick and track gtag when both are provided', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = vi.fn()
const loc = 'settings-page'
// Act
render(<UpgradeBtn onClick={handleClick} loc={loc} />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
expect(mockGtag).toHaveBeenCalledTimes(1)
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
@@ -300,121 +244,95 @@ describe('UpgradeBtn', () => {
// Edge Cases (REQUIRED)
describe('Edge Cases', () => {
it('should handle undefined className', () => {
// Act
render(<UpgradeBtn className={undefined} />)
// Assert - should render without error
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
})
it('should handle undefined style', () => {
// Act
render(<UpgradeBtn style={undefined} />)
// Assert - should render without error
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
})
it('should handle undefined onClick', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn onClick={undefined} />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert - should fall back to setShowPricingModal
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should handle undefined loc', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn loc={undefined} />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert - should not attempt to track gtag
expect(mockGtag).not.toHaveBeenCalled()
})
it('should handle undefined labelKey', () => {
// Act
render(<UpgradeBtn labelKey={undefined} />)
// Assert - should use default label
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
})
it('should handle empty string className', () => {
// Act
render(<UpgradeBtn className="" />)
// Assert
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
})
it('should handle empty string loc', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn loc="" />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert - empty loc should not trigger gtag
expect(mockGtag).not.toHaveBeenCalled()
})
it('should handle empty string labelKey', () => {
// Act
render(<UpgradeBtn labelKey={'' as any} />)
it('should handle labelKey with isShort - labelKey takes precedence', () => {
render(<UpgradeBtn isShort labelKey="triggerLimitModal.title" />)
// Assert - empty labelKey is falsy, so it falls back to default label
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument()
expect(screen.queryByText(/billing\.upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
})
})
// Prop Combinations
describe('Prop Combinations', () => {
it('should handle isPlain with isShort', () => {
// Act
render(<UpgradeBtn isPlain isShort />)
// Assert - isShort should not affect plain button text
expect(screen.getByText(/billing\.upgradeBtn\.plain/i)).toBeInTheDocument()
})
it('should handle isPlain with custom labelKey', () => {
// Act
render(<UpgradeBtn isPlain labelKey={'custom.key' as any} />)
render(<UpgradeBtn isPlain labelKey="triggerLimitModal.upgrade" />)
// Assert - labelKey should override plain text
expect(screen.getByText(/custom\.key/i)).toBeInTheDocument()
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
expect(screen.queryByText(/billing\.upgradeBtn\.plain/i)).not.toBeInTheDocument()
})
it('should handle isShort with custom labelKey', () => {
// Act
render(<UpgradeBtn isShort labelKey={'custom.short.key' as any} />)
render(<UpgradeBtn isShort labelKey="triggerLimitModal.title" />)
// Assert - labelKey should override isShort behavior
expect(screen.getByText(/custom\.short\.key/i)).toBeInTheDocument()
expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument()
expect(screen.queryByText(/billing\.upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
})
it('should handle all custom props together', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = vi.fn()
const customStyle = { margin: '10px' }
const customClass = 'all-custom'
// Act
const { container } = render(
<UpgradeBtn
className={customClass}
@@ -423,17 +341,16 @@ describe('UpgradeBtn', () => {
isShort
onClick={handleClick}
loc="test-loc"
labelKey={'custom.all' as any}
labelKey="triggerLimitModal.description"
/>,
)
const badge = screen.getByText(/custom\.all/i)
const badge = screen.getByText(/triggerLimitModal\.description/i)
await user.click(badge)
// Assert
const rootElement = container.firstChild as HTMLElement
expect(rootElement).toHaveClass(customClass)
expect(rootElement).toHaveStyle(customStyle)
expect(screen.getByText(/custom\.all/i)).toBeInTheDocument()
expect(screen.getByText(/triggerLimitModal\.description/i)).toBeInTheDocument()
expect(handleClick).toHaveBeenCalledTimes(1)
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
loc: 'test-loc',
@@ -444,11 +361,9 @@ describe('UpgradeBtn', () => {
// Accessibility Tests
describe('Accessibility', () => {
it('should be keyboard accessible with plain button', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = vi.fn()
// Act
render(<UpgradeBtn isPlain onClick={handleClick} />)
const button = screen.getByRole('button')
@@ -459,47 +374,38 @@ describe('UpgradeBtn', () => {
// Press Enter
await user.keyboard('{Enter}')
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should be keyboard accessible with Space key', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = vi.fn()
// Act
render(<UpgradeBtn isPlain onClick={handleClick} />)
// Tab to button and press Space
await user.tab()
await user.keyboard(' ')
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should be clickable for premium badge variant', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = vi.fn()
// Act
render(<UpgradeBtn onClick={handleClick} />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
// Click badge
await user.click(badge)
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should have proper button role when isPlain is true', () => {
// Act
render(<UpgradeBtn isPlain />)
// Assert - Plain button should have button role
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
@@ -508,31 +414,25 @@ describe('UpgradeBtn', () => {
// Integration Tests
describe('Integration', () => {
it('should work with modal context for pricing modal', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert
await waitFor(() => {
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
})
it('should integrate onClick with analytics tracking', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = vi.fn()
// Act
render(<UpgradeBtn onClick={handleClick} loc="integration-test" />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert - Both onClick and gtag should be called
await waitFor(() => {
expect(handleClick).toHaveBeenCalledTimes(1)
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {

View File

@@ -0,0 +1,67 @@
import { render, screen } from '@testing-library/react'
import { defaultPlan } from '../../config'
import AppsInfo from '../apps-info'
const mockProviderContext = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockProviderContext(),
}))
describe('AppsInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
mockProviderContext.mockReturnValue({
plan: {
...defaultPlan,
usage: { ...defaultPlan.usage, buildApps: 7 },
total: { ...defaultPlan.total, buildApps: 15 },
},
})
})
it('renders build apps usage information with context data', () => {
render(<AppsInfo className="apps-info-class" />)
expect(screen.getByText('billing.usagePage.buildApps')).toBeInTheDocument()
expect(screen.getByText('7')).toBeInTheDocument()
expect(screen.getByText('15')).toBeInTheDocument()
expect(screen.getByText('billing.usagePage.buildApps').closest('.apps-info-class')).toBeInTheDocument()
})
it('renders without className', () => {
render(<AppsInfo />)
expect(screen.getByText('billing.usagePage.buildApps')).toBeInTheDocument()
})
it('renders zero usage correctly', () => {
mockProviderContext.mockReturnValue({
plan: {
...defaultPlan,
usage: { ...defaultPlan.usage, buildApps: 0 },
total: { ...defaultPlan.total, buildApps: 5 },
},
})
render(<AppsInfo />)
expect(screen.getByText('0')).toBeInTheDocument()
expect(screen.getByText('5')).toBeInTheDocument()
})
it('renders when usage equals total (at capacity)', () => {
mockProviderContext.mockReturnValue({
plan: {
...defaultPlan,
usage: { ...defaultPlan.usage, buildApps: 10 },
total: { ...defaultPlan.total, buildApps: 10 },
},
})
render(<AppsInfo />)
const tens = screen.getAllByText('10')
expect(tens.length).toBe(2)
})
})

Some files were not shown because too many files have changed in this diff Show More