mirror of
https://github.com/langgenius/dify.git
synced 2026-02-11 11:04:01 +00:00
Compare commits
14 Commits
test/integ
...
test/integ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca9c2a62fb | ||
|
|
f6c03362e7 | ||
|
|
e92867884f | ||
|
|
a9f56716fc | ||
|
|
94eefaaee2 | ||
|
|
59f3acb021 | ||
|
|
4655a6a244 | ||
|
|
5006a5e804 | ||
|
|
5e6e8a16ce | ||
|
|
0301d6b690 | ||
|
|
755c9b0c15 | ||
|
|
f1cce53bc2 | ||
|
|
a29e74422e | ||
|
|
83ef687d00 |
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
|
||||
@@ -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
84
api/uv.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
301
web/__tests__/datasets/create-dataset-flow.test.tsx
Normal file
301
web/__tests__/datasets/create-dataset-flow.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
451
web/__tests__/datasets/dataset-settings-flow.test.tsx
Normal file
451
web/__tests__/datasets/dataset-settings-flow.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
335
web/__tests__/datasets/document-management.test.tsx
Normal file
335
web/__tests__/datasets/document-management.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
215
web/__tests__/datasets/external-knowledge-base.test.tsx
Normal file
215
web/__tests__/datasets/external-knowledge-base.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
404
web/__tests__/datasets/hit-testing-flow.test.tsx
Normal file
404
web/__tests__/datasets/hit-testing-flow.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
337
web/__tests__/datasets/metadata-management-flow.test.tsx
Normal file
337
web/__tests__/datasets/metadata-management-flow.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
477
web/__tests__/datasets/pipeline-datasource-flow.test.tsx
Normal file
477
web/__tests__/datasets/pipeline-datasource-flow.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
301
web/__tests__/datasets/segment-crud.test.tsx
Normal file
301
web/__tests__/datasets/segment-crud.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
309
web/app/components/datasets/__tests__/chunk.spec.tsx
Normal file
309
web/app/components/datasets/__tests__/chunk.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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', () => {
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 => ({
|
||||
@@ -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', () => {
|
||||
@@ -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', () => {
|
||||
@@ -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', () => {
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
@@ -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(),
|
||||
@@ -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', () => ({
|
||||
@@ -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', () => {
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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', () => {
|
||||
@@ -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 () => {
|
||||
@@ -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,
|
||||
@@ -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', () => {
|
||||
@@ -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: {
|
||||
@@ -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: {
|
||||
@@ -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',
|
||||
@@ -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: {
|
||||
@@ -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: {
|
||||
@@ -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',
|
||||
@@ -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: {
|
||||
@@ -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', () => {
|
||||
@@ -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',
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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 />)
|
||||
@@ -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 />)
|
||||
@@ -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} />)
|
||||
@@ -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} />)
|
||||
@@ -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)
|
||||
|
||||
@@ -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} />)
|
||||
@@ -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,
|
||||
@@ -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} />)
|
||||
@@ -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} />)
|
||||
@@ -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({
|
||||
@@ -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 />)
|
||||
@@ -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({
|
||||
@@ -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 />)
|
||||
@@ -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} />)
|
||||
@@ -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} />)
|
||||
@@ -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} />)
|
||||
@@ -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} />)
|
||||
@@ -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} />)
|
||||
@@ -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} />)
|
||||
@@ -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())
|
||||
@@ -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({
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}))
|
||||
@@ -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"]')
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user