Compare commits

..

14 Commits

Author SHA1 Message Date
CodingOnStar
ca9c2a62fb test: enhance dataset tests with vitest integration
- Added vitest imports to various dataset test files for improved testing capabilities.
- Ensured consistency in test structure across files by incorporating beforeEach, describe, expect, and it functions.
2026-02-11 12:50:01 +08:00
CodingOnStar
f6c03362e7 Merge branch 'test/tool-plugin' into test/integrate-datasets
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-11 12:36:46 +08:00
CodingOnStar
e92867884f test: add unit tests for various dataset components
- Introduced new test files for ChunkLabel, ChunkContainer, QAPreview, DatasetsLoading, NoLinkedAppsPanel, ApiIndex, and several document-related components.
- Enhanced test coverage by validating rendering, user interactions, and edge cases for each component.
- Ensured proper functionality of user interactions, such as rendering conditions and state management, improving the reliability and maintainability of dataset-related features.
2026-02-11 12:31:12 +08:00
CodingOnStar
a9f56716fc test: update unit tests to use Vitest framework
- Refactored test files for data source options, drawer, and pipeline settings to utilize Vitest for improved testing capabilities.
- Ensured consistent testing practices across components by importing necessary Vitest functions.
2026-02-10 21:01:53 +08:00
CodingOnStar
94eefaaee2 test: enhance unit tests for StepTwo and segment list components
- Added new tests for the StepTwo component, covering user interactions with the QA checkbox and file picker functionality.
- Improved test coverage for the segment list content, ensuring proper handling of click events on segment cards.
- Introduced tests for the useChildSegmentData hook, validating cache updates and scroll behavior based on child segment changes.

These enhancements improve the reliability and maintainability of dataset creation and document management features.
2026-02-10 20:58:07 +08:00
CodingOnStar
59f3acb021 test: add integration tests for dataset settings, metadata management, and pipeline data source flows
- Introduced new test files for Dataset Settings Flow, Metadata Management Flow, and Pipeline Data Source Store Composition.
- Enhanced test coverage by validating cross-module interactions, data contracts, and state management across various components.
- Ensured proper handling of user interactions and configuration cascades in the dataset settings and metadata management processes.

These additions improve the reliability and maintainability of dataset-related features.
2026-02-10 20:12:50 +08:00
CodingOnStar
4655a6a244 test: refactor and enhance unit tests for dataset creation components
- Moved unit tests for child components (usePreviewState, DataSourceTypeSelector, NextStepButton, PreviewPanel) to dedicated spec files for better organization.
- Added new tests for the StepTwo component, covering rendering, user interactions, and state management.
- Improved test coverage for CrawledResultItem, ensuring proper handling of checkbox interactions.
- Updated tests for MenuBar and other components to validate user interactions and rendering.

These changes enhance the maintainability and reliability of the dataset creation and processing features.
2026-02-10 18:41:23 +08:00
CodingOnStar
5006a5e804 test: add unit tests for website crawl and document preview components
- Introduced new test files for CheckboxWithLabel, CrawledResultItem, ErrorMessage, and various components related to website crawling and document preview.
- Enhanced test coverage by validating rendering, user interactions, and edge cases for each component.
- Ensured proper functionality of user interactions such as checkbox selections, button clicks, and rendering of dynamic content.

These additions improve the reliability and maintainability of the website crawl and document preview features.
2026-02-10 17:31:40 +08:00
CodingOnStar
5e6e8a16ce test: add unit tests for embedding process and website components
- Introduced new test files for DocumentList, IndexingProgressItem, RuleDetail, UpgradeBanner, and various utility functions related to the embedding process.
- Enhanced test coverage by validating rendering, user interactions, and edge cases for each component.
- Ensured proper functionality of user interactions, such as button clicks and state changes, as well as validation of parameters in hooks and utilities.

These additions improve the reliability and maintainability of the embedding process and website features.
2026-02-10 16:54:35 +08:00
CodingOnStar
0301d6b690 test: add unit tests for dataset creation and processing components
- Introduced new test files for DataSourceTypeSelector, NextStepButton, PreviewPanel, and various hooks related to document creation and indexing.
- Enhanced test coverage by validating rendering, user interactions, and edge cases for each component.
- Ensured proper functionality of user interactions such as button clicks and state changes, as well as validation of parameters in hooks.

These additions improve the reliability and maintainability of the dataset creation and processing features.
2026-02-10 15:53:18 +08:00
CodingOnStar
755c9b0c15 delete some useless comment 2026-02-10 15:02:56 +08:00
CodingOnStar
f1cce53bc2 test: add integration tests for dataset flows
- Introduced new test files for Create Dataset Flow, Document Management Flow, External Knowledge Base Creation Flow, Hit Testing Flow, and Segment CRUD Flow.
- Validated cross-module interactions, data contracts, and API calls for dataset creation, document management, and hit testing functionalities.
- Enhanced test coverage by ensuring proper handling of user interactions, query submissions, and state management across various components.

These additions improve the reliability and maintainability of the dataset-related features.
2026-02-10 14:58:31 +08:00
CodingOnStar
a29e74422e test: add unit tests for dataset creation components
- Introduced new test files for GeneralChunkingOptions, IndexingModeSection, Inputs, OptionCard, ParentChildOptions, and SummaryIndexSetting components.
- Enhanced test coverage by validating rendering, user interactions, and edge cases for each component.
- Ensured proper functionality of user interactions such as button clicks and state changes.

These additions improve the reliability and maintainability of the dataset creation feature.
2026-02-10 14:33:57 +08:00
CodingOnStar
83ef687d00 test: enhance unit tests for various components including chat, datasets, and documents
- Updated tests for  to ensure proper async behavior.
- Added comprehensive tests for , , and  components, covering rendering, user interactions, and edge cases.
- Introduced new tests for , , and  components, validating rendering and user interactions.
- Implemented tests for status filtering and document list query state to ensure correct functionality.

These changes improve test coverage and reliability across multiple components.
2026-02-10 13:59:54 +08:00
414 changed files with 22896 additions and 16178 deletions

View File

@@ -715,7 +715,6 @@ ANNOTATION_IMPORT_MAX_CONCURRENT=5
# Sandbox expired records clean configuration
SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL=200
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL=90000

View File

@@ -1344,10 +1344,6 @@ class SandboxExpiredRecordsCleanConfig(BaseSettings):
description="Maximum number of records to process in each batch",
default=1000,
)
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL: PositiveInt = Field(
description="Maximum interval in milliseconds between batches",
default=200,
)
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS: PositiveInt = Field(
description="Retention days for sandbox expired workflow_run records and message records",
default=30,

View File

@@ -1,7 +1,6 @@
import urllib.parse
import httpx
from flask_restx import Resource
from pydantic import BaseModel, Field
import services
@@ -11,12 +10,12 @@ from controllers.common.errors import (
RemoteFileUploadError,
UnsupportedFileTypeError,
)
from controllers.console import console_ns
from controllers.fastopenapi import console_router
from core.file import helpers as file_helpers
from core.helper import ssrf_proxy
from extensions.ext_database import db
from fields.file_fields import FileWithSignedUrl, RemoteFileInfo
from libs.login import current_account_with_tenant, login_required
from libs.login import current_account_with_tenant
from services.file_service import FileService
@@ -24,73 +23,69 @@ class RemoteFileUploadPayload(BaseModel):
url: str = Field(..., description="URL to fetch")
@console_ns.route("/remote-files/<path:url>")
class GetRemoteFileInfo(Resource):
@login_required
def get(self, url: str):
decoded_url = urllib.parse.unquote(url)
resp = ssrf_proxy.head(decoded_url)
@console_router.get(
"/remote-files/<path:url>",
response_model=RemoteFileInfo,
tags=["console"],
)
def get_remote_file_info(url: str) -> RemoteFileInfo:
decoded_url = urllib.parse.unquote(url)
resp = ssrf_proxy.head(decoded_url)
if resp.status_code != httpx.codes.OK:
resp = ssrf_proxy.get(decoded_url, timeout=3)
resp.raise_for_status()
return RemoteFileInfo(
file_type=resp.headers.get("Content-Type", "application/octet-stream"),
file_length=int(resp.headers.get("Content-Length", 0)),
)
@console_router.post(
"/remote-files/upload",
response_model=FileWithSignedUrl,
tags=["console"],
status_code=201,
)
def upload_remote_file(payload: RemoteFileUploadPayload) -> FileWithSignedUrl:
url = payload.url
try:
resp = ssrf_proxy.head(url=url)
if resp.status_code != httpx.codes.OK:
resp = ssrf_proxy.get(decoded_url, timeout=3)
resp.raise_for_status()
return RemoteFileInfo(
file_type=resp.headers.get("Content-Type", "application/octet-stream"),
file_length=int(resp.headers.get("Content-Length", 0)),
).model_dump(mode="json")
resp = ssrf_proxy.get(url=url, timeout=3, follow_redirects=True)
if resp.status_code != httpx.codes.OK:
raise RemoteFileUploadError(f"Failed to fetch file from {url}: {resp.text}")
except httpx.RequestError as e:
raise RemoteFileUploadError(f"Failed to fetch file from {url}: {str(e)}")
file_info = helpers.guess_file_info_from_response(resp)
@console_ns.route("/remote-files/upload")
class RemoteFileUpload(Resource):
@login_required
def post(self):
payload = RemoteFileUploadPayload.model_validate(console_ns.payload)
url = payload.url
if not FileService.is_file_size_within_limit(extension=file_info.extension, file_size=file_info.size):
raise FileTooLargeError
# Try to fetch remote file metadata/content first
try:
resp = ssrf_proxy.head(url=url)
if resp.status_code != httpx.codes.OK:
resp = ssrf_proxy.get(url=url, timeout=3, follow_redirects=True)
if resp.status_code != httpx.codes.OK:
# Normalize into a user-friendly error message expected by tests
raise RemoteFileUploadError(f"Failed to fetch file from {url}: {resp.text}")
except httpx.RequestError as e:
raise RemoteFileUploadError(f"Failed to fetch file from {url}: {str(e)}")
content = resp.content if resp.request.method == "GET" else ssrf_proxy.get(url).content
file_info = helpers.guess_file_info_from_response(resp)
# Enforce file size limit with 400 (Bad Request) per tests' expectation
if not FileService.is_file_size_within_limit(extension=file_info.extension, file_size=file_info.size):
raise FileTooLargeError()
# Load content if needed
content = resp.content if resp.request.method == "GET" else ssrf_proxy.get(url).content
try:
user, _ = current_account_with_tenant()
upload_file = FileService(db.engine).upload_file(
filename=file_info.filename,
content=content,
mimetype=file_info.mimetype,
user=user,
source_url=url,
)
except services.errors.file.FileTooLargeError as file_too_large_error:
raise FileTooLargeError(file_too_large_error.description)
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
# Success: return created resource with 201 status
return (
FileWithSignedUrl(
id=upload_file.id,
name=upload_file.name,
size=upload_file.size,
extension=upload_file.extension,
url=file_helpers.get_signed_file_url(upload_file_id=upload_file.id),
mime_type=upload_file.mime_type,
created_by=upload_file.created_by,
created_at=int(upload_file.created_at.timestamp()),
).model_dump(mode="json"),
201,
try:
user, _ = current_account_with_tenant()
upload_file = FileService(db.engine).upload_file(
filename=file_info.filename,
content=content,
mimetype=file_info.mimetype,
user=user,
source_url=url,
)
except services.errors.file.FileTooLargeError as file_too_large_error:
raise FileTooLargeError(file_too_large_error.description)
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
return FileWithSignedUrl(
id=upload_file.id,
name=upload_file.name,
size=upload_file.size,
extension=upload_file.extension,
url=file_helpers.get_signed_file_url(upload_file_id=upload_file.id),
mime_type=upload_file.mime_type,
created_by=upload_file.created_by,
created_at=int(upload_file.created_at.timestamp()),
)

View File

@@ -1,39 +0,0 @@
"""fix index to optimize message clean job performance
Revision ID: fce013ca180e
Revises: f55813ffe2c8
Create Date: 2026-02-11 15:49:17.603638
"""
from alembic import op
import models as models
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fce013ca180e'
down_revision = 'f55813ffe2c8'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('messages', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('message_created_at_idx'))
with op.batch_alter_table('saved_messages', schema=None) as batch_op:
batch_op.create_index('saved_message_message_id_idx', ['message_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('saved_messages', schema=None) as batch_op:
batch_op.drop_index('saved_message_message_id_idx')
with op.batch_alter_table('messages', schema=None) as batch_op:
batch_op.create_index(batch_op.f('message_created_at_idx'), ['created_at'], unique=False)
# ### end Alembic commands ###

View File

@@ -1040,6 +1040,7 @@ class Message(Base):
Index("message_end_user_idx", "app_id", "from_source", "from_end_user_id"),
Index("message_account_idx", "app_id", "from_source", "from_account_id"),
Index("message_workflow_run_id_idx", "conversation_id", "workflow_run_id"),
Index("message_created_at_idx", "created_at"),
Index("message_app_mode_idx", "app_mode"),
Index("message_created_at_id_idx", "created_at", "id"),
)

View File

@@ -16,7 +16,6 @@ class SavedMessage(TypeBase):
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="saved_message_pkey"),
sa.Index("saved_message_message_idx", "app_id", "message_id", "created_by_role", "created_by"),
sa.Index("saved_message_message_id_idx", "message_id"),
)
id: Mapped[str] = mapped_column(

View File

@@ -1,6 +1,6 @@
[project]
name = "dify-api"
version = "1.13.0"
version = "1.12.1"
requires-python = ">=3.11,<3.13"
dependencies = [
@@ -23,7 +23,7 @@ dependencies = [
"gevent~=25.9.1",
"gmpy2~=2.2.1",
"google-api-core==2.18.0",
"google-api-python-client==2.189.0",
"google-api-python-client==2.90.0",
"google-auth==2.29.0",
"google-auth-httplib2==0.2.0",
"google-cloud-aiplatform==1.49.0",

View File

@@ -1,13 +1,10 @@
import datetime
import logging
import os
import random
import time
from collections.abc import Sequence
from typing import cast
import sqlalchemy as sa
from sqlalchemy import delete, select, tuple_
from sqlalchemy import delete, select
from sqlalchemy.engine import CursorResult
from sqlalchemy.orm import Session
@@ -196,15 +193,11 @@ class MessagesCleanService:
self._end_before,
)
max_batch_interval_ms = int(os.environ.get("SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL", 200))
while True:
stats["batches"] += 1
batch_start = time.monotonic()
# Step 1: Fetch a batch of messages using cursor
with Session(db.engine, expire_on_commit=False) as session:
fetch_messages_start = time.monotonic()
msg_stmt = (
select(Message.id, Message.app_id, Message.created_at)
.where(Message.created_at < self._end_before)
@@ -216,13 +209,13 @@ class MessagesCleanService:
msg_stmt = msg_stmt.where(Message.created_at >= self._start_from)
# Apply cursor condition: (created_at, id) > (last_created_at, last_message_id)
# This translates to:
# created_at > last_created_at OR (created_at = last_created_at AND id > last_message_id)
if _cursor:
# Continuing from previous batch
msg_stmt = msg_stmt.where(
tuple_(Message.created_at, Message.id)
> tuple_(
sa.literal(_cursor[0], type_=sa.DateTime()),
sa.literal(_cursor[1], type_=Message.id.type),
)
(Message.created_at > _cursor[0])
| ((Message.created_at == _cursor[0]) & (Message.id > _cursor[1]))
)
raw_messages = list(session.execute(msg_stmt).all())
@@ -230,12 +223,6 @@ class MessagesCleanService:
SimpleMessage(id=msg_id, app_id=app_id, created_at=msg_created_at)
for msg_id, app_id, msg_created_at in raw_messages
]
logger.info(
"clean_messages (batch %s): fetched %s messages in %sms",
stats["batches"],
len(messages),
int((time.monotonic() - fetch_messages_start) * 1000),
)
# Track total messages fetched across all batches
stats["total_messages"] += len(messages)
@@ -254,16 +241,8 @@ class MessagesCleanService:
logger.info("clean_messages (batch %s): no app_ids found, skip", stats["batches"])
continue
fetch_apps_start = time.monotonic()
app_stmt = select(App.id, App.tenant_id).where(App.id.in_(app_ids))
apps = list(session.execute(app_stmt).all())
logger.info(
"clean_messages (batch %s): fetched %s apps for %s app_ids in %sms",
stats["batches"],
len(apps),
len(app_ids),
int((time.monotonic() - fetch_apps_start) * 1000),
)
if not apps:
logger.info("clean_messages (batch %s): no apps found, skip", stats["batches"])
@@ -273,15 +252,7 @@ class MessagesCleanService:
app_to_tenant: dict[str, str] = {app.id: app.tenant_id for app in apps}
# Step 3: Delegate to policy to determine which messages to delete
policy_start = time.monotonic()
message_ids_to_delete = self._policy.filter_message_ids(messages, app_to_tenant)
logger.info(
"clean_messages (batch %s): policy selected %s/%s messages in %sms",
stats["batches"],
len(message_ids_to_delete),
len(messages),
int((time.monotonic() - policy_start) * 1000),
)
if not message_ids_to_delete:
logger.info("clean_messages (batch %s): no messages to delete, skip", stats["batches"])
@@ -292,20 +263,14 @@ class MessagesCleanService:
# Step 4: Batch delete messages and their relations
if not self._dry_run:
with Session(db.engine, expire_on_commit=False) as session:
delete_relations_start = time.monotonic()
# Delete related records first
self._batch_delete_message_relations(session, message_ids_to_delete)
delete_relations_ms = int((time.monotonic() - delete_relations_start) * 1000)
# Delete messages
delete_messages_start = time.monotonic()
delete_stmt = delete(Message).where(Message.id.in_(message_ids_to_delete))
delete_result = cast(CursorResult, session.execute(delete_stmt))
messages_deleted = delete_result.rowcount
delete_messages_ms = int((time.monotonic() - delete_messages_start) * 1000)
commit_start = time.monotonic()
session.commit()
commit_ms = int((time.monotonic() - commit_start) * 1000)
stats["total_deleted"] += messages_deleted
@@ -315,19 +280,6 @@ class MessagesCleanService:
len(messages),
messages_deleted,
)
logger.info(
"clean_messages (batch %s): relations %sms, messages %sms, commit %sms, batch total %sms",
stats["batches"],
delete_relations_ms,
delete_messages_ms,
commit_ms,
int((time.monotonic() - batch_start) * 1000),
)
# Random sleep between batches to avoid overwhelming the database
sleep_ms = random.uniform(0, max_batch_interval_ms) # noqa: S311
logger.info("clean_messages (batch %s): sleeping for %.2fms", stats["batches"], sleep_ms)
time.sleep(sleep_ms / 1000)
else:
# Log random sample of message IDs that would be deleted (up to 10)
sample_size = min(10, len(message_ids_to_delete))

View File

@@ -1,8 +1,5 @@
import datetime
import logging
import os
import random
import time
from collections.abc import Iterable, Sequence
import click
@@ -75,12 +72,7 @@ class WorkflowRunCleanup:
batch_index = 0
last_seen: tuple[datetime.datetime, str] | None = None
max_batch_interval_ms = int(os.environ.get("SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL", 200))
while True:
batch_start = time.monotonic()
fetch_start = time.monotonic()
run_rows = self.workflow_run_repo.get_runs_batch_by_time_range(
start_from=self.window_start,
end_before=self.window_end,
@@ -88,30 +80,12 @@ class WorkflowRunCleanup:
batch_size=self.batch_size,
)
if not run_rows:
logger.info("workflow_run_cleanup (batch #%s): no more rows to process", batch_index + 1)
break
batch_index += 1
last_seen = (run_rows[-1].created_at, run_rows[-1].id)
logger.info(
"workflow_run_cleanup (batch #%s): fetched %s rows in %sms",
batch_index,
len(run_rows),
int((time.monotonic() - fetch_start) * 1000),
)
tenant_ids = {row.tenant_id for row in run_rows}
filter_start = time.monotonic()
free_tenants = self._filter_free_tenants(tenant_ids)
logger.info(
"workflow_run_cleanup (batch #%s): filtered %s free tenants from %s tenants in %sms",
batch_index,
len(free_tenants),
len(tenant_ids),
int((time.monotonic() - filter_start) * 1000),
)
free_runs = [row for row in run_rows if row.tenant_id in free_tenants]
paid_or_skipped = len(run_rows) - len(free_runs)
@@ -130,17 +104,11 @@ class WorkflowRunCleanup:
total_runs_targeted += len(free_runs)
if self.dry_run:
count_start = time.monotonic()
batch_counts = self.workflow_run_repo.count_runs_with_related(
free_runs,
count_node_executions=self._count_node_executions,
count_trigger_logs=self._count_trigger_logs,
)
logger.info(
"workflow_run_cleanup (batch #%s, dry_run): counted related records in %sms",
batch_index,
int((time.monotonic() - count_start) * 1000),
)
if related_totals is not None:
for key in related_totals:
related_totals[key] += batch_counts.get(key, 0)
@@ -152,21 +120,14 @@ class WorkflowRunCleanup:
fg="yellow",
)
)
logger.info(
"workflow_run_cleanup (batch #%s, dry_run): batch total %sms",
batch_index,
int((time.monotonic() - batch_start) * 1000),
)
continue
try:
delete_start = time.monotonic()
counts = self.workflow_run_repo.delete_runs_with_related(
free_runs,
delete_node_executions=self._delete_node_executions,
delete_trigger_logs=self._delete_trigger_logs,
)
delete_ms = int((time.monotonic() - delete_start) * 1000)
except Exception:
logger.exception("Failed to delete workflow runs batch ending at %s", last_seen[0])
raise
@@ -182,17 +143,6 @@ class WorkflowRunCleanup:
fg="green",
)
)
logger.info(
"workflow_run_cleanup (batch #%s): delete %sms, batch total %sms",
batch_index,
delete_ms,
int((time.monotonic() - batch_start) * 1000),
)
# Random sleep between batches to avoid overwhelming the database
sleep_ms = random.uniform(0, max_batch_interval_ms) # noqa: S311
logger.info("workflow_run_cleanup (batch #%s): sleeping for %.2fms", batch_index, sleep_ms)
time.sleep(sleep_ms / 1000)
if self.dry_run:
if self.window_start:

View File

@@ -1,286 +1,92 @@
"""Tests for remote file upload API endpoints using Flask-RESTX."""
import contextlib
import builtins
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import Mock, patch
from unittest.mock import patch
import httpx
import pytest
from flask import Flask, g
from flask import Flask
from flask.views import MethodView
from extensions import ext_fastopenapi
if not hasattr(builtins, "MethodView"):
builtins.MethodView = MethodView # type: ignore[attr-defined]
@pytest.fixture
def app() -> Flask:
"""Create Flask app for testing."""
app = Flask(__name__)
app.config["TESTING"] = True
app.config["SECRET_KEY"] = "test-secret-key"
return app
@pytest.fixture
def client(app):
"""Create test client with console blueprint registered."""
from controllers.console import bp
def test_console_remote_files_fastopenapi_get_info(app: Flask):
ext_fastopenapi.init_app(app)
app.register_blueprint(bp)
return app.test_client()
@pytest.fixture
def mock_account():
"""Create a mock account for testing."""
from models import Account
account = Mock(spec=Account)
account.id = "test-account-id"
account.current_tenant_id = "test-tenant-id"
return account
@pytest.fixture
def auth_ctx(app, mock_account):
"""Context manager to set auth/tenant context in flask.g for a request."""
@contextlib.contextmanager
def _ctx():
with app.test_request_context():
g._login_user = mock_account
g._current_tenant = mock_account.current_tenant_id
yield
return _ctx
class TestGetRemoteFileInfo:
"""Test GET /console/api/remote-files/<path:url> endpoint."""
def test_get_remote_file_info_success(self, app, client, mock_account):
"""Test successful retrieval of remote file info."""
response = httpx.Response(
200,
request=httpx.Request("HEAD", "http://example.com/file.txt"),
headers={"Content-Type": "text/plain", "Content-Length": "1024"},
)
with (
patch(
"controllers.console.remote_files.current_account_with_tenant",
return_value=(mock_account, "test-tenant-id"),
),
patch("controllers.console.remote_files.ssrf_proxy.head", return_value=response),
patch("libs.login.check_csrf_token", return_value=None),
):
with app.test_request_context():
g._login_user = mock_account
g._current_tenant = mock_account.current_tenant_id
encoded_url = "http%3A%2F%2Fexample.com%2Ffile.txt"
resp = client.get(f"/console/api/remote-files/{encoded_url}")
assert resp.status_code == 200
data = resp.get_json()
assert data["file_type"] == "text/plain"
assert data["file_length"] == 1024
def test_get_remote_file_info_fallback_to_get_on_head_failure(self, app, client, mock_account):
"""Test fallback to GET when HEAD returns non-200 status."""
head_response = httpx.Response(
404,
request=httpx.Request("HEAD", "http://example.com/file.pdf"),
)
get_response = httpx.Response(
200,
request=httpx.Request("GET", "http://example.com/file.pdf"),
headers={"Content-Type": "application/pdf", "Content-Length": "2048"},
)
with (
patch(
"controllers.console.remote_files.current_account_with_tenant",
return_value=(mock_account, "test-tenant-id"),
),
patch("controllers.console.remote_files.ssrf_proxy.head", return_value=head_response),
patch("controllers.console.remote_files.ssrf_proxy.get", return_value=get_response),
patch("libs.login.check_csrf_token", return_value=None),
):
with app.test_request_context():
g._login_user = mock_account
g._current_tenant = mock_account.current_tenant_id
encoded_url = "http%3A%2F%2Fexample.com%2Ffile.pdf"
resp = client.get(f"/console/api/remote-files/{encoded_url}")
assert resp.status_code == 200
data = resp.get_json()
assert data["file_type"] == "application/pdf"
assert data["file_length"] == 2048
class TestRemoteFileUpload:
"""Test POST /console/api/remote-files/upload endpoint."""
@pytest.mark.parametrize(
("head_status", "use_get"),
[
(200, False), # HEAD succeeds
(405, True), # HEAD fails -> fallback GET
],
response = httpx.Response(
200,
request=httpx.Request("HEAD", "http://example.com/file.txt"),
headers={"Content-Type": "text/plain", "Content-Length": "10"},
)
def test_upload_remote_file_success_paths(self, client, mock_account, auth_ctx, head_status, use_get):
url = "http://example.com/file.pdf"
head_resp = httpx.Response(
head_status,
request=httpx.Request("HEAD", url),
headers={"Content-Type": "application/pdf", "Content-Length": "1024"},
)
get_resp = httpx.Response(
200,
request=httpx.Request("GET", url),
headers={"Content-Type": "application/pdf", "Content-Length": "1024"},
content=b"file content",
)
file_info = SimpleNamespace(
extension="pdf",
size=1024,
filename="file.pdf",
mimetype="application/pdf",
)
uploaded_file = SimpleNamespace(
id="uploaded-file-id",
name="file.pdf",
size=1024,
extension="pdf",
mime_type="application/pdf",
created_by="test-account-id",
created_at=datetime(2024, 1, 1, 12, 0, 0),
)
with patch("controllers.console.remote_files.ssrf_proxy.head", return_value=response):
client = app.test_client()
encoded_url = "http%3A%2F%2Fexample.com%2Ffile.txt"
resp = client.get(f"/console/api/remote-files/{encoded_url}")
with (
patch(
"controllers.console.remote_files.current_account_with_tenant",
return_value=(mock_account, "test-tenant-id"),
),
patch("controllers.console.remote_files.ssrf_proxy.head", return_value=head_resp) as p_head,
patch("controllers.console.remote_files.ssrf_proxy.get", return_value=get_resp) as p_get,
patch(
"controllers.console.remote_files.helpers.guess_file_info_from_response",
return_value=file_info,
),
patch(
"controllers.console.remote_files.FileService.is_file_size_within_limit",
return_value=True,
),
patch("controllers.console.remote_files.db", spec=["engine"]),
patch("controllers.console.remote_files.FileService") as mock_file_service,
patch(
"controllers.console.remote_files.file_helpers.get_signed_file_url",
return_value="http://example.com/signed-url",
),
patch("libs.login.check_csrf_token", return_value=None),
):
mock_file_service.return_value.upload_file.return_value = uploaded_file
assert resp.status_code == 200
assert resp.get_json() == {"file_type": "text/plain", "file_length": 10}
with auth_ctx():
resp = client.post(
"/console/api/remote-files/upload",
json={"url": url},
)
assert resp.status_code == 201
p_head.assert_called_once()
# GET is used either for fallback (HEAD fails) or to fetch content after HEAD succeeds
p_get.assert_called_once()
mock_file_service.return_value.upload_file.assert_called_once()
def test_console_remote_files_fastopenapi_upload(app: Flask):
ext_fastopenapi.init_app(app)
data = resp.get_json()
assert data["id"] == "uploaded-file-id"
assert data["name"] == "file.pdf"
assert data["size"] == 1024
assert data["extension"] == "pdf"
assert data["url"] == "http://example.com/signed-url"
assert data["mime_type"] == "application/pdf"
assert data["created_by"] == "test-account-id"
@pytest.mark.parametrize(
("size_ok", "raises", "expected_status", "expected_msg"),
[
# When size check fails in controller, API returns 413 with message "File size exceeded..."
(False, None, 413, "file size exceeded"),
# When service raises unsupported type, controller maps to 415 with message "File type not allowed."
(True, "unsupported", 415, "file type not allowed"),
],
head_response = httpx.Response(
200,
request=httpx.Request("GET", "http://example.com/file.txt"),
content=b"hello",
)
def test_upload_remote_file_errors(
self, client, mock_account, auth_ctx, size_ok, raises, expected_status, expected_msg
file_info = SimpleNamespace(
extension="txt",
size=5,
filename="file.txt",
mimetype="text/plain",
)
uploaded = SimpleNamespace(
id="file-id",
name="file.txt",
size=5,
extension="txt",
mime_type="text/plain",
created_by="user-id",
created_at=datetime(2024, 1, 1),
)
with (
patch("controllers.console.remote_files.db", new=SimpleNamespace(engine=object())),
patch("controllers.console.remote_files.ssrf_proxy.head", return_value=head_response),
patch("controllers.console.remote_files.helpers.guess_file_info_from_response", return_value=file_info),
patch("controllers.console.remote_files.FileService.is_file_size_within_limit", return_value=True),
patch("controllers.console.remote_files.FileService.__init__", return_value=None),
patch("controllers.console.remote_files.current_account_with_tenant", return_value=(object(), "tenant-id")),
patch("controllers.console.remote_files.FileService.upload_file", return_value=uploaded),
patch("controllers.console.remote_files.file_helpers.get_signed_file_url", return_value="signed-url"),
):
url = "http://example.com/x.pdf"
head_resp = httpx.Response(
200,
request=httpx.Request("HEAD", url),
headers={"Content-Type": "application/pdf", "Content-Length": "9"},
client = app.test_client()
resp = client.post(
"/console/api/remote-files/upload",
json={"url": "http://example.com/file.txt"},
)
file_info = SimpleNamespace(extension="pdf", size=9, filename="x.pdf", mimetype="application/pdf")
with (
patch(
"controllers.console.remote_files.current_account_with_tenant",
return_value=(mock_account, "test-tenant-id"),
),
patch("controllers.console.remote_files.ssrf_proxy.head", return_value=head_resp),
patch(
"controllers.console.remote_files.helpers.guess_file_info_from_response",
return_value=file_info,
),
patch(
"controllers.console.remote_files.FileService.is_file_size_within_limit",
return_value=size_ok,
),
patch("controllers.console.remote_files.db", spec=["engine"]),
patch("libs.login.check_csrf_token", return_value=None),
):
if raises == "unsupported":
from services.errors.file import UnsupportedFileTypeError
with patch("controllers.console.remote_files.FileService") as mock_file_service:
mock_file_service.return_value.upload_file.side_effect = UnsupportedFileTypeError("bad")
with auth_ctx():
resp = client.post(
"/console/api/remote-files/upload",
json={"url": url},
)
else:
with auth_ctx():
resp = client.post(
"/console/api/remote-files/upload",
json={"url": url},
)
assert resp.status_code == expected_status
data = resp.get_json()
msg = (data.get("error") or {}).get("message") or data.get("message", "")
assert expected_msg in msg.lower()
def test_upload_remote_file_fetch_failure(self, client, mock_account, auth_ctx):
"""Test upload when fetching of remote file fails."""
with (
patch(
"controllers.console.remote_files.current_account_with_tenant",
return_value=(mock_account, "test-tenant-id"),
),
patch(
"controllers.console.remote_files.ssrf_proxy.head",
side_effect=httpx.RequestError("Connection failed"),
),
patch("libs.login.check_csrf_token", return_value=None),
):
with auth_ctx():
resp = client.post(
"/console/api/remote-files/upload",
json={"url": "http://unreachable.com/file.pdf"},
)
assert resp.status_code == 400
data = resp.get_json()
msg = (data.get("error") or {}).get("message") or data.get("message", "")
assert "failed to fetch" in msg.lower()
assert resp.status_code == 201
assert resp.get_json() == {
"id": "file-id",
"name": "file.txt",
"size": 5,
"extension": "txt",
"url": "signed-url",
"mime_type": "text/plain",
"created_by": "user-id",
"created_at": int(uploaded.created_at.timestamp()),
}

View File

@@ -496,9 +496,6 @@ class TestSchemaResolverClass:
avg_time_no_cache = sum(results1) / len(results1)
# Second run (with cache) - run multiple times
# Warm up cache first
resolve_dify_schema_refs(schema)
results2 = []
for _ in range(3):
start = time.perf_counter()

84
api/uv.lock generated
View File

@@ -1237,47 +1237,49 @@ wheels = [
[[package]]
name = "cryptography"
version = "46.0.5"
version = "46.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
{ url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" },
{ url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" },
{ url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" },
{ url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" },
{ url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" },
{ url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" },
{ url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
{ url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
{ url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
{ url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
{ url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" },
{ url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" },
{ url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" },
{ url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" },
{ url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" },
{ url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" },
]
[[package]]
@@ -1366,7 +1368,7 @@ wheels = [
[[package]]
name = "dify-api"
version = "1.13.0"
version = "1.12.1"
source = { virtual = "." }
dependencies = [
{ name = "aliyun-log-python-sdk" },
@@ -1592,7 +1594,7 @@ requires-dist = [
{ name = "gevent", specifier = "~=25.9.1" },
{ name = "gmpy2", specifier = "~=2.2.1" },
{ name = "google-api-core", specifier = "==2.18.0" },
{ name = "google-api-python-client", specifier = "==2.189.0" },
{ name = "google-api-python-client", specifier = "==2.90.0" },
{ name = "google-auth", specifier = "==2.29.0" },
{ name = "google-auth-httplib2", specifier = "==0.2.0" },
{ name = "google-cloud-aiplatform", specifier = "==1.49.0" },
@@ -2304,7 +2306,7 @@ grpc = [
[[package]]
name = "google-api-python-client"
version = "2.189.0"
version = "2.90.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core" },
@@ -2313,9 +2315,9 @@ dependencies = [
{ name = "httplib2" },
{ name = "uritemplate" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6f/f8/0783aeca3410ee053d4dd1fccafd85197847b8f84dd038e036634605d083/google_api_python_client-2.189.0.tar.gz", hash = "sha256:45f2d8559b5c895dde6ad3fb33de025f5cb2c197fa5862f18df7f5295a172741", size = 13979470, upload-time = "2026-02-03T19:24:55.432Z" }
sdist = { url = "https://files.pythonhosted.org/packages/35/8b/d990f947c261304a5c1599d45717d02c27d46af5f23e1fee5dc19c8fa79d/google-api-python-client-2.90.0.tar.gz", hash = "sha256:cbcb3ba8be37c6806676a49df16ac412077e5e5dc7fa967941eff977b31fba03", size = 10891311, upload-time = "2023-06-20T16:29:25.008Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/44/3677ff27998214f2fa7957359da48da378a0ffff1bd0bdaba42e752bc13e/google_api_python_client-2.189.0-py3-none-any.whl", hash = "sha256:a258c09660a49c6159173f8bbece171278e917e104a11f0640b34751b79c8a1a", size = 14547633, upload-time = "2026-02-03T19:24:52.845Z" },
{ url = "https://files.pythonhosted.org/packages/39/03/209b5c36a621ae644dc7d4743746cd3b38b18e133f8779ecaf6b95cc01ce/google_api_python_client-2.90.0-py2.py3-none-any.whl", hash = "sha256:4a41ffb7797d4f28e44635fb1e7076240b741c6493e7c3233c0e4421cec7c913", size = 11379891, upload-time = "2023-06-20T16:29:19.532Z" },
]
[[package]]

View File

@@ -1523,7 +1523,6 @@ AMPLITUDE_API_KEY=
# Sandbox expired records clean configuration
SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL=200
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30

View File

@@ -21,7 +21,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.13.0
image: langgenius/dify-api:1.12.1
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.13.0
image: langgenius/dify-api:1.12.1
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.13.0
image: langgenius/dify-api:1.12.1
restart: always
environment:
# Use the shared environment variables.
@@ -132,7 +132,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.13.0
image: langgenius/dify-web:1.12.1
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}

View File

@@ -684,7 +684,6 @@ x-shared-env: &shared-api-worker-env
AMPLITUDE_API_KEY: ${AMPLITUDE_API_KEY:-}
SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD: ${SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD:-21}
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE:-1000}
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL:-200}
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS: ${SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS:-30}
PUBSUB_REDIS_URL: ${PUBSUB_REDIS_URL:-}
PUBSUB_REDIS_CHANNEL_TYPE: ${PUBSUB_REDIS_CHANNEL_TYPE:-pubsub}
@@ -715,7 +714,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.13.0
image: langgenius/dify-api:1.12.1
restart: always
environment:
# Use the shared environment variables.
@@ -757,7 +756,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.13.0
image: langgenius/dify-api:1.12.1
restart: always
environment:
# Use the shared environment variables.
@@ -796,7 +795,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.13.0
image: langgenius/dify-api:1.12.1
restart: always
environment:
# Use the shared environment variables.
@@ -826,7 +825,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.13.0
image: langgenius/dify-web:1.12.1
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}

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

@@ -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

@@ -204,10 +204,23 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
}
}
catch {
// Avoid executing arbitrary code; require valid JSON for chart options.
setChartState('error')
processedRef.current = true
return
try {
// eslint-disable-next-line no-new-func
const result = new Function(`return ${trimmedContent}`)()
if (typeof result === 'object' && result !== null) {
setFinalChartOption(result)
setChartState('success')
processedRef.current = true
return
}
}
catch {
// If we have a complete JSON structure but it doesn't parse,
// it's likely an error rather than incomplete data
setChartState('error')
processedRef.current = true
return
}
}
}
@@ -236,9 +249,19 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
}
}
catch {
// Only accept JSON to avoid executing arbitrary code from the message.
setChartState('error')
processedRef.current = true
try {
// eslint-disable-next-line no-new-func
const result = new Function(`return ${trimmedContent}`)()
if (typeof result === 'object' && result !== null) {
setFinalChartOption(result)
isValidOption = true
}
}
catch {
// Both parsing methods failed, but content looks complete
setChartState('error')
processedRef.current = true
}
}
if (isValidOption) {

View File

@@ -0,0 +1,309 @@
import type { QA } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkContainer, ChunkLabel, QAPreview } from '../chunk'
vi.mock('../../base/icons/src/public/knowledge', () => ({
SelectionMod: (props: React.ComponentProps<'svg'>) => (
<svg data-testid="selection-mod-icon" {...props} />
),
}))
function createQA(overrides: Partial<QA> = {}): QA {
return {
question: 'What is Dify?',
answer: 'Dify is an open-source LLM app development platform.',
...overrides,
}
}
describe('ChunkLabel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render the label text', () => {
render(<ChunkLabel label="Chunk #1" characterCount={100} />)
expect(screen.getByText('Chunk #1')).toBeInTheDocument()
})
it('should render the character count with unit', () => {
render(<ChunkLabel label="Chunk #1" characterCount={256} />)
expect(screen.getByText('256 characters')).toBeInTheDocument()
})
it('should render the SelectionMod icon', () => {
render(<ChunkLabel label="Chunk" characterCount={10} />)
expect(screen.getByTestId('selection-mod-icon')).toBeInTheDocument()
})
it('should render a middle dot separator between label and count', () => {
render(<ChunkLabel label="Chunk" characterCount={10} />)
expect(screen.getByText('·')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should display zero character count', () => {
render(<ChunkLabel label="Empty Chunk" characterCount={0} />)
expect(screen.getByText('0 characters')).toBeInTheDocument()
})
it('should display large character counts', () => {
render(<ChunkLabel label="Large" characterCount={999999} />)
expect(screen.getByText('999999 characters')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should render with empty label', () => {
render(<ChunkLabel label="" characterCount={50} />)
expect(screen.getByText('50 characters')).toBeInTheDocument()
})
it('should render with special characters in label', () => {
render(<ChunkLabel label="Chunk <#1> & 'test'" characterCount={10} />)
expect(screen.getByText('Chunk <#1> & \'test\'')).toBeInTheDocument()
})
})
})
// Tests for ChunkContainer - wraps ChunkLabel with children content area
describe('ChunkContainer', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render ChunkLabel with correct props', () => {
render(
<ChunkContainer label="Chunk #1" characterCount={200}>
Content here
</ChunkContainer>,
)
expect(screen.getByText('Chunk #1')).toBeInTheDocument()
expect(screen.getByText('200 characters')).toBeInTheDocument()
})
it('should render children in the content area', () => {
render(
<ChunkContainer label="Chunk" characterCount={50}>
<p>Paragraph content</p>
</ChunkContainer>,
)
expect(screen.getByText('Paragraph content')).toBeInTheDocument()
})
it('should render the SelectionMod icon via ChunkLabel', () => {
render(
<ChunkContainer label="Chunk" characterCount={10}>
Content
</ChunkContainer>,
)
expect(screen.getByTestId('selection-mod-icon')).toBeInTheDocument()
})
})
describe('Structure', () => {
it('should have space-y-2 on the outer container', () => {
const { container } = render(
<ChunkContainer label="Chunk" characterCount={10}>Content</ChunkContainer>,
)
expect(container.firstElementChild).toHaveClass('space-y-2')
})
it('should render children inside a styled content div', () => {
render(
<ChunkContainer label="Chunk" characterCount={10}>
<span>Test child</span>
</ChunkContainer>,
)
const contentDiv = screen.getByText('Test child').parentElement
expect(contentDiv).toHaveClass('body-md-regular', 'text-text-secondary')
})
})
describe('Edge Cases', () => {
it('should render without children', () => {
const { container } = render(
<ChunkContainer label="Empty" characterCount={0} />,
)
expect(container.firstElementChild).toBeInTheDocument()
expect(screen.getByText('Empty')).toBeInTheDocument()
})
it('should render multiple children', () => {
render(
<ChunkContainer label="Multi" characterCount={100}>
<span>First</span>
<span>Second</span>
</ChunkContainer>,
)
expect(screen.getByText('First')).toBeInTheDocument()
expect(screen.getByText('Second')).toBeInTheDocument()
})
it('should render with string children', () => {
render(
<ChunkContainer label="Text" characterCount={5}>
Plain text content
</ChunkContainer>,
)
expect(screen.getByText('Plain text content')).toBeInTheDocument()
})
})
})
// Tests for QAPreview - displays question and answer pair
describe('QAPreview', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render the question text', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
expect(screen.getByText('What is Dify?')).toBeInTheDocument()
})
it('should render the answer text', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
expect(screen.getByText('Dify is an open-source LLM app development platform.')).toBeInTheDocument()
})
it('should render Q and A labels', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
})
describe('Structure', () => {
it('should render Q label as a label element', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
const qLabel = screen.getByText('Q')
expect(qLabel.tagName).toBe('LABEL')
})
it('should render A label as a label element', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
const aLabel = screen.getByText('A')
expect(aLabel.tagName).toBe('LABEL')
})
it('should render question in a p element', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
const questionEl = screen.getByText(qa.question)
expect(questionEl.tagName).toBe('P')
})
it('should render answer in a p element', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
const answerEl = screen.getByText(qa.answer)
expect(answerEl.tagName).toBe('P')
})
it('should have the outer container with flex column layout', () => {
const qa = createQA()
const { container } = render(<QAPreview qa={qa} />)
expect(container.firstElementChild).toHaveClass('flex', 'flex-col', 'gap-y-2')
})
it('should apply text styling classes to question paragraph', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
const questionEl = screen.getByText(qa.question)
expect(questionEl).toHaveClass('body-md-regular', 'text-text-secondary')
})
it('should apply text styling classes to answer paragraph', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
const answerEl = screen.getByText(qa.answer)
expect(answerEl).toHaveClass('body-md-regular', 'text-text-secondary')
})
})
describe('Edge Cases', () => {
it('should render with empty question', () => {
const qa = createQA({ question: '' })
render(<QAPreview qa={qa} />)
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should render with empty answer', () => {
const qa = createQA({ answer: '' })
render(<QAPreview qa={qa} />)
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText(qa.question)).toBeInTheDocument()
})
it('should render with long text', () => {
const longText = 'x'.repeat(1000)
const qa = createQA({ question: longText, answer: longText })
render(<QAPreview qa={qa} />)
const elements = screen.getAllByText(longText)
expect(elements).toHaveLength(2)
})
it('should render with special characters in question and answer', () => {
const qa = createQA({
question: 'What about <html> & "quotes"?',
answer: 'It handles \'single\' & "double" quotes.',
})
render(<QAPreview qa={qa} />)
expect(screen.getByText('What about <html> & "quotes"?')).toBeInTheDocument()
expect(screen.getByText('It handles \'single\' & "double" quotes.')).toBeInTheDocument()
})
it('should render with multiline text', () => {
const qa = createQA({
question: 'Line1\nLine2',
answer: 'Answer1\nAnswer2',
})
render(<QAPreview qa={qa} />)
expect(screen.getByText(/Line1/)).toBeInTheDocument()
expect(screen.getByText(/Answer1/)).toBeInTheDocument()
})
})
})

View File

@@ -1,6 +1,6 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import DatasetsLoading from './loading'
import DatasetsLoading from '../loading'
afterEach(() => {
cleanup()

View File

@@ -1,13 +1,6 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import NoLinkedAppsPanel from './no-linked-apps-panel'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
import NoLinkedAppsPanel from '../no-linked-apps-panel'
// Mock useDocLink
vi.mock('@/context/i18n', () => ({
@@ -21,17 +14,17 @@ afterEach(() => {
describe('NoLinkedAppsPanel', () => {
it('should render without crashing', () => {
render(<NoLinkedAppsPanel />)
expect(screen.getByText('datasetMenus.emptyTip')).toBeInTheDocument()
expect(screen.getByText('common.datasetMenus.emptyTip')).toBeInTheDocument()
})
it('should render the empty tip text', () => {
render(<NoLinkedAppsPanel />)
expect(screen.getByText('datasetMenus.emptyTip')).toBeInTheDocument()
expect(screen.getByText('common.datasetMenus.emptyTip')).toBeInTheDocument()
})
it('should render the view doc link', () => {
render(<NoLinkedAppsPanel />)
expect(screen.getByText('datasetMenus.viewDoc')).toBeInTheDocument()
expect(screen.getByText('common.datasetMenus.viewDoc')).toBeInTheDocument()
})
it('should render link with correct href', () => {

View File

@@ -1,6 +1,6 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import ApiIndex from './index'
import ApiIndex from '../index'
afterEach(() => {
cleanup()

View File

@@ -1,111 +0,0 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { ChunkContainer, ChunkLabel, QAPreview } from './chunk'
afterEach(() => {
cleanup()
})
describe('ChunkLabel', () => {
it('should render label text', () => {
render(<ChunkLabel label="Chunk 1" characterCount={100} />)
expect(screen.getByText('Chunk 1')).toBeInTheDocument()
})
it('should render character count', () => {
render(<ChunkLabel label="Chunk 1" characterCount={150} />)
expect(screen.getByText('150 characters')).toBeInTheDocument()
})
it('should render separator dot', () => {
render(<ChunkLabel label="Chunk 1" characterCount={100} />)
expect(screen.getByText('·')).toBeInTheDocument()
})
it('should render with zero character count', () => {
render(<ChunkLabel label="Empty Chunk" characterCount={0} />)
expect(screen.getByText('0 characters')).toBeInTheDocument()
})
it('should render with large character count', () => {
render(<ChunkLabel label="Large Chunk" characterCount={999999} />)
expect(screen.getByText('999999 characters')).toBeInTheDocument()
})
})
describe('ChunkContainer', () => {
it('should render label and character count', () => {
render(<ChunkContainer label="Container 1" characterCount={200}>Content</ChunkContainer>)
expect(screen.getByText('Container 1')).toBeInTheDocument()
expect(screen.getByText('200 characters')).toBeInTheDocument()
})
it('should render children content', () => {
render(<ChunkContainer label="Container 1" characterCount={200}>Test Content</ChunkContainer>)
expect(screen.getByText('Test Content')).toBeInTheDocument()
})
it('should render with complex children', () => {
render(
<ChunkContainer label="Container" characterCount={100}>
<div data-testid="child-div">
<span>Nested content</span>
</div>
</ChunkContainer>,
)
expect(screen.getByTestId('child-div')).toBeInTheDocument()
expect(screen.getByText('Nested content')).toBeInTheDocument()
})
it('should render empty children', () => {
render(<ChunkContainer label="Empty" characterCount={0}>{null}</ChunkContainer>)
expect(screen.getByText('Empty')).toBeInTheDocument()
})
})
describe('QAPreview', () => {
const mockQA = {
question: 'What is the meaning of life?',
answer: 'The meaning of life is 42.',
}
it('should render question text', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('What is the meaning of life?')).toBeInTheDocument()
})
it('should render answer text', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('The meaning of life is 42.')).toBeInTheDocument()
})
it('should render Q label', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('Q')).toBeInTheDocument()
})
it('should render A label', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should render with empty strings', () => {
render(<QAPreview qa={{ question: '', answer: '' }} />)
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should render with long text', () => {
const longQuestion = 'Q'.repeat(500)
const longAnswer = 'A'.repeat(500)
render(<QAPreview qa={{ question: longQuestion, answer: longAnswer }} />)
expect(screen.getByText(longQuestion)).toBeInTheDocument()
expect(screen.getByText(longAnswer)).toBeInTheDocument()
})
it('should render with special characters', () => {
render(<QAPreview qa={{ question: 'What about <script>?', answer: '& special chars!' }} />)
expect(screen.getByText('What about <script>?')).toBeInTheDocument()
expect(screen.getByText('& special chars!')).toBeInTheDocument()
})
})

View File

@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'
import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { RerankingModeEnum } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import { ensureRerankModelSelected, isReRankModelSelected } from './check-rerank-model'
import { ensureRerankModelSelected, isReRankModelSelected } from '../check-rerank-model'
// Test data factory
const createRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): RetrievalConfig => ({

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import ChunkingModeLabel from './chunking-mode-label'
import ChunkingModeLabel from '../chunking-mode-label'
describe('ChunkingModeLabel', () => {
describe('Rendering', () => {

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { CredentialIcon } from './credential-icon'
import { CredentialIcon } from '../credential-icon'
describe('CredentialIcon', () => {
describe('Rendering', () => {

View File

@@ -1,6 +1,6 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import DocumentFileIcon from './document-file-icon'
import DocumentFileIcon from '../document-file-icon'
describe('DocumentFileIcon', () => {
describe('Rendering', () => {

View File

@@ -0,0 +1,49 @@
import type { DocumentItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DocumentList from '../document-list'
vi.mock('../../document-file-icon', () => ({
default: ({ name, extension }: { name?: string, extension?: string }) => (
<span data-testid="file-icon">
{name}
.
{extension}
</span>
),
}))
describe('DocumentList', () => {
const mockList = [
{ id: 'doc-1', name: 'report', extension: 'pdf' },
{ id: 'doc-2', name: 'data', extension: 'csv' },
] as DocumentItem[]
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render all documents', () => {
render(<DocumentList list={mockList} onChange={onChange} />)
expect(screen.getByText('report')).toBeInTheDocument()
expect(screen.getByText('data')).toBeInTheDocument()
})
it('should render file icons', () => {
render(<DocumentList list={mockList} onChange={onChange} />)
expect(screen.getAllByTestId('file-icon')).toHaveLength(2)
})
it('should call onChange with document on click', () => {
render(<DocumentList list={mockList} onChange={onChange} />)
fireEvent.click(screen.getByText('report'))
expect(onChange).toHaveBeenCalledWith(mockList[0])
})
it('should render empty list without errors', () => {
const { container } = render(<DocumentList list={[]} onChange={onChange} />)
expect(container.firstChild).toBeInTheDocument()
})
})

View File

@@ -3,7 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { ChunkingMode, DataSourceType } from '@/models/datasets'
import DocumentPicker from './index'
import DocumentPicker from '../index'
// Mock portal-to-follow-elem - always render content for testing
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
@@ -52,25 +52,6 @@ vi.mock('@/service/knowledge/use-document', () => ({
useDocumentList: mockUseDocumentList,
}))
// Mock icons - mock all remixicon components used in the component tree
vi.mock('@remixicon/react', () => ({
RiArrowDownSLine: () => <span data-testid="arrow-icon"></span>,
RiFile3Fill: () => <span data-testid="file-icon">📄</span>,
RiFileCodeFill: () => <span data-testid="file-code-icon">📄</span>,
RiFileExcelFill: () => <span data-testid="file-excel-icon">📄</span>,
RiFileGifFill: () => <span data-testid="file-gif-icon">📄</span>,
RiFileImageFill: () => <span data-testid="file-image-icon">📄</span>,
RiFileMusicFill: () => <span data-testid="file-music-icon">📄</span>,
RiFilePdf2Fill: () => <span data-testid="file-pdf-icon">📄</span>,
RiFilePpt2Fill: () => <span data-testid="file-ppt-icon">📄</span>,
RiFileTextFill: () => <span data-testid="file-text-icon">📄</span>,
RiFileVideoFill: () => <span data-testid="file-video-icon">📄</span>,
RiFileWordFill: () => <span data-testid="file-word-icon">📄</span>,
RiMarkdownFill: () => <span data-testid="file-markdown-icon">📄</span>,
RiSearchLine: () => <span data-testid="search-icon">🔍</span>,
RiCloseLine: () => <span data-testid="close-icon"></span>,
}))
// Factory function to create mock SimpleDocumentDetail
const createMockDocument = (overrides: Partial<SimpleDocumentDetail> = {}): SimpleDocumentDetail => ({
id: `doc-${Math.random().toString(36).substr(2, 9)}`,
@@ -211,12 +192,6 @@ describe('DocumentPicker', () => {
expect(screen.getByText('--')).toBeInTheDocument()
})
it('should render arrow icon', () => {
renderComponent()
expect(screen.getByTestId('arrow-icon')).toBeInTheDocument()
})
it('should render general mode label', () => {
renderComponent({
value: {
@@ -473,7 +448,7 @@ describe('DocumentPicker', () => {
describe('Memoization Logic', () => {
it('should be wrapped with React.memo', () => {
// React.memo components have a $$typeof property
expect((DocumentPicker as any).$$typeof).toBeDefined()
expect((DocumentPicker as unknown as { $$typeof: symbol }).$$typeof).toBeDefined()
})
it('should compute parentModeLabel correctly with useMemo', () => {
@@ -952,7 +927,6 @@ describe('DocumentPicker', () => {
renderComponent({ onChange })
// Click on a document in the list
fireEvent.click(screen.getByText('Document 2'))
// handleChange should find the document and call onChange with full document
@@ -1026,8 +1000,9 @@ describe('DocumentPicker', () => {
},
})
// FileIcon should be rendered via DocumentFileIcon - pdf renders pdf icon
expect(screen.getByTestId('file-pdf-icon')).toBeInTheDocument()
// FileIcon should render an SVG icon for the file extension
const trigger = screen.getByTestId('portal-trigger')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
})

View File

@@ -1,20 +1,7 @@
import type { DocumentItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import PreviewDocumentPicker from './preview-document-picker'
// Override shared i18n mock for custom translations
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) => {
if (key === 'preprocessDocument' && params?.num)
return `${params.num} files`
const prefix = params?.ns ? `${params.ns}.` : ''
return `${prefix}${key}`
},
}),
}))
import PreviewDocumentPicker from '../preview-document-picker'
// Mock portal-to-follow-elem - always render content for testing
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
@@ -45,23 +32,6 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
),
}))
// Mock icons
vi.mock('@remixicon/react', () => ({
RiArrowDownSLine: () => <span data-testid="arrow-icon"></span>,
RiFile3Fill: () => <span data-testid="file-icon">📄</span>,
RiFileCodeFill: () => <span data-testid="file-code-icon">📄</span>,
RiFileExcelFill: () => <span data-testid="file-excel-icon">📄</span>,
RiFileGifFill: () => <span data-testid="file-gif-icon">📄</span>,
RiFileImageFill: () => <span data-testid="file-image-icon">📄</span>,
RiFileMusicFill: () => <span data-testid="file-music-icon">📄</span>,
RiFilePdf2Fill: () => <span data-testid="file-pdf-icon">📄</span>,
RiFilePpt2Fill: () => <span data-testid="file-ppt-icon">📄</span>,
RiFileTextFill: () => <span data-testid="file-text-icon">📄</span>,
RiFileVideoFill: () => <span data-testid="file-video-icon">📄</span>,
RiFileWordFill: () => <span data-testid="file-word-icon">📄</span>,
RiMarkdownFill: () => <span data-testid="file-markdown-icon">📄</span>,
}))
// Factory function to create mock DocumentItem
const createMockDocumentItem = (overrides: Partial<DocumentItem> = {}): DocumentItem => ({
id: `doc-${Math.random().toString(36).substr(2, 9)}`,
@@ -134,19 +104,14 @@ describe('PreviewDocumentPicker', () => {
expect(screen.getByText('--')).toBeInTheDocument()
})
it('should render arrow icon', () => {
renderComponent()
expect(screen.getByTestId('arrow-icon')).toBeInTheDocument()
})
it('should render file icon', () => {
renderComponent({
value: createMockDocumentItem({ extension: 'txt' }),
files: [], // Use empty files to avoid duplicate icons
})
expect(screen.getByTestId('file-text-icon')).toBeInTheDocument()
const trigger = screen.getByTestId('portal-trigger')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
it('should render pdf icon for pdf extension', () => {
@@ -155,7 +120,8 @@ describe('PreviewDocumentPicker', () => {
files: [], // Use empty files to avoid duplicate icons
})
expect(screen.getByTestId('file-pdf-icon')).toBeInTheDocument()
const trigger = screen.getByTestId('portal-trigger')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
})
@@ -206,7 +172,8 @@ describe('PreviewDocumentPicker', () => {
value: createMockDocumentItem({ name: 'test.docx', extension: 'docx' }),
})
expect(screen.getByTestId('file-word-icon')).toBeInTheDocument()
const trigger = screen.getByTestId('portal-trigger')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
})
@@ -282,7 +249,7 @@ describe('PreviewDocumentPicker', () => {
// Tests for component memoization
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
expect((PreviewDocumentPicker as any).$$typeof).toBeDefined()
expect((PreviewDocumentPicker as unknown as { $$typeof: symbol }).$$typeof).toBeDefined()
})
it('should not re-render when props are the same', () => {
@@ -329,7 +296,6 @@ describe('PreviewDocumentPicker', () => {
renderComponent({ files, onChange })
// Click on a document
fireEvent.click(screen.getByText('Document 2'))
// handleChange should call onChange with the selected item
@@ -506,21 +472,16 @@ describe('PreviewDocumentPicker', () => {
})
describe('extension variations', () => {
const extensions = [
{ ext: 'txt', icon: 'file-text-icon' },
{ ext: 'pdf', icon: 'file-pdf-icon' },
{ ext: 'docx', icon: 'file-word-icon' },
{ ext: 'xlsx', icon: 'file-excel-icon' },
{ ext: 'md', icon: 'file-markdown-icon' },
]
const extensions = ['txt', 'pdf', 'docx', 'xlsx', 'md']
it.each(extensions)('should render correct icon for $ext extension', ({ ext, icon }) => {
it.each(extensions)('should render icon for %s extension', (ext) => {
renderComponent({
value: createMockDocumentItem({ extension: ext }),
files: [], // Use empty files to avoid duplicate icons
})
expect(screen.getByTestId(icon)).toBeInTheDocument()
const trigger = screen.getByTestId('portal-trigger')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
})
})
@@ -543,7 +504,6 @@ describe('PreviewDocumentPicker', () => {
renderComponent({ files, onChange })
// Click on first document
fireEvent.click(screen.getByText('Document 1'))
expect(onChange).toHaveBeenCalledWith(files[0])
@@ -568,7 +528,7 @@ describe('PreviewDocumentPicker', () => {
onChange={vi.fn()}
/>,
)
expect(screen.getByText('3 files')).toBeInTheDocument()
expect(screen.getByText(/dataset\.preprocessDocument/)).toBeInTheDocument()
})
})
@@ -609,7 +569,6 @@ describe('PreviewDocumentPicker', () => {
renderComponent({ files, onChange })
// Click first document
fireEvent.click(screen.getByText('Document 1'))
expect(onChange).toHaveBeenCalledWith(files[0])
@@ -624,11 +583,9 @@ describe('PreviewDocumentPicker', () => {
renderComponent({ files: customFiles, onChange })
// Click on first custom file
fireEvent.click(screen.getByText('Custom File 1'))
expect(onChange).toHaveBeenCalledWith(customFiles[0])
// Click on second custom file
fireEvent.click(screen.getByText('Custom File 2'))
expect(onChange).toHaveBeenCalledWith(customFiles[1])
})

View File

@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { useAutoDisabledDocuments } from '@/service/knowledge/use-document'
import AutoDisabledDocument from './auto-disabled-document'
import AutoDisabledDocument from '../auto-disabled-document'
type AutoDisabledDocumentsResponse = { document_ids: string[] }
@@ -15,7 +15,6 @@ const createMockQueryResult = (
isLoading,
}) as ReturnType<typeof useAutoDisabledDocuments>
// Mock service hooks
const mockMutateAsync = vi.fn()
const mockInvalidDisabledDocument = vi.fn()
@@ -27,7 +26,6 @@ vi.mock('@/service/knowledge/use-document', () => ({
useInvalidDisabledDocument: vi.fn(() => mockInvalidDisabledDocument),
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),

View File

@@ -3,9 +3,8 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { retryErrorDocs } from '@/service/datasets'
import { useDatasetErrorDocs } from '@/service/knowledge/use-dataset'
import RetryButton from './index-failed'
import RetryButton from '../index-failed'
// Mock service hooks
const mockRefetch = vi.fn()
vi.mock('@/service/knowledge/use-dataset', () => ({

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import StatusWithAction from './status-with-action'
import StatusWithAction from '../status-with-action'
describe('StatusWithAction', () => {
describe('Rendering', () => {

View File

@@ -1,9 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { RETRIEVE_METHOD } from '@/types/app'
import EconomicalRetrievalMethodConfig from './index'
import EconomicalRetrievalMethodConfig from '../index'
// Mock dependencies
vi.mock('../../settings/option-card', () => ({
vi.mock('../../../settings/option-card', () => ({
default: ({ children, title, description, disabled, id }: {
children?: React.ReactNode
title?: string
@@ -18,7 +17,7 @@ vi.mock('../../settings/option-card', () => ({
),
}))
vi.mock('../retrieval-param-config', () => ({
vi.mock('../../retrieval-param-config', () => ({
default: ({ value, onChange, type }: {
value: Record<string, unknown>
onChange: (value: Record<string, unknown>) => void

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ImageList from './index'
import ImageList from '../index'
// Track handleImageClick calls for testing
type FileEntity = {
@@ -43,7 +43,7 @@ type ImageInfo = {
}
// Mock ImagePreviewer since it uses createPortal
vi.mock('../image-previewer', () => ({
vi.mock('../../image-previewer', () => ({
default: ({ images, initialIndex, onClose }: ImagePreviewerProps) => (
<div data-testid="image-previewer">
<span data-testid="preview-count">{images.length}</span>
@@ -132,7 +132,6 @@ describe('ImageList', () => {
const images = createMockImages(15)
render(<ImageList images={images} size="md" limit={9} />)
// Click More button
const moreButton = screen.getByText(/\+6/)
fireEvent.click(moreButton)
@@ -182,7 +181,6 @@ describe('ImageList', () => {
const images = createMockImages(3)
const { rerender } = render(<ImageList images={images} size="md" />)
// Click first image to open preview
const firstThumb = screen.getByTestId('file-thumb-https://example.com/image-1.png')
fireEvent.click(firstThumb)
@@ -197,7 +195,6 @@ describe('ImageList', () => {
const newImages = createMockImages(2) // Only 2 images
rerender(<ImageList images={newImages} size="md" />)
// Click on a thumbnail that exists
const validThumb = screen.getByTestId('file-thumb-https://example.com/image-1.png')
fireEvent.click(validThumb)
expect(screen.getByTestId('image-previewer')).toBeInTheDocument()

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import More from './more'
import More from '../more'
describe('More', () => {
describe('Rendering', () => {

View File

@@ -1,6 +1,6 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ImagePreviewer from './index'
import ImagePreviewer from '../index'
// Mock fetch
const mockFetch = vi.fn()
@@ -12,7 +12,6 @@ const mockCreateObjectURL = vi.fn(() => 'blob:mock-url')
globalThis.URL.revokeObjectURL = mockRevokeObjectURL
globalThis.URL.createObjectURL = mockCreateObjectURL
// Mock Image
class MockImage {
onload: (() => void) | null = null
onerror: (() => void) | null = null
@@ -294,7 +293,6 @@ describe('ImagePreviewer', () => {
expect(screen.getByText('image1.png')).toBeInTheDocument()
})
// Click prev button multiple times - should stay at first image
const buttons = document.querySelectorAll('button')
const prevButton = Array.from(buttons).find(btn =>
btn.className.includes('left-8'),
@@ -325,7 +323,6 @@ describe('ImagePreviewer', () => {
expect(screen.getByText('image3.png')).toBeInTheDocument()
})
// Click next button multiple times - should stay at last image
const buttons = document.querySelectorAll('button')
const nextButton = Array.from(buttons).find(btn =>
btn.className.includes('right-8'),
@@ -372,7 +369,6 @@ describe('ImagePreviewer', () => {
expect(screen.getByText(/Failed to load image/)).toBeInTheDocument()
})
// Click retry button
const retryButton = document.querySelector('button.rounded-full')
if (retryButton) {
await act(async () => {

View File

@@ -1,4 +1,4 @@
import type { FileEntity } from './types'
import type { FileEntity } from '../types'
import { act, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import {
@@ -6,7 +6,7 @@ import {
FileContextProvider,
useFileStore,
useFileStoreWithSelector,
} from './store'
} from '../store'
const createMockFile = (id: string): FileEntity => ({
id,

View File

@@ -1,12 +1,12 @@
import type { FileEntity } from './types'
import type { FileEntity } from '../types'
import type { FileUploadConfigResponse } from '@/models/common'
import { describe, expect, it } from 'vitest'
import {
DEFAULT_IMAGE_FILE_BATCH_LIMIT,
DEFAULT_IMAGE_FILE_SIZE_LIMIT,
DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
} from './constants'
import { fileIsUploaded, getFileType, getFileUploadConfig, traverseFileEntry } from './utils'
} from '../constants'
import { fileIsUploaded, getFileType, getFileUploadConfig, traverseFileEntry } from '../utils'
describe('image-uploader utils', () => {
describe('getFileType', () => {

View File

@@ -1,13 +1,12 @@
import type { PropsWithChildren } from 'react'
import type { FileEntity } from '../types'
import type { FileEntity } from '../../types'
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { FileContextProvider } from '../store'
import { useUpload } from './use-upload'
import { FileContextProvider } from '../../store'
import { useUpload } from '../use-upload'
// Mock dependencies
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(() => ({
data: {

View File

@@ -1,9 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { FileContextProvider } from '../store'
import ImageInput from './image-input'
import { FileContextProvider } from '../../store'
import ImageInput from '../image-input'
// Mock dependencies
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(() => ({
data: {

View File

@@ -1,7 +1,7 @@
import type { FileEntity } from '../types'
import type { FileEntity } from '../../types'
import { fireEvent, render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ImageItem from './image-item'
import ImageItem from '../image-item'
const createMockFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
id: 'test-id',

View File

@@ -1,9 +1,8 @@
import type { FileEntity } from '../types'
import type { FileEntity } from '../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ImageUploaderInChunkWrapper from './index'
import ImageUploaderInChunkWrapper from '../index'
// Mock dependencies
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(() => ({
data: {

View File

@@ -1,10 +1,9 @@
import type { FileEntity } from '../types'
import type { FileEntity } from '../../types'
import { fireEvent, render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { FileContextProvider } from '../store'
import ImageInput from './image-input'
import { FileContextProvider } from '../../store'
import ImageInput from '../image-input'
// Mock dependencies
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(() => ({
data: {

View File

@@ -1,7 +1,7 @@
import type { FileEntity } from '../types'
import type { FileEntity } from '../../types'
import { fireEvent, render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ImageItem from './image-item'
import ImageItem from '../image-item'
const createMockFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
id: 'test-id',

View File

@@ -1,9 +1,8 @@
import type { FileEntity } from '../types'
import type { FileEntity } from '../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ImageUploaderInRetrievalTestingWrapper from './index'
import ImageUploaderInRetrievalTestingWrapper from '../index'
// Mock dependencies
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(() => ({
data: {

View File

@@ -7,7 +7,7 @@ import {
WeightedScoreEnum,
} from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import RetrievalMethodConfig from './index'
import RetrievalMethodConfig from '../index'
// Mock provider context with controllable supportRetrievalMethods
let mockSupportRetrievalMethods: RETRIEVE_METHOD[] = [
@@ -37,7 +37,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
}))
// Mock child component RetrievalParamConfig to simplify testing
vi.mock('../retrieval-param-config', () => ({
vi.mock('../../retrieval-param-config', () => ({
default: ({ type, value, onChange, showMultiModalTip }: {
type: RETRIEVE_METHOD
value: RetrievalConfig
@@ -585,7 +585,7 @@ describe('RetrievalMethodConfig', () => {
// Verify the component is wrapped with React.memo by checking its displayName or type
expect(RetrievalMethodConfig).toBeDefined()
// React.memo components have a $$typeof property
expect((RetrievalMethodConfig as any).$$typeof).toBeDefined()
expect((RetrievalMethodConfig as unknown as { $$typeof: symbol }).$$typeof).toBeDefined()
})
it('should not re-render when props are the same', () => {

View File

@@ -1,10 +1,10 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { RETRIEVE_METHOD } from '@/types/app'
import { retrievalIcon } from '../../create/icons'
import RetrievalMethodInfo, { getIcon } from './index'
import { retrievalIcon } from '../../../create/icons'
import RetrievalMethodInfo, { getIcon } from '../index'
// Mock next/image
// Override global next/image auto-mock: tests assert on rendered <img> src attributes via data-testid
vi.mock('next/image', () => ({
default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => (
<img src={src} alt={alt || ''} className={className} data-testid="method-icon" />
@@ -24,7 +24,7 @@ vi.mock('@/app/components/base/radio-card', () => ({
}))
// Mock icons
vi.mock('../../create/icons', () => ({
vi.mock('../../../create/icons', () => ({
retrievalIcon: {
vector: 'vector-icon.png',
fullText: 'fulltext-icon.png',

View File

@@ -2,13 +2,7 @@ import type { RetrievalConfig } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import { RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import RetrievalParamConfig from './index'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
import RetrievalParamConfig from '../index'
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
@@ -268,7 +262,7 @@ describe('RetrievalParamConfig', () => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'errorMsg.rerankModelRequired',
message: 'workflow.errorMsg.rerankModelRequired',
})
})
@@ -358,7 +352,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
expect(screen.getByText('form.retrievalSetting.multiModalTip')).toBeInTheDocument()
expect(screen.getByText('datasetSettings.form.retrievalSetting.multiModalTip')).toBeInTheDocument()
})
it('should not show multimodal tip when showMultiModalTip is false', () => {
@@ -372,7 +366,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
expect(screen.queryByText('form.retrievalSetting.multiModalTip')).not.toBeInTheDocument()
expect(screen.queryByText('datasetSettings.form.retrievalSetting.multiModalTip')).not.toBeInTheDocument()
})
})
@@ -505,7 +499,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
expect(screen.getByText('weightedScore.title')).toBeInTheDocument()
expect(screen.getByText('dataset.weightedScore.title')).toBeInTheDocument()
})
it('should have RerankingModel option', () => {
@@ -517,7 +511,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
expect(screen.getByText('modelProvider.rerankModel.key')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.rerankModel.key')).toBeInTheDocument()
})
it('should show model selector when RerankingModel mode is selected', () => {
@@ -570,7 +564,7 @@ describe('RetrievalParamConfig', () => {
)
const radioCards = screen.getAllByTestId('radio-card')
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'weightedScore.title')
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'dataset.weightedScore.title')
fireEvent.click(weightedScoreCard!)
expect(mockOnChange).toHaveBeenCalled()
@@ -589,7 +583,7 @@ describe('RetrievalParamConfig', () => {
)
const radioCards = screen.getAllByTestId('radio-card')
const rerankModelCard = radioCards.find(card => card.getAttribute('data-title') === 'modelProvider.rerankModel.key')
const rerankModelCard = radioCards.find(card => card.getAttribute('data-title') === 'common.modelProvider.rerankModel.key')
fireEvent.click(rerankModelCard!)
expect(mockOnChange).not.toHaveBeenCalled()
@@ -621,12 +615,12 @@ describe('RetrievalParamConfig', () => {
)
const radioCards = screen.getAllByTestId('radio-card')
const rerankModelCard = radioCards.find(card => card.getAttribute('data-title') === 'modelProvider.rerankModel.key')
const rerankModelCard = radioCards.find(card => card.getAttribute('data-title') === 'common.modelProvider.rerankModel.key')
fireEvent.click(rerankModelCard!)
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'errorMsg.rerankModelRequired',
message: 'workflow.errorMsg.rerankModelRequired',
})
})
@@ -736,7 +730,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
expect(screen.getByText('form.retrievalSetting.multiModalTip')).toBeInTheDocument()
expect(screen.getByText('datasetSettings.form.retrievalSetting.multiModalTip')).toBeInTheDocument()
})
it('should not show multimodal tip for hybrid search with WeightedScore', () => {
@@ -764,7 +758,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
expect(screen.queryByText('form.retrievalSetting.multiModalTip')).not.toBeInTheDocument()
expect(screen.queryByText('datasetSettings.form.retrievalSetting.multiModalTip')).not.toBeInTheDocument()
})
it('should not render rerank switch for hybrid search', () => {
@@ -826,7 +820,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
expect(screen.getByText('modelProvider.rerankModel.key')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.rerankModel.key')).toBeInTheDocument()
})
})
@@ -846,7 +840,7 @@ describe('RetrievalParamConfig', () => {
)
const radioCards = screen.getAllByTestId('radio-card')
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'weightedScore.title')
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'dataset.weightedScore.title')
fireEvent.click(weightedScoreCard!)
expect(mockOnChange).toHaveBeenCalled()
@@ -880,7 +874,7 @@ describe('RetrievalParamConfig', () => {
)
const radioCards = screen.getAllByTestId('radio-card')
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'weightedScore.title')
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'dataset.weightedScore.title')
fireEvent.click(weightedScoreCard!)
expect(mockOnChange).toHaveBeenCalled()

View File

@@ -1,19 +1,17 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Footer from './footer'
import Footer from '../footer'
// Configurable mock for search params
let mockSearchParams = new URLSearchParams()
const mockReplace = vi.fn()
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({ replace: mockReplace }),
useSearchParams: () => mockSearchParams,
}))
// Mock service hook
const mockInvalidDatasetList = vi.fn()
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => mockInvalidDatasetList,
@@ -23,7 +21,7 @@ vi.mock('@/service/knowledge/use-dataset', () => ({
let capturedActiveTab: string | undefined
let capturedDslUrl: string | undefined
vi.mock('./create-options/create-from-dsl-modal', () => ({
vi.mock('../create-options/create-from-dsl-modal', () => ({
default: ({ show, onClose, onSuccess, activeTab, dslUrl }: {
show: boolean
onClose: () => void
@@ -48,9 +46,7 @@ vi.mock('./create-options/create-from-dsl-modal', () => ({
},
}))
// ============================================================================
// Footer Component Tests
// ============================================================================
describe('Footer', () => {
beforeEach(() => {
@@ -60,9 +56,6 @@ describe('Footer', () => {
capturedDslUrl = undefined
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Footer />)
@@ -88,9 +81,6 @@ describe('Footer', () => {
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should open modal when import button is clicked', () => {
render(<Footer />)
@@ -104,12 +94,10 @@ describe('Footer', () => {
it('should close modal when onClose is called', () => {
render(<Footer />)
// Open modal
const importButton = screen.getByText(/importDSL/i)
fireEvent.click(importButton)
expect(screen.getByTestId('dsl-modal')).toBeInTheDocument()
// Close modal
const closeButton = screen.getByTestId('close-modal')
fireEvent.click(closeButton)
expect(screen.queryByTestId('dsl-modal')).not.toBeInTheDocument()
@@ -118,7 +106,6 @@ describe('Footer', () => {
it('should call invalidDatasetList on success', () => {
render(<Footer />)
// Open modal
const importButton = screen.getByText(/importDSL/i)
fireEvent.click(importButton)
@@ -130,9 +117,6 @@ describe('Footer', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container classes', () => {
const { container } = render(<Footer />)
@@ -147,9 +131,6 @@ describe('Footer', () => {
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Footer />)
@@ -158,9 +139,7 @@ describe('Footer', () => {
})
})
// --------------------------------------------------------------------------
// URL Parameter Tests (Branch Coverage)
// --------------------------------------------------------------------------
describe('URL Parameter Handling', () => {
it('should set activeTab to FROM_URL when dslUrl is present', () => {
mockSearchParams = new URLSearchParams('remoteInstallUrl=https://example.com/dsl')
@@ -193,12 +172,10 @@ describe('Footer', () => {
render(<Footer />)
// Open modal
const importButton = screen.getByText(/importDSL/i)
fireEvent.click(importButton)
expect(screen.getByTestId('dsl-modal')).toBeInTheDocument()
// Close modal
const closeButton = screen.getByTestId('close-modal')
fireEvent.click(closeButton)
@@ -210,11 +187,9 @@ describe('Footer', () => {
render(<Footer />)
// Open modal
const importButton = screen.getByText(/importDSL/i)
fireEvent.click(importButton)
// Close modal
const closeButton = screen.getByTestId('close-modal')
fireEvent.click(closeButton)

View File

@@ -1,15 +1,10 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Header from './header'
import Header from '../header'
// ============================================================================
// Header Component Tests
// ============================================================================
describe('Header', () => {
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Header />)
@@ -41,9 +36,6 @@ describe('Header', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container classes', () => {
const { container } = render(<Header />)
@@ -58,9 +50,6 @@ describe('Header', () => {
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Header />)

View File

@@ -1,35 +1,30 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import CreateFromPipeline from './index'
import CreateFromPipeline from '../index'
// Mock child components to isolate testing
vi.mock('./header', () => ({
vi.mock('../header', () => ({
default: () => <div data-testid="mock-header">Header</div>,
}))
vi.mock('./list', () => ({
vi.mock('../list', () => ({
default: () => <div data-testid="mock-list">List</div>,
}))
vi.mock('./footer', () => ({
vi.mock('../footer', () => ({
default: () => <div data-testid="mock-footer">Footer</div>,
}))
vi.mock('../../base/effect', () => ({
vi.mock('../../../base/effect', () => ({
default: ({ className }: { className?: string }) => (
<div data-testid="mock-effect" className={className}>Effect</div>
),
}))
// ============================================================================
// CreateFromPipeline Component Tests
// ============================================================================
describe('CreateFromPipeline', () => {
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<CreateFromPipeline />)
@@ -57,9 +52,6 @@ describe('CreateFromPipeline', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container classes', () => {
const { container } = render(<CreateFromPipeline />)
@@ -86,9 +78,7 @@ describe('CreateFromPipeline', () => {
})
})
// --------------------------------------------------------------------------
// Component Order Tests
// --------------------------------------------------------------------------
describe('Component Order', () => {
it('should render components in correct order', () => {
const { container } = render(<CreateFromPipeline />)

View File

@@ -1,11 +1,9 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DSLConfirmModal from './dsl-confirm-modal'
import DSLConfirmModal from '../dsl-confirm-modal'
// ============================================================================
// DSLConfirmModal Component Tests
// ============================================================================
describe('DSLConfirmModal', () => {
const defaultProps = {
@@ -17,9 +15,6 @@ describe('DSLConfirmModal', () => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<DSLConfirmModal {...defaultProps} />)
@@ -50,9 +45,7 @@ describe('DSLConfirmModal', () => {
})
})
// --------------------------------------------------------------------------
// Versions Display Tests
// --------------------------------------------------------------------------
describe('Versions Display', () => {
it('should display imported version when provided', () => {
render(
@@ -81,9 +74,6 @@ describe('DSLConfirmModal', () => {
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onCancel when cancel button is clicked', () => {
render(<DSLConfirmModal {...defaultProps} />)
@@ -114,9 +104,7 @@ describe('DSLConfirmModal', () => {
})
})
// --------------------------------------------------------------------------
// Button State Tests
// --------------------------------------------------------------------------
describe('Button State', () => {
it('should enable confirm button by default', () => {
render(<DSLConfirmModal {...defaultProps} />)
@@ -140,9 +128,6 @@ describe('DSLConfirmModal', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have button container with proper styling', () => {
render(<DSLConfirmModal {...defaultProps} />)

View File

@@ -1,11 +1,9 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Header from './header'
import Header from '../header'
// ============================================================================
// Header Component Tests
// ============================================================================
describe('Header', () => {
const defaultProps = {
@@ -16,9 +14,6 @@ describe('Header', () => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Header {...defaultProps} />)
@@ -43,9 +38,6 @@ describe('Header', () => {
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onClose when close button is clicked', () => {
const { container } = render(<Header {...defaultProps} />)
@@ -57,9 +49,6 @@ describe('Header', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container styling', () => {
const { container } = render(<Header {...defaultProps} />)
@@ -80,9 +69,6 @@ describe('Header', () => {
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Header {...defaultProps} />)

View File

@@ -1,13 +1,12 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import DSLConfirmModal from './dsl-confirm-modal'
import Header from './header'
import CreateFromDSLModal, { CreateFromDSLModalTab } from './index'
import Tab from './tab'
import TabItem from './tab/item'
import Uploader from './uploader'
import DSLConfirmModal from '../dsl-confirm-modal'
import Header from '../header'
import CreateFromDSLModal, { CreateFromDSLModalTab } from '../index'
import Tab from '../tab'
import TabItem from '../tab/item'
import Uploader from '../uploader'
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
@@ -15,7 +14,6 @@ vi.mock('next/navigation', () => ({
}),
}))
// Mock service hooks
const mockImportDSL = vi.fn()
const mockImportDSLConfirm = vi.fn()
@@ -37,7 +35,6 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
}),
}))
// Mock toast context
const mockNotify = vi.fn()
vi.mock('use-context-selector', async () => {
@@ -48,7 +45,6 @@ vi.mock('use-context-selector', async () => {
}
})
// Test data builders
const createMockFile = (name = 'test.pipeline'): File => {
return new File(['test content'], name, { type: 'application/octet-stream' })
}
@@ -88,9 +84,6 @@ describe('CreateFromDSLModal', () => {
mockHandleCheckPluginDependencies.mockReset()
})
// ============================================
// Rendering Tests
// ============================================
describe('Rendering', () => {
it('should render without crashing when show is true', () => {
render(
@@ -172,9 +165,6 @@ describe('CreateFromDSLModal', () => {
})
})
// ============================================
// Props Testing
// ============================================
describe('Props', () => {
it('should use FROM_FILE as default activeTab', () => {
render(
@@ -232,9 +222,6 @@ describe('CreateFromDSLModal', () => {
})
})
// ============================================
// State Management Tests
// ============================================
describe('State Management', () => {
it('should switch between tabs', () => {
render(
@@ -248,7 +235,6 @@ describe('CreateFromDSLModal', () => {
// Initially file tab is active
expect(screen.getByText('app.dslUploader.button')).toBeInTheDocument()
// Click URL tab
fireEvent.click(screen.getByText('app.importFromDSLUrl'))
// URL input should be visible
@@ -317,9 +303,7 @@ describe('CreateFromDSLModal', () => {
})
})
// ============================================
// API Call Tests
// ============================================
describe('API Calls', () => {
it('should call importDSL with URL mode when URL tab is active', async () => {
mockImportDSL.mockResolvedValue(createImportDSLResponse())
@@ -526,9 +510,7 @@ describe('CreateFromDSLModal', () => {
})
})
// ============================================
// Event Handler Tests
// ============================================
describe('Event Handlers', () => {
it('should call onClose when header close button is clicked', () => {
const onClose = vi.fn()
@@ -638,7 +620,6 @@ describe('CreateFromDSLModal', () => {
const importButton = screen.getByText('app.newApp.import').closest('button')!
// Click multiple times rapidly
fireEvent.click(importButton)
fireEvent.click(importButton)
fireEvent.click(importButton)
@@ -650,9 +631,6 @@ describe('CreateFromDSLModal', () => {
})
})
// ============================================
// Memoization Tests
// ============================================
describe('Memoization', () => {
it('should correctly compute buttonDisabled based on currentTab and file/URL', () => {
render(
@@ -684,9 +662,6 @@ describe('CreateFromDSLModal', () => {
})
})
// ============================================
// Edge Cases Tests
// ============================================
describe('Edge Cases', () => {
it('should handle empty URL gracefully', () => {
render(
@@ -842,9 +817,7 @@ describe('CreateFromDSLModal', () => {
})
})
// ============================================
// File Import Tests (covers readFile, handleFile, file mode import)
// ============================================
describe('File Import', () => {
it('should read file content when file is selected', async () => {
mockImportDSL.mockResolvedValue(createImportDSLResponse())
@@ -877,7 +850,6 @@ describe('CreateFromDSLModal', () => {
expect(importButton).not.toBeDisabled()
})
// Click import button
const importButton = screen.getByText('app.newApp.import').closest('button')!
fireEvent.click(importButton)
@@ -927,9 +899,7 @@ describe('CreateFromDSLModal', () => {
})
})
// ============================================
// DSL Confirm Flow Tests (covers onDSLConfirm)
// ============================================
describe('DSL Confirm Flow', () => {
it('should handle DSL confirm success', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
@@ -978,7 +948,6 @@ describe('CreateFromDSLModal', () => {
vi.advanceTimersByTime(400)
})
// Click confirm button in error modal
await waitFor(() => {
expect(screen.getByText('app.newApp.Confirm')).toBeInTheDocument()
})
@@ -1027,7 +996,6 @@ describe('CreateFromDSLModal', () => {
vi.advanceTimersByTime(400)
})
// Click confirm - should return early since importId is empty
await waitFor(() => {
expect(screen.getByText('app.newApp.Confirm')).toBeInTheDocument()
})
@@ -1163,7 +1131,6 @@ describe('CreateFromDSLModal', () => {
// There are two Cancel buttons now (one in main modal footer, one in error modal)
// Find the Cancel button in the error modal context
const cancelButtons = screen.getAllByText('app.newApp.Cancel')
// Click the last Cancel button (the one in the error modal)
fireEvent.click(cancelButtons[cancelButtons.length - 1])
vi.useRealTimers()
@@ -1171,9 +1138,7 @@ describe('CreateFromDSLModal', () => {
})
})
// ============================================
// Header Component Tests
// ============================================
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -1206,9 +1171,7 @@ describe('Header', () => {
})
})
// ============================================
// Tab Component Tests
// ============================================
describe('Tab', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -1261,9 +1224,7 @@ describe('Tab', () => {
})
})
// ============================================
// Tab Item Component Tests
// ============================================
describe('TabItem', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -1353,9 +1314,7 @@ describe('TabItem', () => {
})
})
// ============================================
// Uploader Component Tests
// ============================================
describe('Uploader', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -1679,7 +1638,6 @@ describe('Uploader', () => {
// After click, oncancel should be set
})
// Click browse link to trigger selectHandle
const browseLink = screen.getByText('app.dslUploader.browse')
fireEvent.click(browseLink)
@@ -1755,9 +1713,7 @@ describe('Uploader', () => {
})
})
// ============================================
// DSLConfirmModal Component Tests
// ============================================
describe('DSLConfirmModal', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -1923,9 +1879,6 @@ describe('DSLConfirmModal', () => {
})
})
// ============================================
// Integration Tests
// ============================================
describe('CreateFromDSLModal Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -1958,7 +1911,6 @@ describe('CreateFromDSLModal Integration', () => {
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/pipeline.yaml' } })
// Click import
const importButton = screen.getByText('app.newApp.import').closest('button')!
fireEvent.click(importButton)
@@ -1999,7 +1951,6 @@ describe('CreateFromDSLModal Integration', () => {
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
fireEvent.change(input, { target: { value: 'https://example.com/old-pipeline.yaml' } })
// Click import
const importButton = screen.getByText('app.newApp.import').closest('button')!
fireEvent.click(importButton)

View File

@@ -1,9 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Uploader from './uploader'
import Uploader from '../uploader'
// Mock ToastContext
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
ToastContext: {
@@ -17,17 +16,11 @@ vi.mock('use-context-selector', () => ({
useContext: () => ({ notify: mockNotify }),
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createMockFile = (name = 'test.pipeline', _size = 1024): File => {
return new File(['test content'], name, { type: 'application/octet-stream' })
}
// ============================================================================
// Uploader Component Tests
// ============================================================================
describe('Uploader', () => {
const defaultProps = {
@@ -39,9 +32,7 @@ describe('Uploader', () => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests - No File
// --------------------------------------------------------------------------
describe('Rendering - No File', () => {
it('should render without crashing', () => {
render(<Uploader {...defaultProps} />)
@@ -78,9 +69,7 @@ describe('Uploader', () => {
})
})
// --------------------------------------------------------------------------
// Rendering Tests - With File
// --------------------------------------------------------------------------
describe('Rendering - With File', () => {
it('should render file name when file is provided', () => {
const file = createMockFile('my-pipeline.pipeline')
@@ -109,9 +98,6 @@ describe('Uploader', () => {
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should open file dialog when browse is clicked', () => {
render(<Uploader {...defaultProps} />)
@@ -151,9 +137,7 @@ describe('Uploader', () => {
})
})
// --------------------------------------------------------------------------
// Custom className Tests
// --------------------------------------------------------------------------
describe('Custom className', () => {
it('should apply custom className', () => {
const { container } = render(<Uploader {...defaultProps} className="custom-class" />)
@@ -168,9 +152,6 @@ describe('Uploader', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container styling', () => {
const { container } = render(<Uploader {...defaultProps} />)
@@ -192,9 +173,6 @@ describe('Uploader', () => {
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Uploader {...defaultProps} />)

View File

@@ -2,9 +2,8 @@ import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CreateFromDSLModalTab, useDSLImport } from './use-dsl-import'
import { CreateFromDSLModalTab, useDSLImport } from '../use-dsl-import'
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
@@ -12,7 +11,6 @@ vi.mock('next/navigation', () => ({
}),
}))
// Mock service hooks
const mockImportDSL = vi.fn()
const mockImportDSLConfirm = vi.fn()
@@ -34,7 +32,6 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
}),
}))
// Mock toast context
const mockNotify = vi.fn()
vi.mock('use-context-selector', async () => {
@@ -45,7 +42,6 @@ vi.mock('use-context-selector', async () => {
}
})
// Test data builders
const createImportDSLResponse = (overrides = {}) => ({
id: 'import-123',
status: 'completed' as const,

View File

@@ -2,11 +2,9 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
import Tab from './index'
import Tab from '../index'
// ============================================================================
// Tab Component Tests
// ============================================================================
describe('Tab', () => {
const defaultProps = {
@@ -18,9 +16,6 @@ describe('Tab', () => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Tab {...defaultProps} />)
@@ -44,9 +39,7 @@ describe('Tab', () => {
})
})
// --------------------------------------------------------------------------
// Active State Tests
// --------------------------------------------------------------------------
describe('Active State', () => {
it('should mark file tab as active when currentTab is FROM_FILE', () => {
const { container } = render(
@@ -65,9 +58,6 @@ describe('Tab', () => {
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call setCurrentTab with FROM_FILE when file tab is clicked', () => {
render(<Tab {...defaultProps} currentTab={CreateFromDSLModalTab.FROM_URL} />)
@@ -96,9 +86,6 @@ describe('Tab', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container styling', () => {
const { container } = render(<Tab {...defaultProps} />)

View File

@@ -1,11 +1,9 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Item from './item'
import Item from '../item'
// ============================================================================
// Item Component Tests
// ============================================================================
describe('Item', () => {
const defaultProps = {
@@ -18,9 +16,6 @@ describe('Item', () => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Item {...defaultProps} />)
@@ -45,9 +40,7 @@ describe('Item', () => {
})
})
// --------------------------------------------------------------------------
// Active State Tests
// --------------------------------------------------------------------------
describe('Active State', () => {
it('should have tertiary text color when inactive', () => {
const { container } = render(<Item {...defaultProps} isActive={false} />)
@@ -68,9 +61,6 @@ describe('Item', () => {
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onClick when clicked', () => {
render(<Item {...defaultProps} />)
@@ -88,9 +78,6 @@ describe('Item', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container styling', () => {
const { container } = render(<Item {...defaultProps} />)
@@ -99,9 +86,6 @@ describe('Item', () => {
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Item {...defaultProps} />)

View File

@@ -1,14 +1,13 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BuiltInPipelineList from './built-in-pipeline-list'
import BuiltInPipelineList from '../built-in-pipeline-list'
// Mock child components
vi.mock('./create-card', () => ({
vi.mock('../create-card', () => ({
default: () => <div data-testid="create-card">CreateCard</div>,
}))
vi.mock('./template-card', () => ({
vi.mock('../template-card', () => ({
default: ({ type, pipeline, showMoreOperations }: { type: string, pipeline: { name: string }, showMoreOperations?: boolean }) => (
<div data-testid="template-card" data-type={type} data-show-more={String(showMoreOperations)}>
{pipeline.name}
@@ -19,7 +18,6 @@ vi.mock('./template-card', () => ({
// Configurable locale mock
let mockLocale = 'en-US'
// Mock hooks
vi.mock('@/context/i18n', () => ({
useLocale: () => mockLocale,
}))
@@ -36,9 +34,7 @@ vi.mock('@/service/use-pipeline', () => ({
usePipelineTemplateList: (...args: unknown[]) => mockUsePipelineTemplateList(...args),
}))
// ============================================================================
// BuiltInPipelineList Component Tests
// ============================================================================
describe('BuiltInPipelineList', () => {
beforeEach(() => {
@@ -46,9 +42,6 @@ describe('BuiltInPipelineList', () => {
mockLocale = 'en-US'
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
mockUsePipelineTemplateList.mockReturnValue({
@@ -71,9 +64,7 @@ describe('BuiltInPipelineList', () => {
})
})
// --------------------------------------------------------------------------
// Loading State Tests
// --------------------------------------------------------------------------
describe('Loading State', () => {
it('should not render TemplateCards when loading', () => {
mockUsePipelineTemplateList.mockReturnValue({
@@ -88,9 +79,7 @@ describe('BuiltInPipelineList', () => {
})
})
// --------------------------------------------------------------------------
// Rendering with Data Tests
// --------------------------------------------------------------------------
describe('Rendering with Data', () => {
it('should render TemplateCard for each pipeline when not loading', () => {
const mockPipelines = [
@@ -136,9 +125,7 @@ describe('BuiltInPipelineList', () => {
})
})
// --------------------------------------------------------------------------
// API Call Tests
// --------------------------------------------------------------------------
describe('API Call', () => {
it('should call usePipelineTemplateList with type built-in', () => {
mockUsePipelineTemplateList.mockReturnValue({
@@ -154,9 +141,6 @@ describe('BuiltInPipelineList', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have grid layout', () => {
mockUsePipelineTemplateList.mockReturnValue({
@@ -181,9 +165,7 @@ describe('BuiltInPipelineList', () => {
})
})
// --------------------------------------------------------------------------
// Locale Handling Tests (Branch Coverage)
// --------------------------------------------------------------------------
describe('Locale Handling', () => {
it('should use zh-Hans locale when set', () => {
mockLocale = 'zh-Hans'
@@ -247,9 +229,7 @@ describe('BuiltInPipelineList', () => {
})
})
// --------------------------------------------------------------------------
// Empty Data Tests
// --------------------------------------------------------------------------
describe('Empty Data', () => {
it('should handle null pipeline_templates', () => {
mockUsePipelineTemplateList.mockReturnValue({

View File

@@ -1,9 +1,8 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CreateCard from './create-card'
import CreateCard from '../create-card'
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
@@ -14,14 +13,12 @@ vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
// Mock service hooks
const mockCreateEmptyDataset = vi.fn()
const mockInvalidDatasetList = vi.fn()
@@ -35,18 +32,13 @@ vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
// ============================================================================
// CreateCard Component Tests
// ============================================================================
describe('CreateCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<CreateCard />)
@@ -66,9 +58,6 @@ describe('CreateCard', () => {
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call createEmptyDataset when clicked', async () => {
mockCreateEmptyDataset.mockImplementation((_data, callbacks) => {
@@ -154,9 +143,6 @@ describe('CreateCard', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper card styling', () => {
const { container } = render(<CreateCard />)
@@ -177,9 +163,6 @@ describe('CreateCard', () => {
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<CreateCard />)

View File

@@ -1,10 +1,10 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CustomizedList from './customized-list'
import CustomizedList from '../customized-list'
// Mock TemplateCard
vi.mock('./template-card', () => ({
vi.mock('../template-card', () => ({
default: ({ type, pipeline }: { type: string, pipeline: { name: string } }) => (
<div data-testid="template-card" data-type={type}>
{pipeline.name}
@@ -18,18 +18,14 @@ vi.mock('@/service/use-pipeline', () => ({
usePipelineTemplateList: (...args: unknown[]) => mockUsePipelineTemplateList(...args),
}))
// ============================================================================
// CustomizedList Component Tests
// ============================================================================
describe('CustomizedList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Loading State Tests
// --------------------------------------------------------------------------
describe('Loading State', () => {
it('should return null when loading', () => {
mockUsePipelineTemplateList.mockReturnValue({
@@ -42,9 +38,7 @@ describe('CustomizedList', () => {
})
})
// --------------------------------------------------------------------------
// Empty State Tests
// --------------------------------------------------------------------------
describe('Empty State', () => {
it('should return null when list is empty', () => {
mockUsePipelineTemplateList.mockReturnValue({
@@ -67,9 +61,7 @@ describe('CustomizedList', () => {
})
})
// --------------------------------------------------------------------------
// Rendering with Data Tests
// --------------------------------------------------------------------------
describe('Rendering with Data', () => {
it('should render title when list has items', () => {
mockUsePipelineTemplateList.mockReturnValue({
@@ -116,9 +108,7 @@ describe('CustomizedList', () => {
})
})
// --------------------------------------------------------------------------
// API Call Tests
// --------------------------------------------------------------------------
describe('API Call', () => {
it('should call usePipelineTemplateList with type customized', () => {
mockUsePipelineTemplateList.mockReturnValue({
@@ -131,9 +121,6 @@ describe('CustomizedList', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have grid layout for cards', () => {
mockUsePipelineTemplateList.mockReturnValue({

View File

@@ -1,25 +1,19 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import List from './index'
import List from '../index'
// Mock child components
vi.mock('./built-in-pipeline-list', () => ({
vi.mock('../built-in-pipeline-list', () => ({
default: () => <div data-testid="built-in-list">BuiltInPipelineList</div>,
}))
vi.mock('./customized-list', () => ({
vi.mock('../customized-list', () => ({
default: () => <div data-testid="customized-list">CustomizedList</div>,
}))
// ============================================================================
// List Component Tests
// ============================================================================
describe('List', () => {
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<List />)
@@ -37,9 +31,6 @@ describe('List', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container classes', () => {
const { container } = render(<List />)
@@ -54,9 +45,7 @@ describe('List', () => {
})
})
// --------------------------------------------------------------------------
// Component Order Tests
// --------------------------------------------------------------------------
describe('Component Order', () => {
it('should render BuiltInPipelineList before CustomizedList', () => {
const { container } = render(<List />)

View File

@@ -1,11 +1,9 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Actions from './actions'
import Actions from '../actions'
// ============================================================================
// Actions Component Tests
// ============================================================================
describe('Actions', () => {
const defaultProps = {
@@ -21,9 +19,6 @@ describe('Actions', () => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Actions {...defaultProps} />)
@@ -53,9 +48,7 @@ describe('Actions', () => {
})
})
// --------------------------------------------------------------------------
// More Operations Tests
// --------------------------------------------------------------------------
describe('More Operations', () => {
it('should render more operations button when showMoreOperations is true', () => {
const { container } = render(<Actions {...defaultProps} showMoreOperations={true} />)
@@ -72,9 +65,6 @@ describe('Actions', () => {
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onApplyTemplate when choose button is clicked', () => {
render(<Actions {...defaultProps} />)
@@ -95,9 +85,7 @@ describe('Actions', () => {
})
})
// --------------------------------------------------------------------------
// Button Variants Tests
// --------------------------------------------------------------------------
describe('Button Variants', () => {
it('should have primary variant for choose button', () => {
render(<Actions {...defaultProps} />)
@@ -112,9 +100,6 @@ describe('Actions', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have absolute positioning', () => {
const { container } = render(<Actions {...defaultProps} />)
@@ -141,9 +126,6 @@ describe('Actions', () => {
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Actions {...defaultProps} />)

View File

@@ -3,11 +3,7 @@ import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import Content from './content'
// ============================================================================
// Test Data Factories
// ============================================================================
import Content from '../content'
const createIconInfo = (overrides: Partial<IconInfo> = {}): IconInfo => ({
icon_type: 'emoji',
@@ -25,9 +21,7 @@ const createImageIconInfo = (overrides: Partial<IconInfo> = {}): IconInfo => ({
...overrides,
})
// ============================================================================
// Content Component Tests
// ============================================================================
describe('Content', () => {
const defaultProps = {
@@ -37,9 +31,6 @@ describe('Content', () => {
chunkStructure: 'text' as ChunkingMode,
}
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Content {...defaultProps} />)
@@ -75,9 +66,7 @@ describe('Content', () => {
})
})
// --------------------------------------------------------------------------
// Icon Rendering Tests
// --------------------------------------------------------------------------
describe('Icon Rendering', () => {
it('should render emoji icon correctly', () => {
const { container } = render(<Content {...defaultProps} />)
@@ -104,9 +93,7 @@ describe('Content', () => {
})
})
// --------------------------------------------------------------------------
// Chunk Structure Tests
// --------------------------------------------------------------------------
describe('Chunk Structure', () => {
it('should handle text chunk structure', () => {
render(<Content {...defaultProps} chunkStructure={ChunkingMode.text} />)
@@ -132,9 +119,6 @@ describe('Content', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper header layout', () => {
const { container } = render(<Content {...defaultProps} />)
@@ -155,9 +139,6 @@ describe('Content', () => {
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle empty name', () => {
render(<Content {...defaultProps} name="" />)
@@ -186,9 +167,6 @@ describe('Content', () => {
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Content {...defaultProps} />)

View File

@@ -4,9 +4,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { ChunkingMode } from '@/models/datasets'
import EditPipelineInfo from './edit-pipeline-info'
import EditPipelineInfo from '../edit-pipeline-info'
// Mock service hooks
const mockUpdatePipeline = vi.fn()
const mockInvalidCustomizedTemplateList = vi.fn()
@@ -17,7 +16,6 @@ vi.mock('@/service/use-pipeline', () => ({
useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList,
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
@@ -51,10 +49,6 @@ vi.mock('@/app/components/base/app-icon-picker', () => ({
},
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createPipelineTemplate = (overrides: Partial<PipelineTemplate> = {}): PipelineTemplate => ({
id: 'pipeline-1',
name: 'Test Pipeline',
@@ -84,9 +78,7 @@ const createImagePipelineTemplate = (): PipelineTemplate => ({
position: 1,
})
// ============================================================================
// EditPipelineInfo Component Tests
// ============================================================================
describe('EditPipelineInfo', () => {
const defaultProps = {
@@ -100,9 +92,6 @@ describe('EditPipelineInfo', () => {
_mockOnClose = undefined
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<EditPipelineInfo {...defaultProps} />)
@@ -149,9 +138,6 @@ describe('EditPipelineInfo', () => {
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onClose when close button is clicked', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
@@ -238,9 +224,6 @@ describe('EditPipelineInfo', () => {
})
})
// --------------------------------------------------------------------------
// Validation Tests
// --------------------------------------------------------------------------
describe('Validation', () => {
it('should show error toast when name is empty', async () => {
render(<EditPipelineInfo {...defaultProps} />)
@@ -274,9 +257,7 @@ describe('EditPipelineInfo', () => {
})
})
// --------------------------------------------------------------------------
// Icon Types Tests (Branch Coverage for lines 29-30, 36-37)
// --------------------------------------------------------------------------
describe('Icon Types', () => {
it('should initialize with emoji icon type when pipeline has emoji icon', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
@@ -409,7 +390,6 @@ describe('EditPipelineInfo', () => {
const selectEmojiButton = screen.getByTestId('select-emoji')
fireEvent.click(selectEmojiButton)
// Save
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
@@ -440,7 +420,6 @@ describe('EditPipelineInfo', () => {
const selectImageButton = screen.getByTestId('select-image')
fireEvent.click(selectImageButton)
// Save
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
@@ -458,9 +437,7 @@ describe('EditPipelineInfo', () => {
})
})
// --------------------------------------------------------------------------
// AppIconPicker Tests (Branch Coverage)
// --------------------------------------------------------------------------
describe('AppIconPicker', () => {
it('should not show picker initially', () => {
render(<EditPipelineInfo {...defaultProps} />)
@@ -525,7 +502,6 @@ describe('EditPipelineInfo', () => {
const selectEmojiButton = screen.getByTestId('select-emoji')
fireEvent.click(selectEmojiButton)
// Save
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
@@ -557,7 +533,6 @@ describe('EditPipelineInfo', () => {
const selectImageButton = screen.getByTestId('select-image')
fireEvent.click(selectImageButton)
// Save
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
@@ -576,9 +551,7 @@ describe('EditPipelineInfo', () => {
})
})
// --------------------------------------------------------------------------
// Save Request Tests
// --------------------------------------------------------------------------
describe('Save Request', () => {
it('should send correct request with emoji icon', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
@@ -635,9 +608,6 @@ describe('EditPipelineInfo', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container styling', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
@@ -652,9 +622,6 @@ describe('EditPipelineInfo', () => {
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<EditPipelineInfo {...defaultProps} />)

View File

@@ -3,9 +3,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { ChunkingMode } from '@/models/datasets'
import TemplateCard from './index'
import TemplateCard from '../index'
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
@@ -16,7 +15,6 @@ vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
@@ -61,7 +59,7 @@ let _capturedHandleDelete: (() => void) | undefined
let _capturedHandleExportDSL: (() => void) | undefined
let _capturedOpenEditModal: (() => void) | undefined
vi.mock('./actions', () => ({
vi.mock('../actions', () => ({
default: ({ onApplyTemplate, handleShowTemplateDetails, showMoreOperations, openEditModal, handleExportDSL, handleDelete }: {
onApplyTemplate: () => void
handleShowTemplateDetails: () => void
@@ -90,7 +88,7 @@ vi.mock('./actions', () => ({
}))
// Mock EditPipelineInfo component
vi.mock('./edit-pipeline-info', () => ({
vi.mock('../edit-pipeline-info', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="edit-pipeline-info">
<button data-testid="edit-close" onClick={onClose}>Close</button>
@@ -99,7 +97,7 @@ vi.mock('./edit-pipeline-info', () => ({
}))
// Mock Details component
vi.mock('./details', () => ({
vi.mock('../details', () => ({
default: ({ onClose, onApplyTemplate }: { onClose: () => void, onApplyTemplate: () => void }) => (
<div data-testid="details-component">
<button data-testid="details-close" onClick={onClose}>Close</button>
@@ -108,7 +106,6 @@ vi.mock('./details', () => ({
),
}))
// Mock service hooks
const mockCreateDataset = vi.fn()
const mockInvalidDatasetList = vi.fn()
const mockGetPipelineTemplateInfo = vi.fn()
@@ -151,10 +148,6 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
}),
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createPipelineTemplate = (overrides: Partial<PipelineTemplate> = {}): PipelineTemplate => ({
id: 'pipeline-1',
name: 'Test Pipeline',
@@ -170,9 +163,7 @@ const createPipelineTemplate = (overrides: Partial<PipelineTemplate> = {}): Pipe
...overrides,
})
// ============================================================================
// TemplateCard Component Tests
// ============================================================================
describe('TemplateCard', () => {
const defaultProps = {
@@ -197,9 +188,6 @@ describe('TemplateCard', () => {
})
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<TemplateCard {...defaultProps} />)
@@ -230,9 +218,7 @@ describe('TemplateCard', () => {
})
})
// --------------------------------------------------------------------------
// Use Template Flow Tests
// --------------------------------------------------------------------------
describe('Use Template Flow', () => {
it('should show error when template info fetch fails', async () => {
mockGetPipelineTemplateInfo.mockResolvedValue({ data: null })
@@ -331,9 +317,7 @@ describe('TemplateCard', () => {
})
})
// --------------------------------------------------------------------------
// Details Modal Tests
// --------------------------------------------------------------------------
describe('Details Modal', () => {
it('should open details modal when details button is clicked', async () => {
render(<TemplateCard {...defaultProps} />)
@@ -385,9 +369,7 @@ describe('TemplateCard', () => {
})
})
// --------------------------------------------------------------------------
// Pipeline ID Branch Tests
// --------------------------------------------------------------------------
describe('Pipeline ID Branch', () => {
it('should call handleCheckPluginDependencies when pipeline_id is present', async () => {
mockCreateDataset.mockImplementation((_data, callbacks) => {
@@ -437,9 +419,7 @@ describe('TemplateCard', () => {
})
})
// --------------------------------------------------------------------------
// Export DSL Tests (Branch Coverage)
// --------------------------------------------------------------------------
describe('Export DSL', () => {
it('should not export when already exporting', async () => {
mockIsExporting = true
@@ -522,9 +502,7 @@ describe('TemplateCard', () => {
})
})
// --------------------------------------------------------------------------
// Delete Flow Tests
// --------------------------------------------------------------------------
describe('Delete Flow', () => {
it('should show confirm dialog when delete is clicked', async () => {
render(<TemplateCard {...defaultProps} />)
@@ -620,9 +598,7 @@ describe('TemplateCard', () => {
})
})
// --------------------------------------------------------------------------
// Edit Modal Tests
// --------------------------------------------------------------------------
describe('Edit Modal', () => {
it('should open edit modal when edit button is clicked', async () => {
render(<TemplateCard {...defaultProps} />)
@@ -652,9 +628,7 @@ describe('TemplateCard', () => {
})
})
// --------------------------------------------------------------------------
// Props Tests
// --------------------------------------------------------------------------
describe('Props', () => {
it('should show more operations when showMoreOperations is true', () => {
render(<TemplateCard {...defaultProps} showMoreOperations={true} />)
@@ -687,9 +661,6 @@ describe('TemplateCard', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper card styling', () => {
const { container } = render(<TemplateCard {...defaultProps} />)
@@ -710,9 +681,6 @@ describe('TemplateCard', () => {
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<TemplateCard {...defaultProps} />)

View File

@@ -1,11 +1,9 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Operations from './operations'
import Operations from '../operations'
// ============================================================================
// Operations Component Tests
// ============================================================================
describe('Operations', () => {
const defaultProps = {
@@ -18,9 +16,6 @@ describe('Operations', () => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Operations {...defaultProps} />)
@@ -41,9 +36,6 @@ describe('Operations', () => {
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call openEditModal when edit is clicked', () => {
render(<Operations {...defaultProps} />)
@@ -106,9 +98,6 @@ describe('Operations', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have divider between sections', () => {
const { container } = render(<Operations {...defaultProps} />)
@@ -131,9 +120,6 @@ describe('Operations', () => {
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Operations {...defaultProps} />)

View File

@@ -1,12 +1,10 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import ChunkStructureCard from './chunk-structure-card'
import { EffectColor } from './types'
import ChunkStructureCard from '../chunk-structure-card'
import { EffectColor } from '../types'
// ============================================================================
// ChunkStructureCard Component Tests
// ============================================================================
describe('ChunkStructureCard', () => {
const defaultProps = {
@@ -16,9 +14,6 @@ describe('ChunkStructureCard', () => {
effectColor: EffectColor.indigo,
}
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ChunkStructureCard {...defaultProps} />)
@@ -53,9 +48,7 @@ describe('ChunkStructureCard', () => {
})
})
// --------------------------------------------------------------------------
// Effect Colors Tests
// --------------------------------------------------------------------------
describe('Effect Colors', () => {
it('should apply indigo effect color', () => {
const { container } = render(
@@ -90,9 +83,7 @@ describe('ChunkStructureCard', () => {
})
})
// --------------------------------------------------------------------------
// Icon Background Tests
// --------------------------------------------------------------------------
describe('Icon Background', () => {
it('should apply indigo icon background', () => {
const { container } = render(
@@ -119,9 +110,7 @@ describe('ChunkStructureCard', () => {
})
})
// --------------------------------------------------------------------------
// Custom className Tests
// --------------------------------------------------------------------------
describe('Custom className', () => {
it('should apply custom className', () => {
const { container } = render(
@@ -140,9 +129,6 @@ describe('ChunkStructureCard', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper card styling', () => {
const { container } = render(<ChunkStructureCard {...defaultProps} />)
@@ -169,9 +155,6 @@ describe('ChunkStructureCard', () => {
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<ChunkStructureCard {...defaultProps} />)

View File

@@ -2,17 +2,13 @@ import { renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import { useChunkStructureConfig } from './hooks'
import { EffectColor } from './types'
import { useChunkStructureConfig } from '../hooks'
import { EffectColor } from '../types'
// ============================================================================
// useChunkStructureConfig Hook Tests
// ============================================================================
describe('useChunkStructureConfig', () => {
// --------------------------------------------------------------------------
// Return Value Tests
// --------------------------------------------------------------------------
describe('Return Value', () => {
it('should return config object', () => {
const { result } = renderHook(() => useChunkStructureConfig())
@@ -36,9 +32,7 @@ describe('useChunkStructureConfig', () => {
})
})
// --------------------------------------------------------------------------
// Text/General Config Tests
// --------------------------------------------------------------------------
describe('Text/General Config', () => {
it('should have title for text mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
@@ -61,9 +55,7 @@ describe('useChunkStructureConfig', () => {
})
})
// --------------------------------------------------------------------------
// Parent-Child Config Tests
// --------------------------------------------------------------------------
describe('Parent-Child Config', () => {
it('should have title for parent-child mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
@@ -86,9 +78,7 @@ describe('useChunkStructureConfig', () => {
})
})
// --------------------------------------------------------------------------
// Q&A Config Tests
// --------------------------------------------------------------------------
describe('Q&A Config', () => {
it('should have title for qa mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
@@ -111,9 +101,7 @@ describe('useChunkStructureConfig', () => {
})
})
// --------------------------------------------------------------------------
// Option Structure Tests
// --------------------------------------------------------------------------
describe('Option Structure', () => {
it('should have all required fields in each option', () => {
const { result } = renderHook(() => useChunkStructureConfig())

View File

@@ -1,7 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Details from './index'
import Details from '../index'
// Mock WorkflowPreview
vi.mock('@/app/components/workflow/workflow-preview', () => ({
@@ -12,16 +12,11 @@ vi.mock('@/app/components/workflow/workflow-preview', () => ({
),
}))
// Mock service hook
const mockUsePipelineTemplateById = vi.fn()
vi.mock('@/service/use-pipeline', () => ({
usePipelineTemplateById: (...args: unknown[]) => mockUsePipelineTemplateById(...args),
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createPipelineTemplateInfo = (overrides = {}) => ({
name: 'Test Pipeline',
description: 'This is a test pipeline',
@@ -52,9 +47,7 @@ const createImageIconPipelineInfo = () => ({
},
})
// ============================================================================
// Details Component Tests
// ============================================================================
describe('Details', () => {
const defaultProps = {
@@ -68,9 +61,7 @@ describe('Details', () => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Loading State Tests
// --------------------------------------------------------------------------
describe('Loading State', () => {
it('should show loading when data is not available', () => {
mockUsePipelineTemplateById.mockReturnValue({
@@ -83,9 +74,6 @@ describe('Details', () => {
})
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing when data is available', () => {
mockUsePipelineTemplateById.mockReturnValue({
@@ -180,9 +168,6 @@ describe('Details', () => {
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onClose when close button is clicked', () => {
mockUsePipelineTemplateById.mockReturnValue({
@@ -209,9 +194,7 @@ describe('Details', () => {
})
})
// --------------------------------------------------------------------------
// Icon Types Tests
// --------------------------------------------------------------------------
describe('Icon Types', () => {
it('should handle emoji icon type', () => {
mockUsePipelineTemplateById.mockReturnValue({
@@ -245,9 +228,7 @@ describe('Details', () => {
})
})
// --------------------------------------------------------------------------
// API Call Tests
// --------------------------------------------------------------------------
describe('API Call', () => {
it('should call usePipelineTemplateById with correct params', () => {
mockUsePipelineTemplateById.mockReturnValue({
@@ -276,9 +257,7 @@ describe('Details', () => {
})
})
// --------------------------------------------------------------------------
// Chunk Structure Tests
// --------------------------------------------------------------------------
describe('Chunk Structure', () => {
it('should render chunk structure card for text mode', () => {
mockUsePipelineTemplateById.mockReturnValue({
@@ -308,9 +287,6 @@ describe('Details', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container styling', () => {
mockUsePipelineTemplateById.mockReturnValue({
@@ -343,9 +319,6 @@ describe('Details', () => {
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
mockUsePipelineTemplateById.mockReturnValue({

View File

@@ -0,0 +1,141 @@
import type { ReactNode } from 'react'
import type { IndexingStatusResponse } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
import IndexingProgressItem from '../indexing-progress-item'
vi.mock('@/app/components/billing/priority-label', () => ({
default: () => <span data-testid="priority-label">Priority</span>,
}))
vi.mock('../../../common/document-file-icon', () => ({
default: ({ name }: { name?: string }) => <span data-testid="file-icon">{name}</span>,
}))
vi.mock('@/app/components/base/notion-icon', () => ({
default: ({ src }: { src?: string }) => <span data-testid="notion-icon">{src}</span>,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children?: ReactNode, popupContent?: ReactNode }) => (
<div data-testid="tooltip" data-content={popupContent}>{children}</div>
),
}))
describe('IndexingProgressItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const makeDetail = (overrides: Partial<IndexingStatusResponse> = {}): IndexingStatusResponse => ({
id: 'doc-1',
indexing_status: 'indexing',
processing_started_at: 0,
parsing_completed_at: 0,
cleaning_completed_at: 0,
splitting_completed_at: 0,
completed_at: null,
paused_at: null,
error: null,
stopped_at: null,
completed_segments: 50,
total_segments: 100,
...overrides,
})
it('should render name and progress for embedding status', () => {
render(
<IndexingProgressItem
detail={makeDetail()}
name="test.pdf"
sourceType={DataSourceType.FILE}
/>,
)
// Name appears in both the file-icon mock and the display div; verify at least one
expect(screen.getAllByText('test.pdf').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('50%')).toBeInTheDocument()
})
it('should render file icon for FILE source type', () => {
render(
<IndexingProgressItem
detail={makeDetail()}
name="report.docx"
sourceType={DataSourceType.FILE}
/>,
)
expect(screen.getByTestId('file-icon')).toBeInTheDocument()
})
it('should render notion icon for NOTION source type', () => {
render(
<IndexingProgressItem
detail={makeDetail()}
name="My Page"
sourceType={DataSourceType.NOTION}
notionIcon="notion-icon-url"
/>,
)
expect(screen.getByTestId('notion-icon')).toBeInTheDocument()
})
it('should render success icon for completed status', () => {
render(
<IndexingProgressItem
detail={makeDetail({ indexing_status: 'completed' })}
name="done.pdf"
/>,
)
// No progress percentage should be shown for completed
expect(screen.queryByText(/%/)).not.toBeInTheDocument()
})
it('should render error icon with tooltip for error status', () => {
render(
<IndexingProgressItem
detail={makeDetail({ indexing_status: 'error', error: 'Parse failed' })}
name="broken.pdf"
/>,
)
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'Parse failed')
})
it('should show priority label when billing is enabled', () => {
render(
<IndexingProgressItem
detail={makeDetail()}
name="test.pdf"
enableBilling={true}
/>,
)
expect(screen.getByTestId('priority-label')).toBeInTheDocument()
})
it('should not show priority label when billing is disabled', () => {
render(
<IndexingProgressItem
detail={makeDetail()}
name="test.pdf"
enableBilling={false}
/>,
)
expect(screen.queryByTestId('priority-label')).not.toBeInTheDocument()
})
it('should apply error styling for error status', () => {
const { container } = render(
<IndexingProgressItem
detail={makeDetail({ indexing_status: 'error' })}
name="error.pdf"
/>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('bg-state-destructive-hover-alt')
})
})

View File

@@ -0,0 +1,145 @@
import type { ProcessRuleResponse } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ProcessMode } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import RuleDetail from '../rule-detail'
vi.mock('@/app/components/datasets/documents/detail/metadata', () => ({
FieldInfo: ({ label, displayedValue }: { label: string, displayedValue: string }) => (
<div data-testid="field-info">
<span data-testid="field-label">{label}</span>
<span data-testid="field-value">{displayedValue}</span>
</div>
),
}))
vi.mock('../../icons', () => ({
indexMethodIcon: { economical: '/icons/economical.svg', high_quality: '/icons/hq.svg' },
retrievalIcon: { fullText: '/icons/ft.svg', hybrid: '/icons/hy.svg', vector: '/icons/vec.svg' },
}))
describe('RuleDetail', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const makeSourceData = (overrides: Partial<ProcessRuleResponse> = {}): ProcessRuleResponse => ({
mode: ProcessMode.general,
rules: {
segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 50 },
pre_processing_rules: [
{ id: 'remove_extra_spaces', enabled: true },
{ id: 'remove_urls_emails', enabled: false },
],
},
...overrides,
} as ProcessRuleResponse)
it('should render mode, segment length, text cleaning, index mode, and retrieval fields', () => {
render(
<RuleDetail
sourceData={makeSourceData()}
indexingType="high_quality"
retrievalMethod={RETRIEVE_METHOD.semantic}
/>,
)
const fieldInfos = screen.getAllByTestId('field-info')
// mode, segmentLength, textCleaning, indexMode, retrievalSetting = 5
expect(fieldInfos.length).toBe(5)
})
it('should display "custom" for general mode', () => {
render(
<RuleDetail
sourceData={makeSourceData({ mode: ProcessMode.general })}
indexingType="high_quality"
/>,
)
const values = screen.getAllByTestId('field-value')
expect(values[0].textContent).toContain('embedding.custom')
})
it('should display hierarchical mode with parent mode label', () => {
render(
<RuleDetail
sourceData={makeSourceData({
mode: ProcessMode.parentChild,
rules: {
parent_mode: 'paragraph',
segmentation: { separator: '\n', max_tokens: 1000, chunk_overlap: 50 },
subchunk_segmentation: { max_tokens: 200 },
pre_processing_rules: [],
} as unknown as ProcessRuleResponse['rules'],
})}
indexingType="high_quality"
/>,
)
const values = screen.getAllByTestId('field-value')
expect(values[0].textContent).toContain('embedding.hierarchical')
})
it('should display "-" when no sourceData mode', () => {
render(
<RuleDetail
sourceData={makeSourceData({ mode: undefined as unknown as ProcessMode })}
indexingType="high_quality"
/>,
)
const values = screen.getAllByTestId('field-value')
expect(values[0].textContent).toBe('-')
})
it('should display segment length for general mode', () => {
render(
<RuleDetail
sourceData={makeSourceData()}
indexingType="high_quality"
/>,
)
const values = screen.getAllByTestId('field-value')
expect(values[1].textContent).toBe('500')
})
it('should display enabled pre-processing rules', () => {
render(
<RuleDetail
sourceData={makeSourceData()}
indexingType="high_quality"
/>,
)
const values = screen.getAllByTestId('field-value')
// Only remove_extra_spaces is enabled
expect(values[2].textContent).toContain('stepTwo.removeExtraSpaces')
})
it('should display economical index mode', () => {
render(
<RuleDetail
sourceData={makeSourceData()}
indexingType="economy"
/>,
)
const values = screen.getAllByTestId('field-value')
// Index mode field is 4th (index 3)
expect(values[3].textContent).toContain('stepTwo.economical')
})
it('should display qualified index mode for high_quality', () => {
render(
<RuleDetail
sourceData={makeSourceData()}
indexingType="high_quality"
/>,
)
const values = screen.getAllByTestId('field-value')
expect(values[3].textContent).toContain('stepTwo.qualified')
})
})

View File

@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import UpgradeBanner from '../upgrade-banner'
vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({
ZapFast: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="zap-icon" {...props} />,
}))
vi.mock('@/app/components/billing/upgrade-btn', () => ({
default: ({ loc }: { loc: string }) => <button data-testid="upgrade-btn" data-loc={loc}>Upgrade</button>,
}))
describe('UpgradeBanner', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the banner with icon, text, and upgrade button', () => {
render(<UpgradeBanner />)
expect(screen.getByTestId('zap-icon')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.documentProcessingPriorityUpgrade')).toBeInTheDocument()
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
})
it('should pass correct loc to UpgradeBtn', () => {
render(<UpgradeBanner />)
expect(screen.getByTestId('upgrade-btn')).toHaveAttribute('data-loc', 'knowledge-speed-up')
})
})

View File

@@ -0,0 +1,179 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useIndexingStatusPolling } from '../use-indexing-status-polling'
const mockFetchIndexingStatusBatch = vi.fn()
vi.mock('@/service/datasets', () => ({
fetchIndexingStatusBatch: (...args: unknown[]) => mockFetchIndexingStatusBatch(...args),
}))
describe('useIndexingStatusPolling', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
})
afterEach(() => {
vi.useRealTimers()
})
const defaultParams = { datasetId: 'ds-1', batchId: 'batch-1' }
it('should initialize with empty status list', async () => {
mockFetchIndexingStatusBatch.mockReturnValue(new Promise(() => {}))
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
expect(result.current.statusList).toEqual([])
expect(result.current.isEmbedding).toBe(false)
expect(result.current.isEmbeddingCompleted).toBe(false)
})
it('should fetch status on mount and update state', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [{ indexing_status: 'indexing', completed_segments: 5, total_segments: 10 }],
})
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
// Flush the resolved promise
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(mockFetchIndexingStatusBatch).toHaveBeenCalledWith({
datasetId: 'ds-1',
batchId: 'batch-1',
})
expect(result.current.statusList).toHaveLength(1)
expect(result.current.isEmbedding).toBe(true)
expect(result.current.isEmbeddingCompleted).toBe(false)
})
it('should stop polling when all completed', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [{ indexing_status: 'completed' }],
})
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(result.current.isEmbeddingCompleted).toBe(true)
expect(result.current.isEmbedding).toBe(false)
// Should not schedule another poll
const callCount = mockFetchIndexingStatusBatch.mock.calls.length
await act(async () => {
await vi.advanceTimersByTimeAsync(5000)
})
expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(callCount)
})
it('should continue polling on fetch error', async () => {
mockFetchIndexingStatusBatch
.mockRejectedValueOnce(new Error('network'))
.mockResolvedValueOnce({
data: [{ indexing_status: 'completed' }],
})
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
// First call: rejects
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
// Advance past polling interval for retry
await act(async () => {
await vi.advanceTimersByTimeAsync(2500)
})
expect(result.current.isEmbeddingCompleted).toBe(true)
})
it('should detect embedding statuses', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [
{ indexing_status: 'splitting' },
{ indexing_status: 'parsing' },
],
})
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(result.current.isEmbedding).toBe(true)
expect(result.current.isEmbeddingCompleted).toBe(false)
})
it('should detect mixed statuses (some completed, some embedding)', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [
{ indexing_status: 'completed' },
{ indexing_status: 'indexing' },
],
})
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(result.current.statusList).toHaveLength(2)
expect(result.current.isEmbedding).toBe(true)
expect(result.current.isEmbeddingCompleted).toBe(false)
})
it('should cleanup on unmount', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [{ indexing_status: 'indexing' }],
})
const { unmount } = renderHook(() => useIndexingStatusPolling(defaultParams))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
const callCount = mockFetchIndexingStatusBatch.mock.calls.length
unmount()
await act(async () => {
await vi.advanceTimersByTimeAsync(5000)
})
expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(callCount)
})
it('should treat error and paused as completed statuses', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [
{ indexing_status: 'error' },
{ indexing_status: 'paused' },
],
})
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(result.current.isEmbeddingCompleted).toBe(true)
expect(result.current.isEmbedding).toBe(false)
})
it('should poll at 2500ms intervals', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [{ indexing_status: 'indexing' }],
})
renderHook(() => useIndexingStatusPolling(defaultParams))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(1)
await act(async () => {
await vi.advanceTimersByTimeAsync(2500)
})
expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,140 @@
import type { DataSourceInfo, FullDocumentDetail, IndexingStatusResponse } from '@/models/datasets'
import { describe, expect, it } from 'vitest'
import { createDocumentLookup, getFileType, getSourcePercent, isLegacyDataSourceInfo, isSourceEmbedding } from '../utils'
describe('isLegacyDataSourceInfo', () => {
it('should return true when upload_file object exists', () => {
const info = { upload_file: { id: '1', name: 'test.pdf' } } as unknown as DataSourceInfo
expect(isLegacyDataSourceInfo(info)).toBe(true)
})
it('should return false when upload_file is absent', () => {
const info = { notion_page_icon: 'icon' } as unknown as DataSourceInfo
expect(isLegacyDataSourceInfo(info)).toBe(false)
})
it('should return false for null', () => {
expect(isLegacyDataSourceInfo(null as unknown as DataSourceInfo)).toBe(false)
})
it('should return false when upload_file is a string', () => {
const info = { upload_file: 'not-an-object' } as unknown as DataSourceInfo
expect(isLegacyDataSourceInfo(info)).toBe(false)
})
})
describe('isSourceEmbedding', () => {
const embeddingStatuses = ['indexing', 'splitting', 'parsing', 'cleaning', 'waiting']
const nonEmbeddingStatuses = ['completed', 'error', 'paused', 'unknown']
it.each(embeddingStatuses)('should return true for status "%s"', (status) => {
expect(isSourceEmbedding({ indexing_status: status } as IndexingStatusResponse)).toBe(true)
})
it.each(nonEmbeddingStatuses)('should return false for status "%s"', (status) => {
expect(isSourceEmbedding({ indexing_status: status } as IndexingStatusResponse)).toBe(false)
})
})
describe('getSourcePercent', () => {
it('should calculate correct percentage', () => {
expect(getSourcePercent({ completed_segments: 50, total_segments: 100 } as IndexingStatusResponse)).toBe(50)
})
it('should return 0 when total is 0', () => {
expect(getSourcePercent({ completed_segments: 0, total_segments: 0 } as IndexingStatusResponse)).toBe(0)
})
it('should cap at 100', () => {
expect(getSourcePercent({ completed_segments: 150, total_segments: 100 } as IndexingStatusResponse)).toBe(100)
})
it('should round to nearest integer', () => {
expect(getSourcePercent({ completed_segments: 1, total_segments: 3 } as IndexingStatusResponse)).toBe(33)
})
it('should handle undefined segments as 0', () => {
expect(getSourcePercent({} as IndexingStatusResponse)).toBe(0)
})
})
describe('getFileType', () => {
it('should extract extension from filename', () => {
expect(getFileType('document.pdf')).toBe('pdf')
})
it('should return last extension for multi-dot names', () => {
expect(getFileType('archive.tar.gz')).toBe('gz')
})
it('should default to "txt" for undefined', () => {
expect(getFileType(undefined)).toBe('txt')
})
it('should default to "txt" for empty string', () => {
expect(getFileType('')).toBe('txt')
})
})
describe('createDocumentLookup', () => {
const documents = [
{
id: 'doc-1',
name: 'test.pdf',
data_source_type: 'upload_file',
data_source_info: {
upload_file: { id: 'f1', name: 'test.pdf' },
notion_page_icon: undefined,
},
},
{
id: 'doc-2',
name: 'notion-page',
data_source_type: 'notion_import',
data_source_info: {
upload_file: { id: 'f2', name: '' },
notion_page_icon: 'https://icon.url',
},
},
] as unknown as FullDocumentDetail[]
it('should get document by id', () => {
const lookup = createDocumentLookup(documents)
expect(lookup.getDocument('doc-1')).toBe(documents[0])
})
it('should return undefined for non-existent id', () => {
const lookup = createDocumentLookup(documents)
expect(lookup.getDocument('non-existent')).toBeUndefined()
})
it('should get name by id', () => {
const lookup = createDocumentLookup(documents)
expect(lookup.getName('doc-1')).toBe('test.pdf')
})
it('should get source type by id', () => {
const lookup = createDocumentLookup(documents)
expect(lookup.getSourceType('doc-1')).toBe('upload_file')
})
it('should get notion icon for legacy data source', () => {
const lookup = createDocumentLookup(documents)
expect(lookup.getNotionIcon('doc-2')).toBe('https://icon.url')
})
it('should return undefined notion icon for non-legacy info', () => {
const docs = [{
id: 'doc-3',
data_source_info: { some_other: 'field' },
}] as unknown as FullDocumentDetail[]
const lookup = createDocumentLookup(docs)
expect(lookup.getNotionIcon('doc-3')).toBeUndefined()
})
it('should handle empty documents list', () => {
const lookup = createDocumentLookup([])
expect(lookup.getDocument('any')).toBeUndefined()
expect(lookup.getName('any')).toBeUndefined()
})
})

View File

@@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { createEmptyDataset } from '@/service/datasets'
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import EmptyDatasetCreationModal from './index'
import EmptyDatasetCreationModal from '../index'
// Mock Next.js router
const mockPush = vi.fn()
@@ -54,15 +54,11 @@ describe('EmptyDatasetCreationModal', () => {
} as ReturnType<typeof createEmptyDataset> extends Promise<infer T> ? T : never)
})
// ==========================================
// Rendering Tests - Verify component renders correctly
// ==========================================
describe('Rendering', () => {
it('should render without crashing when show is true', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<EmptyDatasetCreationModal {...props} />)
// Assert - Check modal title is rendered
@@ -70,13 +66,10 @@ describe('EmptyDatasetCreationModal', () => {
})
it('should render modal with correct elements', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<EmptyDatasetCreationModal {...props} />)
// Assert
expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.modal.tip')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.modal.input')).toBeInTheDocument()
@@ -86,22 +79,17 @@ describe('EmptyDatasetCreationModal', () => {
})
it('should render input with empty value initially', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<EmptyDatasetCreationModal {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement
expect(input.value).toBe('')
})
it('should not render modal content when show is false', () => {
// Arrange
const props = createDefaultProps({ show: false })
// Act
render(<EmptyDatasetCreationModal {...props} />)
// Assert - Modal should not be visible (check for absence of title)
@@ -109,29 +97,22 @@ describe('EmptyDatasetCreationModal', () => {
})
})
// ==========================================
// Props Testing - Verify all prop variations work correctly
// ==========================================
describe('Props', () => {
describe('show prop', () => {
it('should show modal when show is true', () => {
// Arrange & Act
render(<EmptyDatasetCreationModal show={true} onHide={vi.fn()} />)
// Assert
expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument()
})
it('should hide modal when show is false', () => {
// Arrange & Act
render(<EmptyDatasetCreationModal show={false} onHide={vi.fn()} />)
// Assert
expect(screen.queryByText('datasetCreation.stepOne.modal.title')).not.toBeInTheDocument()
})
it('should toggle visibility when show prop changes', () => {
// Arrange
const onHide = vi.fn()
const { rerender } = render(<EmptyDatasetCreationModal show={false} onHide={onHide} />)
@@ -146,20 +127,16 @@ describe('EmptyDatasetCreationModal', () => {
describe('onHide prop', () => {
it('should call onHide when cancel button is clicked', () => {
// Arrange
const mockOnHide = vi.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
// Act
const cancelButton = screen.getByText('datasetCreation.stepOne.modal.cancelButton')
fireEvent.click(cancelButton)
// Assert
expect(mockOnHide).toHaveBeenCalledTimes(1)
})
it('should call onHide when close icon is clicked', async () => {
// Arrange
const mockOnHide = vi.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
@@ -172,31 +149,24 @@ describe('EmptyDatasetCreationModal', () => {
expect(closeButton).toBeInTheDocument()
fireEvent.click(closeButton!)
// Assert
expect(mockOnHide).toHaveBeenCalledTimes(1)
})
})
})
// ==========================================
// State Management - Test input state updates
// ==========================================
describe('State Management', () => {
it('should update input value when user types', () => {
// Arrange
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement
// Act
fireEvent.change(input, { target: { value: 'My Dataset' } })
// Assert
expect(input.value).toBe('My Dataset')
})
it('should persist input value when modal is hidden and shown again via rerender', () => {
// Arrange
const onHide = vi.fn()
const { rerender } = render(<EmptyDatasetCreationModal show={true} onHide={onHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement
@@ -215,12 +185,10 @@ describe('EmptyDatasetCreationModal', () => {
})
it('should handle consecutive input changes', () => {
// Arrange
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement
// Act & Assert
fireEvent.change(input, { target: { value: 'A' } })
expect(input.value).toBe('A')
@@ -232,29 +200,23 @@ describe('EmptyDatasetCreationModal', () => {
})
})
// ==========================================
// User Interactions - Test event handlers
// ==========================================
describe('User Interactions', () => {
it('should submit form when confirm button is clicked with valid input', async () => {
// Arrange
const mockOnHide = vi.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Valid Dataset Name' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'Valid Dataset Name' })
})
})
it('should show error notification when input is empty', async () => {
// Arrange
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
@@ -262,7 +224,6 @@ describe('EmptyDatasetCreationModal', () => {
// Act - Click confirm without entering a name
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
@@ -273,7 +234,6 @@ describe('EmptyDatasetCreationModal', () => {
})
it('should show error notification when input exceeds 40 characters', async () => {
// Arrange
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
@@ -284,7 +244,6 @@ describe('EmptyDatasetCreationModal', () => {
fireEvent.change(input, { target: { value: longName } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
@@ -295,7 +254,6 @@ describe('EmptyDatasetCreationModal', () => {
})
it('should allow exactly 40 characters', async () => {
// Arrange
const mockOnHide = vi.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
@@ -306,94 +264,76 @@ describe('EmptyDatasetCreationModal', () => {
fireEvent.change(input, { target: { value: exactLengthName } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: exactLengthName })
})
})
it('should close modal on cancel button click', () => {
// Arrange
const mockOnHide = vi.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const cancelButton = screen.getByText('datasetCreation.stepOne.modal.cancelButton')
// Act
fireEvent.click(cancelButton)
// Assert
expect(mockOnHide).toHaveBeenCalledTimes(1)
})
})
// ==========================================
// API Calls - Test API interactions
// ==========================================
describe('API Calls', () => {
it('should call createEmptyDataset with correct parameters', async () => {
// Arrange
const mockOnHide = vi.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'New Dataset' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'New Dataset' })
})
})
it('should call invalidDatasetList after successful creation', async () => {
// Arrange
const mockOnHide = vi.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Test Dataset' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockInvalidDatasetList).toHaveBeenCalled()
})
})
it('should call onHide after successful creation', async () => {
// Arrange
const mockOnHide = vi.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Test Dataset' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockOnHide).toHaveBeenCalled()
})
})
it('should show error notification on API failure', async () => {
// Arrange
mockCreateEmptyDataset.mockRejectedValue(new Error('API Error'))
const mockOnHide = vi.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Test Dataset' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
@@ -403,14 +343,12 @@ describe('EmptyDatasetCreationModal', () => {
})
it('should not call onHide on API failure', async () => {
// Arrange
mockCreateEmptyDataset.mockRejectedValue(new Error('API Error'))
const mockOnHide = vi.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Test Dataset' } })
fireEvent.click(confirmButton)
@@ -423,18 +361,15 @@ describe('EmptyDatasetCreationModal', () => {
})
it('should not invalidate dataset list on API failure', async () => {
// Arrange
mockCreateEmptyDataset.mockRejectedValue(new Error('API Error'))
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Test Dataset' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalled()
})
@@ -442,12 +377,9 @@ describe('EmptyDatasetCreationModal', () => {
})
})
// ==========================================
// Router Navigation - Test Next.js router
// ==========================================
describe('Router Navigation', () => {
it('should navigate to dataset documents page after successful creation', async () => {
// Arrange
mockCreateEmptyDataset.mockResolvedValue({
id: 'test-dataset-456',
name: 'Test',
@@ -457,18 +389,15 @@ describe('EmptyDatasetCreationModal', () => {
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Test' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-456/documents')
})
})
it('should not navigate on validation error', async () => {
// Arrange
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
@@ -476,7 +405,6 @@ describe('EmptyDatasetCreationModal', () => {
// Act - Click confirm with empty input
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalled()
})
@@ -484,18 +412,15 @@ describe('EmptyDatasetCreationModal', () => {
})
it('should not navigate on API error', async () => {
// Arrange
mockCreateEmptyDataset.mockRejectedValue(new Error('API Error'))
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Test' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalled()
})
@@ -503,12 +428,9 @@ describe('EmptyDatasetCreationModal', () => {
})
})
// ==========================================
// Edge Cases - Test boundary conditions and error handling
// ==========================================
describe('Edge Cases', () => {
it('should handle whitespace-only input as valid (component behavior)', async () => {
// Arrange
const mockOnHide = vi.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
@@ -525,41 +447,34 @@ describe('EmptyDatasetCreationModal', () => {
})
it('should handle special characters in input', async () => {
// Arrange
const mockOnHide = vi.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Test @#$% Dataset!' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'Test @#$% Dataset!' })
})
})
it('should handle Unicode characters in input', async () => {
// Arrange
const mockOnHide = vi.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: '数据集测试 🚀' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: '数据集测试 🚀' })
})
})
it('should handle input at exactly 40 character boundary', async () => {
// Arrange
const mockOnHide = vi.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
@@ -570,14 +485,12 @@ describe('EmptyDatasetCreationModal', () => {
fireEvent.change(input, { target: { value: name40Chars } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: name40Chars })
})
})
it('should reject input at 41 character boundary', async () => {
// Arrange
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
@@ -588,7 +501,6 @@ describe('EmptyDatasetCreationModal', () => {
fireEvent.change(input, { target: { value: name41Chars } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
@@ -599,7 +511,6 @@ describe('EmptyDatasetCreationModal', () => {
})
it('should handle rapid consecutive submits', async () => {
// Arrange
const mockOnHide = vi.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
@@ -618,13 +529,11 @@ describe('EmptyDatasetCreationModal', () => {
})
it('should handle input with leading/trailing spaces', async () => {
// Arrange
const mockOnHide = vi.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: ' Dataset Name ' } })
fireEvent.click(confirmButton)
@@ -635,13 +544,11 @@ describe('EmptyDatasetCreationModal', () => {
})
it('should handle newline characters in input (browser strips newlines)', async () => {
// Arrange
const mockOnHide = vi.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Line1\nLine2' } })
fireEvent.click(confirmButton)
@@ -652,20 +559,15 @@ describe('EmptyDatasetCreationModal', () => {
})
})
// ==========================================
// Validation Tests - Test input validation
// ==========================================
describe('Validation', () => {
it('should not submit when input is empty string', async () => {
// Arrange
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
@@ -675,13 +577,11 @@ describe('EmptyDatasetCreationModal', () => {
})
it('should validate length before calling API', async () => {
// Arrange
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'A'.repeat(50) } })
fireEvent.click(confirmButton)
@@ -696,7 +596,6 @@ describe('EmptyDatasetCreationModal', () => {
})
it('should validate empty string before length check', async () => {
// Arrange
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
@@ -714,12 +613,9 @@ describe('EmptyDatasetCreationModal', () => {
})
})
// ==========================================
// Integration Tests - Test complete flows
// ==========================================
describe('Integration', () => {
it('should complete full successful creation flow', async () => {
// Arrange
const mockOnHide = vi.fn()
mockCreateEmptyDataset.mockResolvedValue({
id: 'new-id-789',
@@ -729,7 +625,6 @@ describe('EmptyDatasetCreationModal', () => {
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Complete Flow Test' } })
fireEvent.click(confirmButton)
@@ -747,14 +642,12 @@ describe('EmptyDatasetCreationModal', () => {
})
it('should handle error flow correctly', async () => {
// Arrange
const mockOnHide = vi.fn()
mockCreateEmptyDataset.mockRejectedValue(new Error('Server Error'))
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Error Test' } })
fireEvent.click(confirmButton)

View File

@@ -2,7 +2,7 @@ import type { MockedFunction } from 'vitest'
import type { CustomFile as File } from '@/models/datasets'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fetchFilePreview } from '@/service/common'
import FilePreview from './index'
import FilePreview from '../index'
// Mock the fetchFilePreview service
vi.mock('@/service/common', () => ({
@@ -48,9 +48,7 @@ const findLoadingSpinner = (container: HTMLElement) => {
return container.querySelector('.spin-animation')
}
// ============================================================================
// FilePreview Component Tests
// ============================================================================
describe('FilePreview', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -58,33 +56,25 @@ describe('FilePreview', () => {
mockFetchFilePreview.mockResolvedValue({ content: 'Preview content here' })
})
// --------------------------------------------------------------------------
// Rendering Tests - Verify component renders properly
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', async () => {
// Arrange & Act
renderFilePreview()
// Assert
await waitFor(() => {
expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
})
})
it('should render file preview header', async () => {
// Arrange & Act
renderFilePreview()
// Assert
expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
})
it('should render close button with XMarkIcon', async () => {
// Arrange & Act
const { container } = renderFilePreview()
// Assert
const closeButton = container.querySelector('.cursor-pointer')
expect(closeButton).toBeInTheDocument()
const xMarkIcon = closeButton?.querySelector('svg')
@@ -92,42 +82,32 @@ describe('FilePreview', () => {
})
it('should render file name without extension', async () => {
// Arrange
const file = createMockFile({ name: 'document.pdf' })
// Act
renderFilePreview({ file })
// Assert
await waitFor(() => {
expect(screen.getByText('document')).toBeInTheDocument()
})
})
it('should render file extension', async () => {
// Arrange
const file = createMockFile({ extension: 'pdf' })
// Act
renderFilePreview({ file })
// Assert
expect(screen.getByText('.pdf')).toBeInTheDocument()
})
it('should apply correct CSS classes to container', async () => {
// Arrange & Act
const { container } = renderFilePreview()
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('h-full')
})
})
// --------------------------------------------------------------------------
// Loading State Tests
// --------------------------------------------------------------------------
describe('Loading State', () => {
it('should show loading indicator initially', async () => {
// Arrange - Delay API response to keep loading state
@@ -135,7 +115,6 @@ describe('FilePreview', () => {
() => new Promise(resolve => setTimeout(() => resolve({ content: 'test' }), 100)),
)
// Act
const { container } = renderFilePreview()
// Assert - Loading should be visible initially (using spin-animation class)
@@ -144,13 +123,10 @@ describe('FilePreview', () => {
})
it('should hide loading indicator after content loads', async () => {
// Arrange
mockFetchFilePreview.mockResolvedValue({ content: 'Loaded content' })
// Act
const { container } = renderFilePreview()
// Assert
await waitFor(() => {
expect(screen.getByText('Loaded content')).toBeInTheDocument()
})
@@ -160,7 +136,6 @@ describe('FilePreview', () => {
})
it('should show loading when file changes', async () => {
// Arrange
const file1 = createMockFile({ id: 'file-1', name: 'file1.txt' })
const file2 = createMockFile({ id: 'file-2', name: 'file2.txt' })
@@ -207,48 +182,36 @@ describe('FilePreview', () => {
})
})
// --------------------------------------------------------------------------
// API Call Tests
// --------------------------------------------------------------------------
describe('API Calls', () => {
it('should call fetchFilePreview with correct fileID', async () => {
// Arrange
const file = createMockFile({ id: 'test-file-id' })
// Act
renderFilePreview({ file })
// Assert
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'test-file-id' })
})
})
it('should not call fetchFilePreview when file is undefined', async () => {
// Arrange & Act
renderFilePreview({ file: undefined })
// Assert
expect(mockFetchFilePreview).not.toHaveBeenCalled()
})
it('should not call fetchFilePreview when file has no id', async () => {
// Arrange
const file = createMockFile({ id: undefined })
// Act
renderFilePreview({ file })
// Assert
expect(mockFetchFilePreview).not.toHaveBeenCalled()
})
it('should call fetchFilePreview again when file changes', async () => {
// Arrange
const file1 = createMockFile({ id: 'file-1' })
const file2 = createMockFile({ id: 'file-2' })
// Act
const { rerender } = render(
<FilePreview file={file1} hidePreview={vi.fn()} />,
)
@@ -259,7 +222,6 @@ describe('FilePreview', () => {
rerender(<FilePreview file={file2} hidePreview={vi.fn()} />)
// Assert
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'file-2' })
expect(mockFetchFilePreview).toHaveBeenCalledTimes(2)
@@ -267,23 +229,18 @@ describe('FilePreview', () => {
})
it('should handle API success and display content', async () => {
// Arrange
mockFetchFilePreview.mockResolvedValue({ content: 'File preview content from API' })
// Act
renderFilePreview()
// Assert
await waitFor(() => {
expect(screen.getByText('File preview content from API')).toBeInTheDocument()
})
})
it('should handle API error gracefully', async () => {
// Arrange
mockFetchFilePreview.mockRejectedValue(new Error('Network error'))
// Act
const { container } = renderFilePreview()
// Assert - Component should not crash, loading may persist
@@ -295,10 +252,8 @@ describe('FilePreview', () => {
})
it('should handle empty content response', async () => {
// Arrange
mockFetchFilePreview.mockResolvedValue({ content: '' })
// Act
const { container } = renderFilePreview()
// Assert - Should still render without loading
@@ -309,29 +264,21 @@ describe('FilePreview', () => {
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call hidePreview when close button is clicked', async () => {
// Arrange
const hidePreview = vi.fn()
const { container } = renderFilePreview({ hidePreview })
// Act
const closeButton = container.querySelector('.cursor-pointer') as HTMLElement
fireEvent.click(closeButton)
// Assert
expect(hidePreview).toHaveBeenCalledTimes(1)
})
it('should call hidePreview with event object when clicked', async () => {
// Arrange
const hidePreview = vi.fn()
const { container } = renderFilePreview({ hidePreview })
// Act
const closeButton = container.querySelector('.cursor-pointer') as HTMLElement
fireEvent.click(closeButton)
@@ -341,52 +288,40 @@ describe('FilePreview', () => {
})
it('should handle multiple clicks on close button', async () => {
// Arrange
const hidePreview = vi.fn()
const { container } = renderFilePreview({ hidePreview })
// Act
const closeButton = container.querySelector('.cursor-pointer') as HTMLElement
fireEvent.click(closeButton)
fireEvent.click(closeButton)
fireEvent.click(closeButton)
// Assert
expect(hidePreview).toHaveBeenCalledTimes(3)
})
})
// --------------------------------------------------------------------------
// State Management Tests
// --------------------------------------------------------------------------
describe('State Management', () => {
it('should initialize with loading state true', async () => {
// Arrange - Keep loading indefinitely (never resolves)
mockFetchFilePreview.mockImplementation(() => new Promise(() => { /* intentionally empty */ }))
// Act
const { container } = renderFilePreview()
// Assert
const loadingElement = findLoadingSpinner(container)
expect(loadingElement).toBeInTheDocument()
})
it('should update previewContent state after successful fetch', async () => {
// Arrange
mockFetchFilePreview.mockResolvedValue({ content: 'New preview content' })
// Act
renderFilePreview()
// Assert
await waitFor(() => {
expect(screen.getByText('New preview content')).toBeInTheDocument()
})
})
it('should reset loading to true when file changes', async () => {
// Arrange
const file1 = createMockFile({ id: 'file-1' })
const file2 = createMockFile({ id: 'file-2' })
@@ -394,7 +329,6 @@ describe('FilePreview', () => {
.mockResolvedValueOnce({ content: 'Content 1' })
.mockImplementationOnce(() => new Promise(() => { /* never resolves */ }))
// Act
const { rerender, container } = render(
<FilePreview file={file1} hidePreview={vi.fn()} />,
)
@@ -414,7 +348,6 @@ describe('FilePreview', () => {
})
it('should preserve content until new content loads', async () => {
// Arrange
const file1 = createMockFile({ id: 'file-1' })
const file2 = createMockFile({ id: 'file-2' })
@@ -424,7 +357,6 @@ describe('FilePreview', () => {
.mockResolvedValueOnce({ content: 'Content 1' })
.mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))
// Act
const { rerender } = render(
<FilePreview file={file1} hidePreview={vi.fn()} />,
)
@@ -448,25 +380,18 @@ describe('FilePreview', () => {
})
})
// --------------------------------------------------------------------------
// Props Testing
// --------------------------------------------------------------------------
describe('Props', () => {
describe('file prop', () => {
it('should render correctly with file prop', async () => {
// Arrange
const file = createMockFile({ name: 'my-document.pdf', extension: 'pdf' })
// Act
renderFilePreview({ file })
// Assert
expect(screen.getByText('my-document')).toBeInTheDocument()
expect(screen.getByText('.pdf')).toBeInTheDocument()
})
it('should render correctly without file prop', async () => {
// Arrange & Act
renderFilePreview({ file: undefined })
// Assert - Header should still render
@@ -474,10 +399,8 @@ describe('FilePreview', () => {
})
it('should handle file with multiple dots in name', async () => {
// Arrange
const file = createMockFile({ name: 'my.document.v2.pdf' })
// Act
renderFilePreview({ file })
// Assert - Should join all parts except last with comma
@@ -485,10 +408,8 @@ describe('FilePreview', () => {
})
it('should handle file with no extension in name', async () => {
// Arrange
const file = createMockFile({ name: 'README' })
// Act
const { container } = renderFilePreview({ file })
// Assert - getFileName returns empty for single segment, but component still renders
@@ -500,10 +421,8 @@ describe('FilePreview', () => {
})
it('should handle file with empty name', async () => {
// Arrange
const file = createMockFile({ name: '' })
// Act
const { container } = renderFilePreview({ file })
// Assert - Should not crash
@@ -513,10 +432,8 @@ describe('FilePreview', () => {
describe('hidePreview prop', () => {
it('should accept hidePreview callback', async () => {
// Arrange
const hidePreview = vi.fn()
// Act
renderFilePreview({ hidePreview })
// Assert - No errors thrown
@@ -525,15 +442,10 @@ describe('FilePreview', () => {
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle file with undefined id', async () => {
// Arrange
const file = createMockFile({ id: undefined })
// Act
const { container } = renderFilePreview({ file })
// Assert - Should not call API, remain in loading state
@@ -542,10 +454,8 @@ describe('FilePreview', () => {
})
it('should handle file with empty string id', async () => {
// Arrange
const file = createMockFile({ id: '' })
// Act
renderFilePreview({ file })
// Assert - Empty string is falsy, should not call API
@@ -553,48 +463,37 @@ describe('FilePreview', () => {
})
it('should handle very long file names', async () => {
// Arrange
const longName = `${'a'.repeat(200)}.pdf`
const file = createMockFile({ name: longName })
// Act
renderFilePreview({ file })
// Assert
expect(screen.getByText('a'.repeat(200))).toBeInTheDocument()
})
it('should handle file with special characters in name', async () => {
// Arrange
const file = createMockFile({ name: 'file-with_special@#$%.txt' })
// Act
renderFilePreview({ file })
// Assert
expect(screen.getByText('file-with_special@#$%')).toBeInTheDocument()
})
it('should handle very long preview content', async () => {
// Arrange
const longContent = 'x'.repeat(10000)
mockFetchFilePreview.mockResolvedValue({ content: longContent })
// Act
renderFilePreview()
// Assert
await waitFor(() => {
expect(screen.getByText(longContent)).toBeInTheDocument()
})
})
it('should handle preview content with special characters safely', async () => {
// Arrange
const specialContent = '<script>alert("xss")</script>\n\t& < > "'
mockFetchFilePreview.mockResolvedValue({ content: specialContent })
// Act
const { container } = renderFilePreview()
// Assert - Should render as text, not execute scripts
@@ -607,25 +506,20 @@ describe('FilePreview', () => {
})
it('should handle preview content with unicode', async () => {
// Arrange
const unicodeContent = '中文内容 🚀 émojis & spëcîal çhàrs'
mockFetchFilePreview.mockResolvedValue({ content: unicodeContent })
// Act
renderFilePreview()
// Assert
await waitFor(() => {
expect(screen.getByText(unicodeContent)).toBeInTheDocument()
})
})
it('should handle preview content with newlines', async () => {
// Arrange
const multilineContent = 'Line 1\nLine 2\nLine 3'
mockFetchFilePreview.mockResolvedValue({ content: multilineContent })
// Act
const { container } = renderFilePreview()
// Assert - Content should be in the DOM
@@ -639,10 +533,8 @@ describe('FilePreview', () => {
})
it('should handle null content from API', async () => {
// Arrange
mockFetchFilePreview.mockResolvedValue({ content: null as unknown as string })
// Act
const { container } = renderFilePreview()
// Assert - Should not crash
@@ -652,16 +544,12 @@ describe('FilePreview', () => {
})
})
// --------------------------------------------------------------------------
// Side Effects and Cleanup Tests
// --------------------------------------------------------------------------
describe('Side Effects and Cleanup', () => {
it('should trigger effect when file prop changes', async () => {
// Arrange
const file1 = createMockFile({ id: 'file-1' })
const file2 = createMockFile({ id: 'file-2' })
// Act
const { rerender } = render(
<FilePreview file={file1} hidePreview={vi.fn()} />,
)
@@ -672,19 +560,16 @@ describe('FilePreview', () => {
rerender(<FilePreview file={file2} hidePreview={vi.fn()} />)
// Assert
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledTimes(2)
})
})
it('should not trigger effect when hidePreview changes', async () => {
// Arrange
const file = createMockFile()
const hidePreview1 = vi.fn()
const hidePreview2 = vi.fn()
// Act
const { rerender } = render(
<FilePreview file={file} hidePreview={hidePreview1} />,
)
@@ -703,11 +588,9 @@ describe('FilePreview', () => {
})
it('should handle rapid file changes', async () => {
// Arrange
const files = Array.from({ length: 5 }, (_, i) =>
createMockFile({ id: `file-${i}` }))
// Act
const { rerender } = render(
<FilePreview file={files[0]} hidePreview={vi.fn()} />,
)
@@ -723,12 +606,10 @@ describe('FilePreview', () => {
})
it('should handle unmount during loading', async () => {
// Arrange
mockFetchFilePreview.mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve({ content: 'delayed' }), 1000)),
)
// Act
const { unmount } = renderFilePreview()
// Unmount before API resolves
@@ -739,10 +620,8 @@ describe('FilePreview', () => {
})
it('should handle file changing from defined to undefined', async () => {
// Arrange
const file = createMockFile()
// Act
const { rerender, container } = render(
<FilePreview file={file} hidePreview={vi.fn()} />,
)
@@ -759,26 +638,19 @@ describe('FilePreview', () => {
})
})
// --------------------------------------------------------------------------
// getFileName Helper Tests
// --------------------------------------------------------------------------
describe('getFileName Helper', () => {
it('should extract name without extension for simple filename', async () => {
// Arrange
const file = createMockFile({ name: 'document.pdf' })
// Act
renderFilePreview({ file })
// Assert
expect(screen.getByText('document')).toBeInTheDocument()
})
it('should handle filename with multiple dots', async () => {
// Arrange
const file = createMockFile({ name: 'file.name.with.dots.txt' })
// Act
renderFilePreview({ file })
// Assert - Should join all parts except last with comma
@@ -786,10 +658,8 @@ describe('FilePreview', () => {
})
it('should return empty for filename without dot', async () => {
// Arrange
const file = createMockFile({ name: 'nodotfile' })
// Act
const { container } = renderFilePreview({ file })
// Assert - slice(0, -1) on single element array returns empty
@@ -799,7 +669,6 @@ describe('FilePreview', () => {
})
it('should return empty string when file is undefined', async () => {
// Arrange & Act
const { container } = renderFilePreview({ file: undefined })
// Assert - File name area should have empty first span
@@ -808,38 +677,27 @@ describe('FilePreview', () => {
})
})
// --------------------------------------------------------------------------
// Accessibility Tests
// --------------------------------------------------------------------------
describe('Accessibility', () => {
it('should have clickable close button with visual indicator', async () => {
// Arrange & Act
const { container } = renderFilePreview()
// Assert
const closeButton = container.querySelector('.cursor-pointer')
expect(closeButton).toBeInTheDocument()
expect(closeButton).toHaveClass('cursor-pointer')
})
it('should have proper heading structure', async () => {
// Arrange & Act
renderFilePreview()
// Assert
expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Error Handling Tests
// --------------------------------------------------------------------------
describe('Error Handling', () => {
it('should not crash on API network error', async () => {
// Arrange
mockFetchFilePreview.mockRejectedValue(new Error('Network Error'))
// Act
const { container } = renderFilePreview()
// Assert - Component should still render
@@ -849,26 +707,20 @@ describe('FilePreview', () => {
})
it('should not crash on API timeout', async () => {
// Arrange
mockFetchFilePreview.mockRejectedValue(new Error('Timeout'))
// Act
const { container } = renderFilePreview()
// Assert
await waitFor(() => {
expect(container.firstChild).toBeInTheDocument()
})
})
it('should not crash on malformed API response', async () => {
// Arrange
mockFetchFilePreview.mockResolvedValue({} as { content: string })
// Act
const { container } = renderFilePreview()
// Assert
await waitFor(() => {
expect(container.firstChild).toBeInTheDocument()
})

View File

@@ -1,26 +1,9 @@
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PROGRESS_NOT_STARTED } from './constants'
import FileUploader from './index'
import { PROGRESS_NOT_STARTED } from '../constants'
import FileUploader from '../index'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'stepOne.uploader.title': 'Upload Files',
'stepOne.uploader.button': 'Drag and drop files, or',
'stepOne.uploader.buttonSingleFile': 'Drag and drop file, or',
'stepOne.uploader.browse': 'Browse',
'stepOne.uploader.tip': 'Supports various file types',
}
return translations[key] || key
},
}),
}))
// Mock ToastContext
const mockNotify = vi.fn()
vi.mock('use-context-selector', async () => {
const actual = await vi.importActual<typeof import('use-context-selector')>('use-context-selector')
@@ -118,22 +101,22 @@ describe('FileUploader', () => {
describe('rendering', () => {
it('should render the component', () => {
render(<FileUploader {...defaultProps} />)
expect(screen.getByText('Upload Files')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.uploader.title')).toBeInTheDocument()
})
it('should render dropzone when no files', () => {
render(<FileUploader {...defaultProps} />)
expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument()
expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.button/)).toBeInTheDocument()
})
it('should render browse button', () => {
render(<FileUploader {...defaultProps} />)
expect(screen.getByText('Browse')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.uploader.browse')).toBeInTheDocument()
})
it('should apply custom title className', () => {
render(<FileUploader {...defaultProps} titleClassName="custom-class" />)
const title = screen.getByText('Upload Files')
const title = screen.getByText('datasetCreation.stepOne.uploader.title')
expect(title).toHaveClass('custom-class')
})
})
@@ -162,19 +145,19 @@ describe('FileUploader', () => {
describe('batch upload mode', () => {
it('should show dropzone with batch upload enabled', () => {
render(<FileUploader {...defaultProps} supportBatchUpload={true} />)
expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument()
expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.button/)).toBeInTheDocument()
})
it('should show single file text when batch upload disabled', () => {
render(<FileUploader {...defaultProps} supportBatchUpload={false} />)
expect(screen.getByText(/Drag and drop file/i)).toBeInTheDocument()
expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.buttonSingleFile/)).toBeInTheDocument()
})
it('should hide dropzone when not batch upload and has files', () => {
const fileList = [createMockFileItem()]
render(<FileUploader {...defaultProps} supportBatchUpload={false} fileList={fileList} />)
expect(screen.queryByText(/Drag and drop/i)).not.toBeInTheDocument()
expect(screen.queryByText(/datasetCreation\.stepOne\.uploader\.button/)).not.toBeInTheDocument()
})
})
@@ -217,7 +200,7 @@ describe('FileUploader', () => {
render(<FileUploader {...defaultProps} />)
// The browse label should trigger file input click
const browseLabel = screen.getByText('Browse')
const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse')
expect(browseLabel).toHaveClass('cursor-pointer')
})
})

View File

@@ -1,9 +1,9 @@
import type { FileListItemProps } from './file-list-item'
import type { FileListItemProps } from '../file-list-item'
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants'
import FileListItem from './file-list-item'
import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants'
import FileListItem from '../file-list-item'
// Mock theme hook - can be changed per test
let mockTheme = 'light'

View File

@@ -1,33 +1,12 @@
import type { RefObject } from 'react'
import type { UploadDropzoneProps } from './upload-dropzone'
import type { UploadDropzoneProps } from '../upload-dropzone'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import UploadDropzone from './upload-dropzone'
import UploadDropzone from '../upload-dropzone'
// Helper to create mock ref objects for testing
const createMockRef = <T,>(value: T | null = null): RefObject<T | null> => ({ current: value })
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
const translations: Record<string, string> = {
'stepOne.uploader.button': 'Drag and drop files, or',
'stepOne.uploader.buttonSingleFile': 'Drag and drop file, or',
'stepOne.uploader.browse': 'Browse',
'stepOne.uploader.tip': 'Supports {{supportTypes}}, Max {{size}}MB each, up to {{batchCount}} files at a time, {{totalCount}} files total',
}
let result = translations[key] || key
if (options && typeof options === 'object') {
Object.entries(options).forEach(([k, v]) => {
result = result.replace(`{{${k}}}`, String(v))
})
}
return result
},
}),
}))
describe('UploadDropzone', () => {
const defaultProps: UploadDropzoneProps = {
dropRef: createMockRef<HTMLDivElement>() as RefObject<HTMLDivElement | null>,
@@ -73,17 +52,17 @@ describe('UploadDropzone', () => {
it('should render browse label when extensions are allowed', () => {
render(<UploadDropzone {...defaultProps} />)
expect(screen.getByText('Browse')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.uploader.browse')).toBeInTheDocument()
})
it('should not render browse label when no extensions allowed', () => {
render(<UploadDropzone {...defaultProps} acceptTypes={[]} />)
expect(screen.queryByText('Browse')).not.toBeInTheDocument()
expect(screen.queryByText('datasetCreation.stepOne.uploader.browse')).not.toBeInTheDocument()
})
it('should render file size and count limits', () => {
render(<UploadDropzone {...defaultProps} />)
const tipText = screen.getByText(/Supports.*Max.*15MB/i)
const tipText = screen.getByText(/datasetCreation\.stepOne\.uploader\.tip/)
expect(tipText).toBeInTheDocument()
})
})
@@ -111,12 +90,12 @@ describe('UploadDropzone', () => {
describe('text content', () => {
it('should show batch upload text when supportBatchUpload is true', () => {
render(<UploadDropzone {...defaultProps} supportBatchUpload={true} />)
expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument()
expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.button/)).toBeInTheDocument()
})
it('should show single file text when supportBatchUpload is false', () => {
render(<UploadDropzone {...defaultProps} supportBatchUpload={false} />)
expect(screen.getByText(/Drag and drop file/i)).toBeInTheDocument()
expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.buttonSingleFile/)).toBeInTheDocument()
})
})
@@ -146,7 +125,7 @@ describe('UploadDropzone', () => {
const onSelectFile = vi.fn()
render(<UploadDropzone {...defaultProps} onSelectFile={onSelectFile} />)
const browseLabel = screen.getByText('Browse')
const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse')
fireEvent.click(browseLabel)
expect(onSelectFile).toHaveBeenCalledTimes(1)
@@ -195,7 +174,7 @@ describe('UploadDropzone', () => {
it('should have cursor-pointer on browse label', () => {
render(<UploadDropzone {...defaultProps} />)
const browseLabel = screen.getByText('Browse')
const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse')
expect(browseLabel).toHaveClass('cursor-pointer')
})
})

View File

@@ -4,15 +4,14 @@ import { act, render, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast'
import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants'
import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants'
// Import after mocks
import { useFileUpload } from './use-file-upload'
import { useFileUpload } from '../use-file-upload'
// Mock notify function
const mockNotify = vi.fn()
const mockClose = vi.fn()
// Mock ToastContext
vi.mock('use-context-selector', async () => {
const actual = await vi.importActual<typeof import('use-context-selector')>('use-context-selector')
return {
@@ -44,12 +43,6 @@ vi.mock('@/service/use-common', () => ({
}))
// Mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock locale
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
@@ -59,7 +52,6 @@ vi.mock('@/i18n-config/language', () => ({
LanguagesSupported: ['en-US', 'zh-Hans'],
}))
// Mock config
vi.mock('@/config', () => ({
IS_CE_EDITION: false,
}))

View File

@@ -2,7 +2,7 @@ import type { MockedFunction } from 'vitest'
import type { NotionPage } from '@/models/common'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fetchNotionPagePreview } from '@/service/datasets'
import NotionPagePreview from './index'
import NotionPagePreview from '../index'
// Mock the fetchNotionPagePreview service
vi.mock('@/service/datasets', () => ({
@@ -85,13 +85,10 @@ const findLoadingSpinner = (container: HTMLElement) => {
return container.querySelector('.spin-animation')
}
// ============================================================================
// NotionPagePreview Component Tests
// ============================================================================
// Note: Branch coverage is ~88% because line 29 (`if (!currentPage) return`)
// is defensive code that cannot be reached - getPreviewContent is only called
// from useEffect when currentPage is truthy.
// ============================================================================
describe('NotionPagePreview', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -106,31 +103,23 @@ describe('NotionPagePreview', () => {
})
})
// --------------------------------------------------------------------------
// Rendering Tests - Verify component renders properly
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', async () => {
// Arrange & Act
await renderNotionPagePreview()
// Assert
expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument()
})
it('should render page preview header', async () => {
// Arrange & Act
await renderNotionPagePreview()
// Assert
expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument()
})
it('should render close button with XMarkIcon', async () => {
// Arrange & Act
const { container } = await renderNotionPagePreview()
// Assert
const closeButton = container.querySelector('.cursor-pointer')
expect(closeButton).toBeInTheDocument()
const xMarkIcon = closeButton?.querySelector('svg')
@@ -138,30 +127,23 @@ describe('NotionPagePreview', () => {
})
it('should render page name', async () => {
// Arrange
const page = createMockNotionPage({ page_name: 'My Notion Page' })
// Act
await renderNotionPagePreview({ currentPage: page })
// Assert
expect(screen.getByText('My Notion Page')).toBeInTheDocument()
})
it('should apply correct CSS classes to container', async () => {
// Arrange & Act
const { container } = await renderNotionPagePreview()
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('h-full')
})
it('should render NotionIcon component', async () => {
// Arrange
const page = createMockNotionPage()
// Act
const { container } = await renderNotionPagePreview({ currentPage: page })
// Assert - NotionIcon should be rendered (either as img or div or svg)
@@ -170,15 +152,11 @@ describe('NotionPagePreview', () => {
})
})
// --------------------------------------------------------------------------
// NotionIcon Rendering Tests
// --------------------------------------------------------------------------
describe('NotionIcon Rendering', () => {
it('should render default icon when page_icon is null', async () => {
// Arrange
const page = createMockNotionPage({ page_icon: null })
// Act
const { container } = await renderNotionPagePreview({ currentPage: page })
// Assert - Should render RiFileTextLine icon (svg)
@@ -187,33 +165,25 @@ describe('NotionPagePreview', () => {
})
it('should render emoji icon when page_icon has emoji type', async () => {
// Arrange
const page = createMockNotionPageWithEmojiIcon('📝')
// Act
await renderNotionPagePreview({ currentPage: page })
// Assert
expect(screen.getByText('📝')).toBeInTheDocument()
})
it('should render image icon when page_icon has url type', async () => {
// Arrange
const page = createMockNotionPageWithUrlIcon('https://example.com/icon.png')
// Act
const { container } = await renderNotionPagePreview({ currentPage: page })
// Assert
const img = container.querySelector('img[alt="page icon"]')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', 'https://example.com/icon.png')
})
})
// --------------------------------------------------------------------------
// Loading State Tests
// --------------------------------------------------------------------------
describe('Loading State', () => {
it('should show loading indicator initially', async () => {
// Arrange - Delay API response to keep loading state
@@ -230,13 +200,10 @@ describe('NotionPagePreview', () => {
})
it('should hide loading indicator after content loads', async () => {
// Arrange
mockFetchNotionPagePreview.mockResolvedValue({ content: 'Loaded content' })
// Act
const { container } = await renderNotionPagePreview()
// Assert
expect(screen.getByText('Loaded content')).toBeInTheDocument()
// Loading should be gone
const loadingElement = findLoadingSpinner(container)
@@ -244,7 +211,6 @@ describe('NotionPagePreview', () => {
})
it('should show loading when currentPage changes', async () => {
// Arrange
const page1 = createMockNotionPage({ page_id: 'page-1', page_name: 'Page 1' })
const page2 = createMockNotionPage({ page_id: 'page-2', page_name: 'Page 2' })
@@ -291,24 +257,19 @@ describe('NotionPagePreview', () => {
})
})
// --------------------------------------------------------------------------
// API Call Tests
// --------------------------------------------------------------------------
describe('API Calls', () => {
it('should call fetchNotionPagePreview with correct parameters', async () => {
// Arrange
const page = createMockNotionPage({
page_id: 'test-page-id',
type: 'database',
})
// Act
await renderNotionPagePreview({
currentPage: page,
notionCredentialId: 'test-credential-id',
})
// Assert
expect(mockFetchNotionPagePreview).toHaveBeenCalledWith({
pageID: 'test-page-id',
pageType: 'database',
@@ -317,19 +278,15 @@ describe('NotionPagePreview', () => {
})
it('should not call fetchNotionPagePreview when currentPage is undefined', async () => {
// Arrange & Act
await renderNotionPagePreview({ currentPage: undefined }, false)
// Assert
expect(mockFetchNotionPagePreview).not.toHaveBeenCalled()
})
it('should call fetchNotionPagePreview again when currentPage changes', async () => {
// Arrange
const page1 = createMockNotionPage({ page_id: 'page-1' })
const page2 = createMockNotionPage({ page_id: 'page-2' })
// Act
const { rerender } = render(
<NotionPagePreview currentPage={page1} notionCredentialId="cred-123" hidePreview={vi.fn()} />,
)
@@ -346,7 +303,6 @@ describe('NotionPagePreview', () => {
rerender(<NotionPagePreview currentPage={page2} notionCredentialId="cred-123" hidePreview={vi.fn()} />)
})
// Assert
await waitFor(() => {
expect(mockFetchNotionPagePreview).toHaveBeenCalledWith({
pageID: 'page-2',
@@ -358,21 +314,16 @@ describe('NotionPagePreview', () => {
})
it('should handle API success and display content', async () => {
// Arrange
mockFetchNotionPagePreview.mockResolvedValue({ content: 'Notion page preview content from API' })
// Act
await renderNotionPagePreview()
// Assert
expect(screen.getByText('Notion page preview content from API')).toBeInTheDocument()
})
it('should handle API error gracefully', async () => {
// Arrange
mockFetchNotionPagePreview.mockRejectedValue(new Error('Network error'))
// Act
const { container } = await renderNotionPagePreview({}, false)
// Assert - Component should not crash
@@ -384,10 +335,8 @@ describe('NotionPagePreview', () => {
})
it('should handle empty content response', async () => {
// Arrange
mockFetchNotionPagePreview.mockResolvedValue({ content: '' })
// Act
const { container } = await renderNotionPagePreview()
// Assert - Should still render without loading
@@ -396,42 +345,30 @@ describe('NotionPagePreview', () => {
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call hidePreview when close button is clicked', async () => {
// Arrange
const hidePreview = vi.fn()
const { container } = await renderNotionPagePreview({ hidePreview })
// Act
const closeButton = container.querySelector('.cursor-pointer') as HTMLElement
fireEvent.click(closeButton)
// Assert
expect(hidePreview).toHaveBeenCalledTimes(1)
})
it('should handle multiple clicks on close button', async () => {
// Arrange
const hidePreview = vi.fn()
const { container } = await renderNotionPagePreview({ hidePreview })
// Act
const closeButton = container.querySelector('.cursor-pointer') as HTMLElement
fireEvent.click(closeButton)
fireEvent.click(closeButton)
fireEvent.click(closeButton)
// Assert
expect(hidePreview).toHaveBeenCalledTimes(3)
})
})
// --------------------------------------------------------------------------
// State Management Tests
// --------------------------------------------------------------------------
describe('State Management', () => {
it('should initialize with loading state true', async () => {
// Arrange - Keep loading indefinitely (never resolves)
@@ -440,24 +377,19 @@ describe('NotionPagePreview', () => {
// Act - Don't wait for content
const { container } = await renderNotionPagePreview({}, false)
// Assert
const loadingElement = findLoadingSpinner(container)
expect(loadingElement).toBeInTheDocument()
})
it('should update previewContent state after successful fetch', async () => {
// Arrange
mockFetchNotionPagePreview.mockResolvedValue({ content: 'New preview content' })
// Act
await renderNotionPagePreview()
// Assert
expect(screen.getByText('New preview content')).toBeInTheDocument()
})
it('should reset loading to true when currentPage changes', async () => {
// Arrange
const page1 = createMockNotionPage({ page_id: 'page-1' })
const page2 = createMockNotionPage({ page_id: 'page-2' })
@@ -465,7 +397,6 @@ describe('NotionPagePreview', () => {
.mockResolvedValueOnce({ content: 'Content 1' })
.mockImplementationOnce(() => new Promise(() => { /* never resolves */ }))
// Act
const { rerender, container } = render(
<NotionPagePreview currentPage={page1} notionCredentialId="cred-123" hidePreview={vi.fn()} />,
)
@@ -487,7 +418,6 @@ describe('NotionPagePreview', () => {
})
it('should replace old content with new content when page changes', async () => {
// Arrange
const page1 = createMockNotionPage({ page_id: 'page-1' })
const page2 = createMockNotionPage({ page_id: 'page-2' })
@@ -497,7 +427,6 @@ describe('NotionPagePreview', () => {
.mockResolvedValueOnce({ content: 'Content 1' })
.mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))
// Act
const { rerender } = render(
<NotionPagePreview currentPage={page1} notionCredentialId="cred-123" hidePreview={vi.fn()} />,
)
@@ -523,24 +452,17 @@ describe('NotionPagePreview', () => {
})
})
// --------------------------------------------------------------------------
// Props Testing
// --------------------------------------------------------------------------
describe('Props', () => {
describe('currentPage prop', () => {
it('should render correctly with currentPage prop', async () => {
// Arrange
const page = createMockNotionPage({ page_name: 'My Test Page' })
// Act
await renderNotionPagePreview({ currentPage: page })
// Assert
expect(screen.getByText('My Test Page')).toBeInTheDocument()
})
it('should render correctly without currentPage prop (undefined)', async () => {
// Arrange & Act
await renderNotionPagePreview({ currentPage: undefined }, false)
// Assert - Header should still render
@@ -548,10 +470,8 @@ describe('NotionPagePreview', () => {
})
it('should handle page with empty name', async () => {
// Arrange
const page = createMockNotionPage({ page_name: '' })
// Act
const { container } = await renderNotionPagePreview({ currentPage: page })
// Assert - Should not crash
@@ -559,52 +479,40 @@ describe('NotionPagePreview', () => {
})
it('should handle page with very long name', async () => {
// Arrange
const longName = 'a'.repeat(200)
const page = createMockNotionPage({ page_name: longName })
// Act
await renderNotionPagePreview({ currentPage: page })
// Assert
expect(screen.getByText(longName)).toBeInTheDocument()
})
it('should handle page with special characters in name', async () => {
// Arrange
const page = createMockNotionPage({ page_name: 'Page with <special> & "chars"' })
// Act
await renderNotionPagePreview({ currentPage: page })
// Assert
expect(screen.getByText('Page with <special> & "chars"')).toBeInTheDocument()
})
it('should handle page with unicode characters in name', async () => {
// Arrange
const page = createMockNotionPage({ page_name: '中文页面名称 🚀 日本語' })
// Act
await renderNotionPagePreview({ currentPage: page })
// Assert
expect(screen.getByText('中文页面名称 🚀 日本語')).toBeInTheDocument()
})
})
describe('notionCredentialId prop', () => {
it('should pass notionCredentialId to API call', async () => {
// Arrange
const page = createMockNotionPage()
// Act
await renderNotionPagePreview({
currentPage: page,
notionCredentialId: 'my-credential-id',
})
// Assert
expect(mockFetchNotionPagePreview).toHaveBeenCalledWith(
expect.objectContaining({ credentialID: 'my-credential-id' }),
)
@@ -613,10 +521,8 @@ describe('NotionPagePreview', () => {
describe('hidePreview prop', () => {
it('should accept hidePreview callback', async () => {
// Arrange
const hidePreview = vi.fn()
// Act
await renderNotionPagePreview({ hidePreview })
// Assert - No errors thrown
@@ -625,15 +531,10 @@ describe('NotionPagePreview', () => {
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle page with undefined page_id', async () => {
// Arrange
const page = createMockNotionPage({ page_id: undefined as unknown as string })
// Act
await renderNotionPagePreview({ currentPage: page })
// Assert - API should still be called (with undefined pageID)
@@ -641,36 +542,28 @@ describe('NotionPagePreview', () => {
})
it('should handle page with empty string page_id', async () => {
// Arrange
const page = createMockNotionPage({ page_id: '' })
// Act
await renderNotionPagePreview({ currentPage: page })
// Assert
expect(mockFetchNotionPagePreview).toHaveBeenCalledWith(
expect.objectContaining({ pageID: '' }),
)
})
it('should handle very long preview content', async () => {
// Arrange
const longContent = 'x'.repeat(10000)
mockFetchNotionPagePreview.mockResolvedValue({ content: longContent })
// Act
await renderNotionPagePreview()
// Assert
expect(screen.getByText(longContent)).toBeInTheDocument()
})
it('should handle preview content with special characters safely', async () => {
// Arrange
const specialContent = '<script>alert("xss")</script>\n\t& < > "'
mockFetchNotionPagePreview.mockResolvedValue({ content: specialContent })
// Act
const { container } = await renderNotionPagePreview()
// Assert - Should render as text, not execute scripts
@@ -680,26 +573,20 @@ describe('NotionPagePreview', () => {
})
it('should handle preview content with unicode', async () => {
// Arrange
const unicodeContent = '中文内容 🚀 émojis & spëcîal çhàrs'
mockFetchNotionPagePreview.mockResolvedValue({ content: unicodeContent })
// Act
await renderNotionPagePreview()
// Assert
expect(screen.getByText(unicodeContent)).toBeInTheDocument()
})
it('should handle preview content with newlines', async () => {
// Arrange
const multilineContent = 'Line 1\nLine 2\nLine 3'
mockFetchNotionPagePreview.mockResolvedValue({ content: multilineContent })
// Act
const { container } = await renderNotionPagePreview()
// Assert
const contentDiv = container.querySelector('[class*="fileContent"]')
expect(contentDiv).toBeInTheDocument()
expect(contentDiv?.textContent).toContain('Line 1')
@@ -708,10 +595,8 @@ describe('NotionPagePreview', () => {
})
it('should handle null content from API', async () => {
// Arrange
mockFetchNotionPagePreview.mockResolvedValue({ content: null as unknown as string })
// Act
const { container } = await renderNotionPagePreview()
// Assert - Should not crash
@@ -719,29 +604,22 @@ describe('NotionPagePreview', () => {
})
it('should handle different page types', async () => {
// Arrange
const databasePage = createMockNotionPage({ type: 'database' })
// Act
await renderNotionPagePreview({ currentPage: databasePage })
// Assert
expect(mockFetchNotionPagePreview).toHaveBeenCalledWith(
expect.objectContaining({ pageType: 'database' }),
)
})
})
// --------------------------------------------------------------------------
// Side Effects and Cleanup Tests
// --------------------------------------------------------------------------
describe('Side Effects and Cleanup', () => {
it('should trigger effect when currentPage prop changes', async () => {
// Arrange
const page1 = createMockNotionPage({ page_id: 'page-1' })
const page2 = createMockNotionPage({ page_id: 'page-2' })
// Act
const { rerender } = render(
<NotionPagePreview currentPage={page1} notionCredentialId="cred-123" hidePreview={vi.fn()} />,
)
@@ -754,19 +632,16 @@ describe('NotionPagePreview', () => {
rerender(<NotionPagePreview currentPage={page2} notionCredentialId="cred-123" hidePreview={vi.fn()} />)
})
// Assert
await waitFor(() => {
expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(2)
})
})
it('should not trigger effect when hidePreview changes', async () => {
// Arrange
const page = createMockNotionPage()
const hidePreview1 = vi.fn()
const hidePreview2 = vi.fn()
// Act
const { rerender } = render(
<NotionPagePreview currentPage={page} notionCredentialId="cred-123" hidePreview={hidePreview1} />,
)
@@ -785,10 +660,8 @@ describe('NotionPagePreview', () => {
})
it('should not trigger effect when notionCredentialId changes', async () => {
// Arrange
const page = createMockNotionPage()
// Act
const { rerender } = render(
<NotionPagePreview currentPage={page} notionCredentialId="cred-1" hidePreview={vi.fn()} />,
)
@@ -806,11 +679,9 @@ describe('NotionPagePreview', () => {
})
it('should handle rapid page changes', async () => {
// Arrange
const pages = Array.from({ length: 5 }, (_, i) =>
createMockNotionPage({ page_id: `page-${i}` }))
// Act
const { rerender } = render(
<NotionPagePreview currentPage={pages[0]} notionCredentialId="cred-123" hidePreview={vi.fn()} />,
)
@@ -829,7 +700,6 @@ describe('NotionPagePreview', () => {
})
it('should handle unmount during loading', async () => {
// Arrange
mockFetchNotionPagePreview.mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve({ content: 'delayed' }), 1000)),
)
@@ -845,10 +715,8 @@ describe('NotionPagePreview', () => {
})
it('should handle page changing from defined to undefined', async () => {
// Arrange
const page = createMockNotionPage()
// Act
const { rerender, container } = render(
<NotionPagePreview currentPage={page} notionCredentialId="cred-123" hidePreview={vi.fn()} />,
)
@@ -867,38 +735,27 @@ describe('NotionPagePreview', () => {
})
})
// --------------------------------------------------------------------------
// Accessibility Tests
// --------------------------------------------------------------------------
describe('Accessibility', () => {
it('should have clickable close button with visual indicator', async () => {
// Arrange & Act
const { container } = await renderNotionPagePreview()
// Assert
const closeButton = container.querySelector('.cursor-pointer')
expect(closeButton).toBeInTheDocument()
expect(closeButton).toHaveClass('cursor-pointer')
})
it('should have proper heading structure', async () => {
// Arrange & Act
await renderNotionPagePreview()
// Assert
expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Error Handling Tests
// --------------------------------------------------------------------------
describe('Error Handling', () => {
it('should not crash on API network error', async () => {
// Arrange
mockFetchNotionPagePreview.mockRejectedValue(new Error('Network Error'))
// Act
const { container } = await renderNotionPagePreview({}, false)
// Assert - Component should still render
@@ -908,122 +765,92 @@ describe('NotionPagePreview', () => {
})
it('should not crash on API timeout', async () => {
// Arrange
mockFetchNotionPagePreview.mockRejectedValue(new Error('Timeout'))
// Act
const { container } = await renderNotionPagePreview({}, false)
// Assert
await waitFor(() => {
expect(container.firstChild).toBeInTheDocument()
})
})
it('should not crash on malformed API response', async () => {
// Arrange
mockFetchNotionPagePreview.mockResolvedValue({} as { content: string })
// Act
const { container } = await renderNotionPagePreview()
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should handle 404 error gracefully', async () => {
// Arrange
mockFetchNotionPagePreview.mockRejectedValue(new Error('404 Not Found'))
// Act
const { container } = await renderNotionPagePreview({}, false)
// Assert
await waitFor(() => {
expect(container.firstChild).toBeInTheDocument()
})
})
it('should handle 500 error gracefully', async () => {
// Arrange
mockFetchNotionPagePreview.mockRejectedValue(new Error('500 Internal Server Error'))
// Act
const { container } = await renderNotionPagePreview({}, false)
// Assert
await waitFor(() => {
expect(container.firstChild).toBeInTheDocument()
})
})
it('should handle authorization error gracefully', async () => {
// Arrange
mockFetchNotionPagePreview.mockRejectedValue(new Error('401 Unauthorized'))
// Act
const { container } = await renderNotionPagePreview({}, false)
// Assert
await waitFor(() => {
expect(container.firstChild).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Page Type Variations Tests
// --------------------------------------------------------------------------
describe('Page Type Variations', () => {
it('should handle page type', async () => {
// Arrange
const page = createMockNotionPage({ type: 'page' })
// Act
await renderNotionPagePreview({ currentPage: page })
// Assert
expect(mockFetchNotionPagePreview).toHaveBeenCalledWith(
expect.objectContaining({ pageType: 'page' }),
)
})
it('should handle database type', async () => {
// Arrange
const page = createMockNotionPage({ type: 'database' })
// Act
await renderNotionPagePreview({ currentPage: page })
// Assert
expect(mockFetchNotionPagePreview).toHaveBeenCalledWith(
expect.objectContaining({ pageType: 'database' }),
)
})
it('should handle unknown type', async () => {
// Arrange
const page = createMockNotionPage({ type: 'unknown_type' })
// Act
await renderNotionPagePreview({ currentPage: page })
// Assert
expect(mockFetchNotionPagePreview).toHaveBeenCalledWith(
expect.objectContaining({ pageType: 'unknown_type' }),
)
})
})
// --------------------------------------------------------------------------
// Icon Type Variations Tests
// --------------------------------------------------------------------------
describe('Icon Type Variations', () => {
it('should handle page with null icon', async () => {
// Arrange
const page = createMockNotionPage({ page_icon: null })
// Act
const { container } = await renderNotionPagePreview({ currentPage: page })
// Assert - Should render default icon
@@ -1032,31 +859,24 @@ describe('NotionPagePreview', () => {
})
it('should handle page with emoji icon object', async () => {
// Arrange
const page = createMockNotionPageWithEmojiIcon('📄')
// Act
await renderNotionPagePreview({ currentPage: page })
// Assert
expect(screen.getByText('📄')).toBeInTheDocument()
})
it('should handle page with url icon object', async () => {
// Arrange
const page = createMockNotionPageWithUrlIcon('https://example.com/custom-icon.png')
// Act
const { container } = await renderNotionPagePreview({ currentPage: page })
// Assert
const img = container.querySelector('img[alt="page icon"]')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', 'https://example.com/custom-icon.png')
})
it('should handle page with icon object having null values', async () => {
// Arrange
const page = createMockNotionPage({
page_icon: {
type: null,
@@ -1065,7 +885,6 @@ describe('NotionPagePreview', () => {
},
})
// Act
const { container } = await renderNotionPagePreview({ currentPage: page })
// Assert - Should render, likely with default/fallback
@@ -1073,7 +892,6 @@ describe('NotionPagePreview', () => {
})
it('should handle page with icon object having empty url', async () => {
// Arrange
// Suppress console.error for this test as we're intentionally testing empty src edge case
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(vi.fn())
@@ -1085,7 +903,6 @@ describe('NotionPagePreview', () => {
},
})
// Act
const { container } = await renderNotionPagePreview({ currentPage: page })
// Assert - Component should not crash, may render img or fallback
@@ -1100,32 +917,24 @@ describe('NotionPagePreview', () => {
})
})
// --------------------------------------------------------------------------
// Content Display Tests
// --------------------------------------------------------------------------
describe('Content Display', () => {
it('should display content in fileContent div with correct class', async () => {
// Arrange
mockFetchNotionPagePreview.mockResolvedValue({ content: 'Test content' })
// Act
const { container } = await renderNotionPagePreview()
// Assert
const contentDiv = container.querySelector('[class*="fileContent"]')
expect(contentDiv).toBeInTheDocument()
expect(contentDiv).toHaveTextContent('Test content')
})
it('should preserve whitespace in content', async () => {
// Arrange
const contentWithWhitespace = ' indented content\n more indent'
mockFetchNotionPagePreview.mockResolvedValue({ content: contentWithWhitespace })
// Act
const { container } = await renderNotionPagePreview()
// Assert
const contentDiv = container.querySelector('[class*="fileContent"]')
expect(contentDiv).toBeInTheDocument()
// The CSS class has white-space: pre-line
@@ -1133,13 +942,10 @@ describe('NotionPagePreview', () => {
})
it('should display empty string content without loading', async () => {
// Arrange
mockFetchNotionPagePreview.mockResolvedValue({ content: '' })
// Act
const { container } = await renderNotionPagePreview()
// Assert
const loadingElement = findLoadingSpinner(container)
expect(loadingElement).not.toBeInTheDocument()
const contentDiv = container.querySelector('[class*="fileContent"]')

View File

@@ -0,0 +1,561 @@
import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types'
import type { NotionPage } from '@/models/common'
import type { CrawlOptions, CrawlResultItem, DataSet, FileItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { Plan } from '@/app/components/billing/type'
import { DataSourceType } from '@/models/datasets'
import StepOne from '../index'
// Mock config for website crawl features
vi.mock('@/config', () => ({
ENABLE_WEBSITE_FIRECRAWL: true,
ENABLE_WEBSITE_JINAREADER: false,
ENABLE_WEBSITE_WATERCRAWL: false,
}))
// Mock dataset detail context
let mockDatasetDetail: DataSet | undefined
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: DataSet | undefined }) => DataSet | undefined) => {
return selector({ dataset: mockDatasetDetail })
},
}))
// Mock provider context
let mockPlan = {
type: Plan.professional,
usage: { vectorSpace: 50, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 },
total: { vectorSpace: 100, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 },
}
let mockEnableBilling = false
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
plan: mockPlan,
enableBilling: mockEnableBilling,
}),
}))
vi.mock('../../file-uploader', () => ({
default: ({ onPreview, fileList }: { onPreview: (file: File) => void, fileList: FileItem[] }) => (
<div data-testid="file-uploader">
<span data-testid="file-count">{fileList.length}</span>
<button data-testid="preview-file" onClick={() => onPreview(new File(['test'], 'test.txt'))}>
Preview
</button>
</div>
),
}))
vi.mock('../../website', () => ({
default: ({ onPreview }: { onPreview: (item: CrawlResultItem) => void }) => (
<div data-testid="website">
<button
data-testid="preview-website"
onClick={() => onPreview({ title: 'Test', markdown: '', description: '', source_url: 'https://test.com' })}
>
Preview Website
</button>
</div>
),
}))
vi.mock('../../empty-dataset-creation-modal', () => ({
default: ({ show, onHide }: { show: boolean, onHide: () => void }) => (
show
? (
<div data-testid="empty-dataset-modal">
<button data-testid="close-modal" onClick={onHide}>Close</button>
</div>
)
: null
),
}))
// NotionConnector is a base component - imported directly without mock
// It only depends on i18n which is globally mocked
vi.mock('@/app/components/base/notion-page-selector', () => ({
NotionPageSelector: ({ onPreview }: { onPreview: (page: NotionPage) => void }) => (
<div data-testid="notion-page-selector">
<button
data-testid="preview-notion"
onClick={() => onPreview({ page_id: 'page-1', type: 'page' } as NotionPage)}
>
Preview Notion
</button>
</div>
),
}))
vi.mock('@/app/components/billing/vector-space-full', () => ({
default: () => <div data-testid="vector-space-full">Vector Space Full</div>,
}))
vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
default: ({ show, onClose }: { show: boolean, onClose: () => void }) => (
show
? (
<div data-testid="plan-upgrade-modal">
<button data-testid="close-upgrade-modal" onClick={onClose}>Close</button>
</div>
)
: null
),
}))
vi.mock('../../file-preview', () => ({
default: ({ file, hidePreview }: { file: File, hidePreview: () => void }) => (
<div data-testid="file-preview">
<span>{file.name}</span>
<button data-testid="hide-file-preview" onClick={hidePreview}>Hide</button>
</div>
),
}))
vi.mock('../../notion-page-preview', () => ({
default: ({ currentPage, hidePreview }: { currentPage: NotionPage, hidePreview: () => void }) => (
<div data-testid="notion-page-preview">
<span>{currentPage.page_id}</span>
<button data-testid="hide-notion-preview" onClick={hidePreview}>Hide</button>
</div>
),
}))
// WebsitePreview is a sibling component without API dependencies - imported directly
// It only depends on i18n which is globally mocked
vi.mock('../upgrade-card', () => ({
default: () => <div data-testid="upgrade-card">Upgrade Card</div>,
}))
const createMockCustomFile = (overrides: { id?: string, name?: string } = {}) => {
const file = new File(['test content'], overrides.name ?? 'test.txt', { type: 'text/plain' })
return Object.assign(file, {
id: overrides.id ?? 'uploaded-id',
extension: 'txt',
mime_type: 'text/plain',
created_by: 'user-1',
created_at: Date.now(),
})
}
const createMockFileItem = (overrides: Partial<FileItem> = {}): FileItem => ({
fileID: `file-${Date.now()}`,
file: createMockCustomFile(overrides.file as { id?: string, name?: string }),
progress: 100,
...overrides,
})
const createMockNotionPage = (overrides: Partial<NotionPage> = {}): NotionPage => ({
page_id: `page-${Date.now()}`,
type: 'page',
...overrides,
} as NotionPage)
const createMockCrawlResult = (overrides: Partial<CrawlResultItem> = {}): CrawlResultItem => ({
title: 'Test Page',
markdown: 'Test content',
description: 'Test description',
source_url: 'https://example.com',
...overrides,
})
const createMockDataSourceAuth = (overrides: Partial<DataSourceAuth> = {}): DataSourceAuth => ({
credential_id: 'cred-1',
provider: 'notion_datasource',
plugin_id: 'plugin-1',
credentials_list: [{ id: 'cred-1', name: 'Workspace 1' }],
...overrides,
} as DataSourceAuth)
const defaultProps = {
dataSourceType: DataSourceType.FILE,
dataSourceTypeDisable: false,
onSetting: vi.fn(),
files: [] as FileItem[],
updateFileList: vi.fn(),
updateFile: vi.fn(),
notionPages: [] as NotionPage[],
notionCredentialId: '',
updateNotionPages: vi.fn(),
updateNotionCredentialId: vi.fn(),
onStepChange: vi.fn(),
changeType: vi.fn(),
websitePages: [] as CrawlResultItem[],
updateWebsitePages: vi.fn(),
onWebsiteCrawlProviderChange: vi.fn(),
onWebsiteCrawlJobIdChange: vi.fn(),
crawlOptions: {
crawl_sub_pages: true,
only_main_content: true,
includes: '',
excludes: '',
limit: 10,
max_depth: '',
use_sitemap: true,
} as CrawlOptions,
onCrawlOptionsChange: vi.fn(),
authedDataSourceList: [] as DataSourceAuth[],
}
// NOTE: Child component unit tests (usePreviewState, DataSourceTypeSelector,
// NextStepButton, PreviewPanel) have been moved to their own dedicated spec files:
// - ./hooks/use-preview-state.spec.ts
// - ./components/data-source-type-selector.spec.tsx
// - ./components/next-step-button.spec.tsx
// - ./components/preview-panel.spec.tsx
// This file now focuses exclusively on StepOne parent component tests.
// StepOne Component Tests
describe('StepOne', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDatasetDetail = undefined
mockPlan = {
type: Plan.professional,
usage: { vectorSpace: 50, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 },
total: { vectorSpace: 100, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 },
}
mockEnableBilling = false
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<StepOne {...defaultProps} />)
expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument()
})
it('should render DataSourceTypeSelector when not editing existing dataset', () => {
render(<StepOne {...defaultProps} />)
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.file')).toBeInTheDocument()
})
it('should render FileUploader when dataSourceType is FILE', () => {
render(<StepOne {...defaultProps} dataSourceType={DataSourceType.FILE} />)
expect(screen.getByTestId('file-uploader')).toBeInTheDocument()
})
it('should render NotionConnector when dataSourceType is NOTION and not authenticated', () => {
render(<StepOne {...defaultProps} dataSourceType={DataSourceType.NOTION} />)
// Assert - NotionConnector shows sync title and connect button
expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /datasetCreation.stepOne.connect/i })).toBeInTheDocument()
})
it('should render NotionPageSelector when dataSourceType is NOTION and authenticated', () => {
const authedDataSourceList = [createMockDataSourceAuth()]
render(<StepOne {...defaultProps} dataSourceType={DataSourceType.NOTION} authedDataSourceList={authedDataSourceList} />)
expect(screen.getByTestId('notion-page-selector')).toBeInTheDocument()
})
it('should render Website when dataSourceType is WEB', () => {
render(<StepOne {...defaultProps} dataSourceType={DataSourceType.WEB} />)
expect(screen.getByTestId('website')).toBeInTheDocument()
})
it('should render empty dataset creation link when no datasetId', () => {
render(<StepOne {...defaultProps} />)
expect(screen.getByText('datasetCreation.stepOne.emptyDatasetCreation')).toBeInTheDocument()
})
it('should not render empty dataset creation link when datasetId exists', () => {
render(<StepOne {...defaultProps} datasetId="dataset-123" />)
expect(screen.queryByText('datasetCreation.stepOne.emptyDatasetCreation')).not.toBeInTheDocument()
})
})
// Props Tests
describe('Props', () => {
it('should pass files to FileUploader', () => {
const files = [createMockFileItem()]
render(<StepOne {...defaultProps} files={files} />)
expect(screen.getByTestId('file-count')).toHaveTextContent('1')
})
it('should call onSetting when NotionConnector connect button is clicked', () => {
const onSetting = vi.fn()
render(<StepOne {...defaultProps} dataSourceType={DataSourceType.NOTION} onSetting={onSetting} />)
// Act - The NotionConnector's button calls onSetting
fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.connect/i }))
expect(onSetting).toHaveBeenCalledTimes(1)
})
it('should call changeType when data source type is changed', () => {
const changeType = vi.fn()
render(<StepOne {...defaultProps} changeType={changeType} />)
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion'))
expect(changeType).toHaveBeenCalledWith(DataSourceType.NOTION)
})
})
describe('State Management', () => {
it('should open empty dataset modal when link is clicked', () => {
render(<StepOne {...defaultProps} />)
fireEvent.click(screen.getByText('datasetCreation.stepOne.emptyDatasetCreation'))
expect(screen.getByTestId('empty-dataset-modal')).toBeInTheDocument()
})
it('should close empty dataset modal when close is clicked', () => {
render(<StepOne {...defaultProps} />)
fireEvent.click(screen.getByText('datasetCreation.stepOne.emptyDatasetCreation'))
fireEvent.click(screen.getByTestId('close-modal'))
expect(screen.queryByTestId('empty-dataset-modal')).not.toBeInTheDocument()
})
})
describe('Memoization', () => {
it('should correctly compute isNotionAuthed based on authedDataSourceList', () => {
// Arrange - No auth
const { rerender } = render(<StepOne {...defaultProps} dataSourceType={DataSourceType.NOTION} />)
// NotionConnector shows the sync title when not authenticated
expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument()
// Act - Add auth
const authedDataSourceList = [createMockDataSourceAuth()]
rerender(<StepOne {...defaultProps} dataSourceType={DataSourceType.NOTION} authedDataSourceList={authedDataSourceList} />)
expect(screen.getByTestId('notion-page-selector')).toBeInTheDocument()
})
it('should correctly compute fileNextDisabled when files are empty', () => {
render(<StepOne {...defaultProps} files={[]} />)
// Assert - Button should be disabled
expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled()
})
it('should correctly compute fileNextDisabled when files are loaded', () => {
const files = [createMockFileItem()]
render(<StepOne {...defaultProps} files={files} />)
// Assert - Button should be enabled
expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled()
})
it('should correctly compute fileNextDisabled when some files are not uploaded', () => {
// Arrange - Create a file item without id (not yet uploaded)
const file = new File(['test'], 'test.txt', { type: 'text/plain' })
const fileItem: FileItem = {
fileID: 'temp-id',
file: Object.assign(file, { id: undefined, extension: 'txt', mime_type: 'text/plain' }),
progress: 0,
}
render(<StepOne {...defaultProps} files={[fileItem]} />)
// Assert - Button should be disabled
expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled()
})
})
describe('Callbacks', () => {
it('should call onStepChange when next button is clicked with valid files', () => {
const onStepChange = vi.fn()
const files = [createMockFileItem()]
render(<StepOne {...defaultProps} files={files} onStepChange={onStepChange} />)
fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }))
expect(onStepChange).toHaveBeenCalledTimes(1)
})
it('should show plan upgrade modal when batch upload not supported and multiple files', () => {
mockEnableBilling = true
mockPlan.type = Plan.sandbox
const files = [createMockFileItem(), createMockFileItem()]
render(<StepOne {...defaultProps} files={files} />)
fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }))
expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument()
})
it('should show upgrade card when in sandbox plan with files', () => {
mockEnableBilling = true
mockPlan.type = Plan.sandbox
const files = [createMockFileItem()]
render(<StepOne {...defaultProps} files={files} />)
expect(screen.getByTestId('upgrade-card')).toBeInTheDocument()
})
})
// Vector Space Full Tests
describe('Vector Space Full', () => {
it('should show VectorSpaceFull when vector space is full and billing is enabled', () => {
mockEnableBilling = true
mockPlan.usage.vectorSpace = 100
mockPlan.total.vectorSpace = 100
const files = [createMockFileItem()]
render(<StepOne {...defaultProps} files={files} />)
expect(screen.getByTestId('vector-space-full')).toBeInTheDocument()
})
it('should disable next button when vector space is full', () => {
mockEnableBilling = true
mockPlan.usage.vectorSpace = 100
mockPlan.total.vectorSpace = 100
const files = [createMockFileItem()]
render(<StepOne {...defaultProps} files={files} />)
expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled()
})
})
// Preview Integration Tests
describe('Preview Integration', () => {
it('should show file preview when file preview button is clicked', () => {
render(<StepOne {...defaultProps} />)
fireEvent.click(screen.getByTestId('preview-file'))
expect(screen.getByTestId('file-preview')).toBeInTheDocument()
})
it('should hide file preview when hide button is clicked', () => {
render(<StepOne {...defaultProps} />)
fireEvent.click(screen.getByTestId('preview-file'))
fireEvent.click(screen.getByTestId('hide-file-preview'))
expect(screen.queryByTestId('file-preview')).not.toBeInTheDocument()
})
it('should show notion page preview when preview button is clicked', () => {
const authedDataSourceList = [createMockDataSourceAuth()]
render(<StepOne {...defaultProps} dataSourceType={DataSourceType.NOTION} authedDataSourceList={authedDataSourceList} />)
fireEvent.click(screen.getByTestId('preview-notion'))
expect(screen.getByTestId('notion-page-preview')).toBeInTheDocument()
})
it('should show website preview when preview button is clicked', () => {
render(<StepOne {...defaultProps} dataSourceType={DataSourceType.WEB} />)
fireEvent.click(screen.getByTestId('preview-website'))
// Assert - Check for pagePreview title which is shown by WebsitePreview
expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty notionPages array', () => {
const authedDataSourceList = [createMockDataSourceAuth()]
render(<StepOne {...defaultProps} dataSourceType={DataSourceType.NOTION} notionPages={[]} authedDataSourceList={authedDataSourceList} />)
// Assert - Button should be disabled when no pages selected
expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled()
})
it('should handle empty websitePages array', () => {
render(<StepOne {...defaultProps} dataSourceType={DataSourceType.WEB} websitePages={[]} />)
// Assert - Button should be disabled when no pages crawled
expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled()
})
it('should handle empty authedDataSourceList', () => {
render(<StepOne {...defaultProps} dataSourceType={DataSourceType.NOTION} authedDataSourceList={[]} />)
// Assert - Should show NotionConnector with connect button
expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument()
})
it('should handle authedDataSourceList without notion credentials', () => {
const authedDataSourceList = [createMockDataSourceAuth({ credentials_list: [] })]
render(<StepOne {...defaultProps} dataSourceType={DataSourceType.NOTION} authedDataSourceList={authedDataSourceList} />)
// Assert - Should show NotionConnector with connect button
expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument()
})
it('should clear previews when switching data source types', () => {
render(<StepOne {...defaultProps} />)
fireEvent.click(screen.getByTestId('preview-file'))
expect(screen.getByTestId('file-preview')).toBeInTheDocument()
// Act - Change to NOTION
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion'))
// Assert - File preview should be cleared
expect(screen.queryByTestId('file-preview')).not.toBeInTheDocument()
})
})
describe('Integration', () => {
it('should complete file upload flow', () => {
const onStepChange = vi.fn()
const files = [createMockFileItem()]
render(<StepOne {...defaultProps} files={files} onStepChange={onStepChange} />)
fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }))
expect(onStepChange).toHaveBeenCalled()
})
it('should complete notion page selection flow', () => {
const onStepChange = vi.fn()
const authedDataSourceList = [createMockDataSourceAuth()]
const notionPages = [createMockNotionPage()]
render(
<StepOne
{...defaultProps}
dataSourceType={DataSourceType.NOTION}
authedDataSourceList={authedDataSourceList}
notionPages={notionPages}
onStepChange={onStepChange}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }))
expect(onStepChange).toHaveBeenCalled()
})
it('should complete website crawl flow', () => {
const onStepChange = vi.fn()
const websitePages = [createMockCrawlResult()]
render(
<StepOne
{...defaultProps}
dataSourceType={DataSourceType.WEB}
websitePages={websitePages}
onStepChange={onStepChange}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }))
expect(onStepChange).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,89 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import UpgradeCard from '../upgrade-card'
const mockSetShowPricingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowPricingModal: mockSetShowPricingModal,
}),
}))
vi.mock('@/app/components/billing/upgrade-btn', () => ({
default: ({ onClick, className }: { onClick?: () => void, className?: string }) => (
<button type="button" className={className} onClick={onClick} data-testid="upgrade-btn">
upgrade
</button>
),
}))
describe('UpgradeCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<UpgradeCard />)
// Assert - title and description i18n keys are rendered
expect(screen.getByText(/uploadMultipleFiles\.title/i)).toBeInTheDocument()
})
it('should render the upgrade title text', () => {
render(<UpgradeCard />)
expect(screen.getByText(/uploadMultipleFiles\.title/i)).toBeInTheDocument()
})
it('should render the upgrade description text', () => {
render(<UpgradeCard />)
expect(screen.getByText(/uploadMultipleFiles\.description/i)).toBeInTheDocument()
})
it('should render the upgrade button', () => {
render(<UpgradeCard />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call setShowPricingModal when upgrade button is clicked', () => {
render(<UpgradeCard />)
fireEvent.click(screen.getByRole('button'))
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should not call setShowPricingModal without user interaction', () => {
render(<UpgradeCard />)
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
})
it('should call setShowPricingModal on each button click', () => {
render(<UpgradeCard />)
const button = screen.getByRole('button')
fireEvent.click(button)
fireEvent.click(button)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(2)
})
})
describe('Memoization', () => {
it('should maintain rendering after rerender with same props', () => {
const { rerender } = render(<UpgradeCard />)
rerender(<UpgradeCard />)
expect(screen.getByText(/uploadMultipleFiles\.title/i)).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,66 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
// Mock config to control web crawl feature flags
vi.mock('@/config', () => ({
ENABLE_WEBSITE_FIRECRAWL: true,
ENABLE_WEBSITE_JINAREADER: true,
ENABLE_WEBSITE_WATERCRAWL: false,
}))
// Mock CSS module
vi.mock('../../../index.module.css', () => ({
default: {
dataSourceItem: 'ds-item',
active: 'active',
disabled: 'disabled',
datasetIcon: 'icon',
notion: 'notion-icon',
web: 'web-icon',
},
}))
const { default: DataSourceTypeSelector } = await import('../data-source-type-selector')
describe('DataSourceTypeSelector', () => {
const defaultProps = {
currentType: DataSourceType.FILE,
disabled: false,
onChange: vi.fn(),
onClearPreviews: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render file, notion, and web options', () => {
render(<DataSourceTypeSelector {...defaultProps} />)
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.file')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.web')).toBeInTheDocument()
})
it('should render as a 3-column grid', () => {
const { container } = render(<DataSourceTypeSelector {...defaultProps} />)
expect(container.firstElementChild).toHaveClass('grid-cols-3')
})
})
describe('interactions', () => {
it('should call onChange and onClearPreviews on type click', () => {
render(<DataSourceTypeSelector {...defaultProps} />)
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion'))
expect(defaultProps.onChange).toHaveBeenCalledWith(DataSourceType.NOTION)
expect(defaultProps.onClearPreviews).toHaveBeenCalledWith(DataSourceType.NOTION)
})
it('should not call onChange when disabled', () => {
render(<DataSourceTypeSelector {...defaultProps} disabled />)
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion'))
expect(defaultProps.onChange).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,48 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NextStepButton from '../next-step-button'
describe('NextStepButton', () => {
const defaultProps = {
disabled: false,
onClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render button text', () => {
render(<NextStepButton {...defaultProps} />)
expect(screen.getByText('datasetCreation.stepOne.button')).toBeInTheDocument()
})
it('should render a primary variant button', () => {
render(<NextStepButton {...defaultProps} />)
const btn = screen.getByRole('button')
expect(btn).toBeInTheDocument()
})
it('should call onClick when clicked', () => {
render(<NextStepButton {...defaultProps} />)
fireEvent.click(screen.getByRole('button'))
expect(defaultProps.onClick).toHaveBeenCalledOnce()
})
it('should be disabled when disabled prop is true', () => {
render(<NextStepButton disabled onClick={defaultProps.onClick} />)
expect(screen.getByRole('button')).toBeDisabled()
})
it('should not call onClick when disabled', () => {
render(<NextStepButton disabled onClick={defaultProps.onClick} />)
fireEvent.click(screen.getByRole('button'))
expect(defaultProps.onClick).not.toHaveBeenCalled()
})
it('should render arrow icon', () => {
const { container } = render(<NextStepButton {...defaultProps} />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,119 @@
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Mock child components - paths must match source file's imports (relative to source)
vi.mock('../../../file-preview', () => ({
default: ({ file, hidePreview }: { file: { name: string }, hidePreview: () => void }) => (
<div data-testid="file-preview">
<span>{file.name}</span>
<button data-testid="close-file" onClick={hidePreview}>close-file</button>
</div>
),
}))
vi.mock('../../../notion-page-preview', () => ({
default: ({ currentPage, hidePreview }: { currentPage: { page_name: string }, hidePreview: () => void }) => (
<div data-testid="notion-preview">
<span>{currentPage.page_name}</span>
<button data-testid="close-notion" onClick={hidePreview}>close-notion</button>
</div>
),
}))
vi.mock('../../../website/preview', () => ({
default: ({ payload, hidePreview }: { payload: { title: string }, hidePreview: () => void }) => (
<div data-testid="website-preview">
<span>{payload.title}</span>
<button data-testid="close-website" onClick={hidePreview}>close-website</button>
</div>
),
}))
vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
default: ({ show, onClose, title }: { show: boolean, onClose: () => void, title: string }) => show
? (
<div data-testid="plan-upgrade-modal">
<span>{title}</span>
<button data-testid="close-modal" onClick={onClose}>close-modal</button>
</div>
)
: null,
}))
const { default: PreviewPanel } = await import('../preview-panel')
describe('PreviewPanel', () => {
const defaultProps = {
currentFile: undefined,
currentNotionPage: undefined,
currentWebsite: undefined,
notionCredentialId: 'cred-1',
isShowPlanUpgradeModal: false,
hideFilePreview: vi.fn(),
hideNotionPagePreview: vi.fn(),
hideWebsitePreview: vi.fn(),
hidePlanUpgradeModal: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render nothing when no preview is active', () => {
const { container } = render(<PreviewPanel {...defaultProps} />)
expect(container.querySelector('[data-testid]')).toBeNull()
})
it('should render file preview when currentFile is set', () => {
render(<PreviewPanel {...defaultProps} currentFile={{ name: 'test.pdf' } as unknown as File} />)
expect(screen.getByTestId('file-preview')).toBeInTheDocument()
expect(screen.getByText('test.pdf')).toBeInTheDocument()
})
it('should render notion preview when currentNotionPage is set', () => {
render(<PreviewPanel {...defaultProps} currentNotionPage={{ page_name: 'My Page' } as unknown as NotionPage} />)
expect(screen.getByTestId('notion-preview')).toBeInTheDocument()
expect(screen.getByText('My Page')).toBeInTheDocument()
})
it('should render website preview when currentWebsite is set', () => {
render(<PreviewPanel {...defaultProps} currentWebsite={{ title: 'My Site' } as unknown as CrawlResultItem} />)
expect(screen.getByTestId('website-preview')).toBeInTheDocument()
expect(screen.getByText('My Site')).toBeInTheDocument()
})
it('should render plan upgrade modal when isShowPlanUpgradeModal is true', () => {
render(<PreviewPanel {...defaultProps} isShowPlanUpgradeModal />)
expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument()
})
})
describe('interactions', () => {
it('should call hideFilePreview when file preview close clicked', () => {
render(<PreviewPanel {...defaultProps} currentFile={{ name: 'test.pdf' } as unknown as File} />)
fireEvent.click(screen.getByTestId('close-file'))
expect(defaultProps.hideFilePreview).toHaveBeenCalledOnce()
})
it('should call hidePlanUpgradeModal when modal close clicked', () => {
render(<PreviewPanel {...defaultProps} isShowPlanUpgradeModal />)
fireEvent.click(screen.getByTestId('close-modal'))
expect(defaultProps.hidePlanUpgradeModal).toHaveBeenCalledOnce()
})
it('should call hideNotionPagePreview when notion preview close clicked', () => {
render(<PreviewPanel {...defaultProps} currentNotionPage={{ page_name: 'My Page' } as unknown as NotionPage} />)
fireEvent.click(screen.getByTestId('close-notion'))
expect(defaultProps.hideNotionPagePreview).toHaveBeenCalledOnce()
})
it('should call hideWebsitePreview when website preview close clicked', () => {
render(<PreviewPanel {...defaultProps} currentWebsite={{ title: 'My Site' } as unknown as CrawlResultItem} />)
fireEvent.click(screen.getByTestId('close-website'))
expect(defaultProps.hideWebsitePreview).toHaveBeenCalledOnce()
})
})
})

View File

@@ -0,0 +1,60 @@
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import usePreviewState from '../use-preview-state'
describe('usePreviewState', () => {
it('should initialize with all previews undefined', () => {
const { result } = renderHook(() => usePreviewState())
expect(result.current.currentFile).toBeUndefined()
expect(result.current.currentNotionPage).toBeUndefined()
expect(result.current.currentWebsite).toBeUndefined()
})
it('should show and hide file preview', () => {
const { result } = renderHook(() => usePreviewState())
const file = new File(['content'], 'test.pdf')
act(() => {
result.current.showFilePreview(file)
})
expect(result.current.currentFile).toBe(file)
act(() => {
result.current.hideFilePreview()
})
expect(result.current.currentFile).toBeUndefined()
})
it('should show and hide notion page preview', () => {
const { result } = renderHook(() => usePreviewState())
const page = { page_id: 'p1', page_name: 'Test' } as unknown as NotionPage
act(() => {
result.current.showNotionPagePreview(page)
})
expect(result.current.currentNotionPage).toBe(page)
act(() => {
result.current.hideNotionPagePreview()
})
expect(result.current.currentNotionPage).toBeUndefined()
})
it('should show and hide website preview', () => {
const { result } = renderHook(() => usePreviewState())
const website = { title: 'Example', source_url: 'https://example.com' } as unknown as CrawlResultItem
act(() => {
result.current.showWebsitePreview(website)
})
expect(result.current.currentWebsite).toBe(website)
act(() => {
result.current.hideWebsitePreview()
})
expect(result.current.currentWebsite).toBeUndefined()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
import type { createDocumentResponse, FullDocumentDetail, IconInfo } from '@/models/datasets'
import type { createDocumentResponse, DataSet, FullDocumentDetail, IconInfo } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { RETRIEVE_METHOD } from '@/types/app'
import StepThree from './index'
import StepThree from '../index'
// Mock the EmbeddingProcess component since it has complex async logic
vi.mock('../embedding-process', () => ({
vi.mock('../../embedding-process', () => ({
default: vi.fn(({ datasetId, batchId, documents, indexingType, retrievalMethod }) => (
<div data-testid="embedding-process">
<span data-testid="ep-dataset-id">{datasetId}</span>
@@ -98,97 +98,74 @@ const renderStepThree = (props: Partial<Parameters<typeof StepThree>[0]> = {}) =
return render(<StepThree {...defaultProps} />)
}
// ============================================================================
// StepThree Component Tests
// ============================================================================
describe('StepThree', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMediaType = 'pc'
})
// --------------------------------------------------------------------------
// Rendering Tests - Verify component renders properly
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
renderStepThree()
// Assert
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
it('should render with creation title when datasetId is not provided', () => {
// Arrange & Act
renderStepThree()
// Assert
expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepThree.creationContent')).toBeInTheDocument()
})
it('should render with addition title when datasetId is provided', () => {
// Arrange & Act
renderStepThree({
datasetId: 'existing-dataset-123',
datasetName: 'Existing Dataset',
})
// Assert
expect(screen.getByText('datasetCreation.stepThree.additionTitle')).toBeInTheDocument()
expect(screen.queryByText('datasetCreation.stepThree.creationTitle')).not.toBeInTheDocument()
})
it('should render label text in creation mode', () => {
// Arrange & Act
renderStepThree()
// Assert
expect(screen.getByText('datasetCreation.stepThree.label')).toBeInTheDocument()
})
it('should render side tip panel on desktop', () => {
// Arrange
mockMediaType = 'pc'
// Act
renderStepThree()
// Assert
expect(screen.getByText('datasetCreation.stepThree.sideTipTitle')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepThree.sideTipContent')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')).toBeInTheDocument()
})
it('should not render side tip panel on mobile', () => {
// Arrange
mockMediaType = 'mobile'
// Act
renderStepThree()
// Assert
expect(screen.queryByText('datasetCreation.stepThree.sideTipTitle')).not.toBeInTheDocument()
expect(screen.queryByText('datasetCreation.stepThree.sideTipContent')).not.toBeInTheDocument()
})
it('should render EmbeddingProcess component', () => {
// Arrange & Act
renderStepThree()
// Assert
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
it('should render documentation link with correct href on desktop', () => {
// Arrange
mockMediaType = 'pc'
// Act
renderStepThree()
// Assert
const link = screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')
expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/use-dify/knowledge/integrate-knowledge-within-application')
expect(link).toHaveAttribute('target', '_blank')
@@ -196,70 +173,53 @@ describe('StepThree', () => {
})
it('should apply correct container classes', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv).toHaveClass('flex', 'h-full', 'max-h-full', 'w-full', 'justify-center', 'overflow-y-auto')
})
})
// --------------------------------------------------------------------------
// Props Testing - Test all prop variations
// --------------------------------------------------------------------------
describe('Props', () => {
describe('datasetId prop', () => {
it('should render creation mode when datasetId is undefined', () => {
// Arrange & Act
renderStepThree({ datasetId: undefined })
// Assert
expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument()
})
it('should render addition mode when datasetId is provided', () => {
// Arrange & Act
renderStepThree({ datasetId: 'dataset-123' })
// Assert
expect(screen.getByText('datasetCreation.stepThree.additionTitle')).toBeInTheDocument()
})
it('should pass datasetId to EmbeddingProcess', () => {
// Arrange
const datasetId = 'my-dataset-id'
// Act
renderStepThree({ datasetId })
// Assert
expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent(datasetId)
})
it('should use creationCache dataset id when datasetId is not provided', () => {
// Arrange
const creationCache = createMockCreationCache()
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('dataset-123')
})
})
describe('datasetName prop', () => {
it('should display datasetName in creation mode', () => {
// Arrange & Act
renderStepThree({ datasetName: 'My Custom Dataset' })
// Assert
expect(screen.getByText('My Custom Dataset')).toBeInTheDocument()
})
it('should display datasetName in addition mode description', () => {
// Arrange & Act
renderStepThree({
datasetId: 'dataset-123',
datasetName: 'Existing Dataset Name',
@@ -271,45 +231,35 @@ describe('StepThree', () => {
})
it('should fallback to creationCache dataset name when datasetName is not provided', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.name = 'Cache Dataset Name'
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByText('Cache Dataset Name')).toBeInTheDocument()
})
})
describe('indexingType prop', () => {
it('should pass indexingType to EmbeddingProcess', () => {
// Arrange & Act
renderStepThree({ indexingType: 'high_quality' })
// Assert
expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('high_quality')
})
it('should use creationCache indexing_technique when indexingType is not provided', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.indexing_technique = 'economy' as any
creationCache.dataset!.indexing_technique = 'economy' as unknown as DataSet['indexing_technique']
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('economy')
})
it('should prefer creationCache indexing_technique over indexingType prop', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.indexing_technique = 'cache_technique' as any
creationCache.dataset!.indexing_technique = 'cache_technique' as unknown as DataSet['indexing_technique']
// Act
renderStepThree({ creationCache, indexingType: 'prop_technique' })
// Assert - creationCache takes precedence
@@ -319,60 +269,47 @@ describe('StepThree', () => {
describe('retrievalMethod prop', () => {
it('should pass retrievalMethod to EmbeddingProcess', () => {
// Arrange & Act
renderStepThree({ retrievalMethod: RETRIEVE_METHOD.semantic })
// Assert
expect(screen.getByTestId('ep-retrieval-method')).toHaveTextContent('semantic_search')
})
it('should use creationCache retrieval method when retrievalMethod is not provided', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.retrieval_model_dict = { search_method: 'full_text_search' } as any
creationCache.dataset!.retrieval_model_dict = { search_method: 'full_text_search' } as unknown as DataSet['retrieval_model_dict']
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-retrieval-method')).toHaveTextContent('full_text_search')
})
})
describe('creationCache prop', () => {
it('should pass batchId from creationCache to EmbeddingProcess', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.batch = 'custom-batch-123'
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('custom-batch-123')
})
it('should pass documents from creationCache to EmbeddingProcess', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.documents = [createMockDocument(), createMockDocument(), createMockDocument()] as any
creationCache.documents = [createMockDocument(), createMockDocument(), createMockDocument()] as unknown as createDocumentResponse['documents']
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('3')
})
it('should use icon_info from creationCache dataset', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.icon_info = createMockIconInfo({
icon: '🚀',
icon_background: '#FF0000',
})
// Act
const { container } = renderStepThree({ creationCache })
// Assert - Check AppIcon component receives correct props
@@ -381,7 +318,6 @@ describe('StepThree', () => {
})
it('should handle undefined creationCache', () => {
// Arrange & Act
renderStepThree({ creationCache: undefined })
// Assert - Should not crash, use fallback values
@@ -390,14 +326,12 @@ describe('StepThree', () => {
})
it('should handle creationCache with undefined dataset', () => {
// Arrange
const creationCache: createDocumentResponse = {
dataset: undefined,
batch: 'batch-123',
documents: [],
}
// Act
renderStepThree({ creationCache })
// Assert - Should use default icon info
@@ -406,12 +340,9 @@ describe('StepThree', () => {
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests - Test null, undefined, empty values and boundaries
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle all props being undefined', () => {
// Arrange & Act
renderStepThree({
datasetId: undefined,
datasetName: undefined,
@@ -426,7 +357,6 @@ describe('StepThree', () => {
})
it('should handle empty string datasetId', () => {
// Arrange & Act
renderStepThree({ datasetId: '' })
// Assert - Empty string is falsy, should show creation mode
@@ -434,7 +364,6 @@ describe('StepThree', () => {
})
it('should handle empty string datasetName', () => {
// Arrange & Act
renderStepThree({ datasetName: '' })
// Assert - Should not crash
@@ -442,23 +371,18 @@ describe('StepThree', () => {
})
it('should handle empty documents array in creationCache', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.documents = []
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('0')
})
it('should handle creationCache with missing icon_info', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.icon_info = undefined as any
creationCache.dataset!.icon_info = undefined as unknown as IconInfo
// Act
renderStepThree({ creationCache })
// Assert - Should use default icon info
@@ -466,10 +390,8 @@ describe('StepThree', () => {
})
it('should handle very long datasetName', () => {
// Arrange
const longName = 'A'.repeat(500)
// Act
renderStepThree({ datasetName: longName })
// Assert - Should render without crashing
@@ -477,10 +399,8 @@ describe('StepThree', () => {
})
it('should handle special characters in datasetName', () => {
// Arrange
const specialName = 'Dataset <script>alert("xss")</script> & "quotes" \'apostrophe\''
// Act
renderStepThree({ datasetName: specialName })
// Assert - Should render safely as text
@@ -488,22 +408,17 @@ describe('StepThree', () => {
})
it('should handle unicode characters in datasetName', () => {
// Arrange
const unicodeName = '数据集名称 🚀 émojis & spëcîal çhàrs'
// Act
renderStepThree({ datasetName: unicodeName })
// Assert
expect(screen.getByText(unicodeName)).toBeInTheDocument()
})
it('should handle creationCache with null dataset name', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.name = null as any
creationCache.dataset!.name = null as unknown as string
// Act
const { container } = renderStepThree({ creationCache })
// Assert - Should not crash
@@ -511,13 +426,10 @@ describe('StepThree', () => {
})
})
// --------------------------------------------------------------------------
// Conditional Rendering Tests - Test mode switching behavior
// --------------------------------------------------------------------------
describe('Conditional Rendering', () => {
describe('Creation Mode (no datasetId)', () => {
it('should show AppIcon component', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert - AppIcon should be rendered
@@ -526,7 +438,6 @@ describe('StepThree', () => {
})
it('should show Divider component', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert - Divider should be rendered (it adds hr with specific classes)
@@ -535,20 +446,16 @@ describe('StepThree', () => {
})
it('should show dataset name input area', () => {
// Arrange
const datasetName = 'Test Dataset Name'
// Act
renderStepThree({ datasetName })
// Assert
expect(screen.getByText(datasetName)).toBeInTheDocument()
})
})
describe('Addition Mode (with datasetId)', () => {
it('should not show AppIcon component', () => {
// Arrange & Act
renderStepThree({ datasetId: 'dataset-123' })
// Assert - Creation section should not be rendered
@@ -556,7 +463,6 @@ describe('StepThree', () => {
})
it('should show addition description with dataset name', () => {
// Arrange & Act
renderStepThree({
datasetId: 'dataset-123',
datasetName: 'My Dataset',
@@ -569,10 +475,8 @@ describe('StepThree', () => {
describe('Mobile vs Desktop', () => {
it('should show side panel on tablet', () => {
// Arrange
mockMediaType = 'tablet'
// Act
renderStepThree()
// Assert - Tablet is not mobile, should show side panel
@@ -580,21 +484,16 @@ describe('StepThree', () => {
})
it('should not show side panel on mobile', () => {
// Arrange
mockMediaType = 'mobile'
// Act
renderStepThree()
// Assert
expect(screen.queryByText('datasetCreation.stepThree.sideTipTitle')).not.toBeInTheDocument()
})
it('should render EmbeddingProcess on mobile', () => {
// Arrange
mockMediaType = 'mobile'
// Act
renderStepThree()
// Assert - Main content should still be rendered
@@ -603,64 +502,48 @@ describe('StepThree', () => {
})
})
// --------------------------------------------------------------------------
// EmbeddingProcess Integration Tests - Verify correct props are passed
// --------------------------------------------------------------------------
describe('EmbeddingProcess Integration', () => {
it('should pass correct datasetId to EmbeddingProcess with datasetId prop', () => {
// Arrange & Act
renderStepThree({ datasetId: 'direct-dataset-id' })
// Assert
expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('direct-dataset-id')
})
it('should pass creationCache dataset id when datasetId prop is undefined', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.id = 'cache-dataset-id'
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('cache-dataset-id')
})
it('should pass empty string for datasetId when both sources are undefined', () => {
// Arrange & Act
renderStepThree()
// Assert
expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('')
})
it('should pass batchId from creationCache', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.batch = 'test-batch-456'
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('test-batch-456')
})
it('should pass empty string for batchId when creationCache is undefined', () => {
// Arrange & Act
renderStepThree()
// Assert
expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('')
})
it('should prefer datasetId prop over creationCache dataset id', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.id = 'cache-id'
// Act
renderStepThree({ datasetId: 'prop-id', creationCache })
// Assert - datasetId prop takes precedence
@@ -668,12 +551,9 @@ describe('StepThree', () => {
})
})
// --------------------------------------------------------------------------
// Icon Rendering Tests - Verify AppIcon behavior
// --------------------------------------------------------------------------
describe('Icon Rendering', () => {
it('should use default icon info when creationCache is undefined', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert - Default background color should be applied
@@ -683,7 +563,6 @@ describe('StepThree', () => {
})
it('should use icon_info from creationCache when available', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.icon_info = {
icon: '🎉',
@@ -692,7 +571,6 @@ describe('StepThree', () => {
icon_url: '',
}
// Act
const { container } = renderStepThree({ creationCache })
// Assert - Custom background color should be applied
@@ -702,11 +580,9 @@ describe('StepThree', () => {
})
it('should use default icon when creationCache dataset icon_info is undefined', () => {
// Arrange
const creationCache = createMockCreationCache()
delete (creationCache.dataset as any).icon_info
delete (creationCache.dataset as Partial<DataSet>).icon_info
// Act
const { container } = renderStepThree({ creationCache })
// Assert - Component should still render with default icon
@@ -714,15 +590,11 @@ describe('StepThree', () => {
})
})
// --------------------------------------------------------------------------
// Layout Tests - Verify correct CSS classes and structure
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have correct outer container classes', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv).toHaveClass('flex')
expect(outerDiv).toHaveClass('h-full')
@@ -730,49 +602,37 @@ describe('StepThree', () => {
})
it('should have correct inner container classes', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert
const innerDiv = container.querySelector('.max-w-\\[960px\\]')
expect(innerDiv).toBeInTheDocument()
expect(innerDiv).toHaveClass('shrink-0', 'grow')
})
it('should have content wrapper with correct max width', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert
const contentWrapper = container.querySelector('.max-w-\\[640px\\]')
expect(contentWrapper).toBeInTheDocument()
})
it('should have side tip panel with correct width on desktop', () => {
// Arrange
mockMediaType = 'pc'
// Act
const { container } = renderStepThree()
// Assert
const sidePanel = container.querySelector('.w-\\[328px\\]')
expect(sidePanel).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Accessibility Tests - Verify accessibility features
// --------------------------------------------------------------------------
describe('Accessibility', () => {
it('should have correct link attributes for external documentation link', () => {
// Arrange
mockMediaType = 'pc'
// Act
renderStepThree()
// Assert
const link = screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')
expect(link.tagName).toBe('A')
expect(link).toHaveAttribute('target', '_blank')
@@ -780,35 +640,27 @@ describe('StepThree', () => {
})
it('should have semantic heading structure in creation mode', () => {
// Arrange & Act
renderStepThree()
// Assert
const title = screen.getByText('datasetCreation.stepThree.creationTitle')
expect(title).toBeInTheDocument()
expect(title.className).toContain('title-2xl-semi-bold')
})
it('should have semantic heading structure in addition mode', () => {
// Arrange & Act
renderStepThree({ datasetId: 'dataset-123' })
// Assert
const title = screen.getByText('datasetCreation.stepThree.additionTitle')
expect(title).toBeInTheDocument()
expect(title.className).toContain('title-2xl-semi-bold')
})
})
// --------------------------------------------------------------------------
// Side Panel Tests - Verify side panel behavior
// --------------------------------------------------------------------------
describe('Side Panel', () => {
it('should render RiBookOpenLine icon in side panel', () => {
// Arrange
mockMediaType = 'pc'
// Act
const { container } = renderStepThree()
// Assert - Icon should be present in side panel
@@ -817,25 +669,19 @@ describe('StepThree', () => {
})
it('should have correct side panel section background', () => {
// Arrange
mockMediaType = 'pc'
// Act
const { container } = renderStepThree()
// Assert
const sidePanel = container.querySelector('.bg-background-section')
expect(sidePanel).toBeInTheDocument()
})
it('should have correct padding for side panel', () => {
// Arrange
mockMediaType = 'pc'
// Act
const { container } = renderStepThree()
// Assert
const sidePanelWrapper = container.querySelector('.pr-8')
expect(sidePanelWrapper).toBeInTheDocument()
})

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