Compare commits

...
Author SHA1 Message Date
hjlarry e1016eaedb add command sql log args 2026-01-21 20:54:56 +08:00
hjlarry e5656b2b5c add from_days_ago and to_days_ago 2026-01-21 14:16:45 +08:00
hjlarry fe9575ada5 Merge branch 'archive-workflow-backend' of github.com:hjlarry/dify into archive-workflow-backend 2026-01-21 10:14:16 +08:00
hjlarry 198c39b97e rm unused property 2026-01-21 10:14:11 +08:00
autofix-ci[bot]andGitHub b4f9f3d224 [autofix.ci] apply automated fixes 2026-01-21 01:41:05 +00:00
hjlarry c89d8ba740 fix comment 2026-01-21 09:35:24 +08:00
hjlarry adae82d073 _delete_node_executions use run_ids 2026-01-21 09:35:20 +08:00
hjlarry 6d23f8a071 use single run_id query nodeExecution 2026-01-21 09:30:28 +08:00
hjlarry 4864076b43 update migration file to support uuidv7 2026-01-21 09:19:24 +08:00
hjlarry d8c6d81810 chore(web): restore eslint suppressions from myori/main 2026-01-20 23:07:28 +08:00
hjlarry 87a8545ca5 chore(web): revert eslint suppressions 2026-01-20 22:55:01 +08:00
hjlarry 8118a2fc38 Merge remote-tracking branch 'myori/main' into archive-workflow-backend 2026-01-20 22:47:39 +08:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>Claude Sonnet 4.5yyh
8f949d503a chore(i18n): sync translations with en-US (#31298)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-01-20 21:40:43 +08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c9519d2f0e chore(deps): bump js-yaml from 4.1.0 to 4.1.1 in /web (#31297)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 21:27:15 +08:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>hyoban
92fc50ac57 chore(web): remove version prefixes from package.json (#31286)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: hyoban <38493346+hyoban@users.noreply.github.com>
2026-01-20 21:14:50 +08:00
3bb80a0934 chore: lint for i18n place holder (#31283)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-01-20 21:14:20 +08:00
hjlarry b4d29ad6ee feat: archive workflow runs backend 2026-01-20 20:45:45 +08:00
yyhandGitHub 54921844bb fix(web): disable HTML escaping for form field validation messages (#31292) 2026-01-20 18:43:01 +08:00
3ebe53ada1 ci: label web changes (#31261)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-20 13:46:23 +08:00
76b64dda52 test: add tests for dataset list (#31231)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-01-20 13:07:00 +08:00
cxhelloandGitHub a715c015e7 chore(web): remove redundant optimizePackageImports config (#31257) 2026-01-20 12:24:16 +08:00
45b8d033be chore: init tsslint (#31209)
Co-authored-by: Johnson Chu <johnsoncodehk@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-20 11:08:50 +08:00
kurokoboandGitHub cb51a449d3 fix: correct i18n for stepOne.uploader.tip (#31177) 2026-01-20 09:30:50 +08:00
盐粒 YanliGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Cursor AgentCopilot
62ac02a568 feat: Download the uploaded files (#31068)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-19 16:48:13 +08:00
zxhlyhandGitHub 2d4289a925 chore: relocate datasets api form (#31224) 2026-01-19 16:15:51 +08:00
wangxiaoleiandGitHub 88780c7eb7 fix: Revert "fix: fix create app xss issue" (#31219) 2026-01-19 16:07:24 +08:00
wangxiaoleiandGitHub 0f1db88dcb fix: fix dify-plugin-daemon error message (#31218) 2026-01-19 16:00:44 +08:00
361 changed files with 24544 additions and 1512 deletions
+3
View File
@@ -0,0 +1,3 @@
web:
- changed-files:
- any-glob-to-any-file: 'web/**'
+14
View File
@@ -0,0 +1,14 @@
name: "Pull Request Labeler"
on:
pull_request_target:
jobs:
labeler:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v6
with:
sync-labels: true
+5
View File
@@ -117,6 +117,11 @@ jobs:
# eslint-report: web/eslint_report.json
# github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Web tsslint
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: pnpm run lint:tss
- name: Web type check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
@@ -134,6 +134,9 @@ jobs:
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
# Allow github-actions bot to trigger this workflow via repository_dispatch
# See: https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
allowed_bots: 'github-actions[bot]'
prompt: |
You are a professional i18n synchronization engineer for the Dify project.
Your task is to keep all language translations in sync with the English source (en-US).
@@ -285,6 +288,22 @@ jobs:
- `${variable}` - Template literal
- `<tag>content</tag>` - HTML tags
- `_one`, `_other` - Pluralization suffixes (these are KEY suffixes, not values)
**CRITICAL: Variable names and tag names MUST stay in English - NEVER translate them**
✅ CORRECT examples:
- English: "{{count}} items" → Japanese: "{{count}} 個のアイテム"
- English: "{{name}} updated" → Korean: "{{name}} 업데이트됨"
- English: "<email>{{email}}</email>" → Chinese: "<email>{{email}}</email>"
- English: "<CustomLink>Marketplace</CustomLink>" → Japanese: "<CustomLink>マーケットプレイス</CustomLink>"
❌ WRONG examples (NEVER do this - will break the application):
- "{{count}}" → "{{カウント}}" ❌ (variable name translated to Japanese)
- "{{name}}" → "{{이름}}" ❌ (variable name translated to Korean)
- "{{email}}" → "{{邮箱}}" ❌ (variable name translated to Chinese)
- "<email>" → "<メール>" ❌ (tag name translated)
- "<CustomLink>" → "<自定义链接>" ❌ (component name translated)
- Use appropriate language register (formal/informal) based on existing translations
- Match existing translation style in each language
- Technical terms: check existing conventions per language
@@ -0,0 +1,52 @@
## Purpose
`api/controllers/console/datasets/datasets_document.py` contains the console (authenticated) APIs for managing dataset documents (list/create/update/delete, processing controls, estimates, etc.).
## Storage model (uploaded files)
- For local file uploads into a knowledge base, the binary is stored via `extensions.ext_storage.storage` under the key:
- `upload_files/<tenant_id>/<uuid>.<ext>`
- File metadata is stored in the `upload_files` table (`UploadFile` model), keyed by `UploadFile.id`.
- Dataset `Document` records reference the uploaded file via:
- `Document.data_source_info.upload_file_id`
## Download endpoint
- `GET /datasets/<dataset_id>/documents/<document_id>/download`
- Only supported when `Document.data_source_type == "upload_file"`.
- Performs dataset permission + tenant checks via `DocumentResource.get_document(...)`.
- Delegates `Document -> UploadFile` validation and signed URL generation to `DocumentService.get_document_download_url(...)`.
- Applies `cloud_edition_billing_rate_limit_check("knowledge")` to match other KB operations.
- Response body is **only**: `{ "url": "<signed-url>" }`.
- `POST /datasets/<dataset_id>/documents/download-zip`
- Accepts `{ "document_ids": ["..."] }` (upload-file only).
- Returns `application/zip` as a single attachment download.
- Rationale: browsers often block multiple automatic downloads; a ZIP avoids that limitation.
- Applies `cloud_edition_billing_rate_limit_check("knowledge")`.
- Delegates dataset permission checks, document/upload-file validation, and download-name generation to
`DocumentService.prepare_document_batch_download_zip(...)` before streaming the ZIP.
## Verification plan
- Upload a document from a local file into a dataset.
- Call the download endpoint and confirm it returns a signed URL.
- Open the URL and confirm:
- Response headers force download (`Content-Disposition`), and
- Downloaded bytes match the uploaded file.
- Select multiple uploaded-file documents and download as ZIP; confirm all selected files exist in the archive.
## Shared helper
- `DocumentService.get_document_download_url(document)` resolves the `UploadFile` and signs a download URL.
- `DocumentService.prepare_document_batch_download_zip(...)` performs dataset permission checks, batches
document + upload file lookups, preserves request order, and generates the client-visible ZIP filename.
- Internal helpers now live in `DocumentService` (`_get_upload_file_id_for_upload_file_document(...)`,
`_get_upload_file_for_upload_file_document(...)`, `_get_upload_files_by_document_id_for_zip_download(...)`).
- ZIP packing is handled by `FileService.build_upload_files_zip_tempfile(...)`, which also:
- sanitizes entry names to avoid path traversal, and
- deduplicates names while preserving extensions (e.g., `doc.txt``doc (1).txt`).
Streaming the response and deferring cleanup is handled by the route via `send_file(path, ...)` + `ExitStack` +
`response.call_on_close(...)` (the file is deleted when the response is closed).
@@ -0,0 +1,18 @@
## Purpose
`api/services/dataset_service.py` hosts dataset/document service logic used by console and API controllers.
## Batch document operations
- Batch document workflows should avoid N+1 database queries by using set-based lookups.
- Tenant checks must be enforced consistently across dataset/document operations.
- `DocumentService.get_documents_by_ids(...)` fetches documents for a dataset using `id.in_(...)`.
- `FileService.get_upload_files_by_ids(...)` performs tenant-scoped batch lookup for `UploadFile` (dedupes ids with `set(...)`).
- `DocumentService.get_document_download_url(...)` and `prepare_document_batch_download_zip(...)` handle
dataset/document permission checks plus `Document -> UploadFile` validation for download endpoints.
## Verification plan
- Exercise document list and download endpoints that use the service helpers.
- Confirm batch download uses constant query count for documents + upload files.
- Request a ZIP with a missing document id and confirm a 404 is returned.
@@ -0,0 +1,35 @@
## Purpose
`api/services/file_service.py` owns business logic around `UploadFile` objects: upload validation, storage persistence,
previews/generators, and deletion.
## Key invariants
- All storage I/O goes through `extensions.ext_storage.storage`.
- Uploaded file keys follow: `upload_files/<tenant_id>/<uuid>.<ext>`.
- Upload validation is enforced in `FileService.upload_file(...)` (blocked extensions, size limits, dataset-only types).
## Batch lookup helpers
- `FileService.get_upload_files_by_ids(tenant_id, upload_file_ids)` is the canonical tenant-scoped batch loader for
`UploadFile`.
## Dataset document download helpers
The dataset document download/ZIP endpoints now delegate “Document → UploadFile” validation and permission checks to
`DocumentService` (`api/services/dataset_service.py`). `FileService` stays focused on generic `UploadFile` operations
(uploading, previews, deletion), plus generic ZIP serving.
### ZIP serving
- `FileService.build_upload_files_zip_tempfile(...)` builds a ZIP from `UploadFile` objects and yields a seeked
tempfile **path** so callers can stream it (e.g., `send_file(path, ...)`) without hitting "read of closed file"
issues from file-handle lifecycle during streamed responses.
- Flask `send_file(...)` and the `ExitStack`/`call_on_close(...)` cleanup pattern are handled in the route layer.
## Verification plan
- Unit: `api/tests/unit_tests/controllers/console/datasets/test_datasets_document_download.py`
- Verify signed URL generation for upload-file documents and ZIP download behavior for multiple documents.
- Unit: `api/tests/unit_tests/services/test_file_service_zip_and_lookup.py`
- Verify ZIP packing produces a valid, openable archive and preserves file content.
@@ -0,0 +1,28 @@
## Purpose
Unit tests for the console dataset document download endpoint:
- `GET /datasets/<dataset_id>/documents/<document_id>/download`
## Testing approach
- Uses `Flask.test_request_context()` and calls the `Resource.get(...)` method directly.
- Monkeypatches console decorators (`login_required`, `setup_required`, rate limit) to no-ops to keep the test focused.
- Mocks:
- `DatasetService.get_dataset` / `check_dataset_permission`
- `DocumentService.get_document` for single-file download tests
- `DocumentService.get_documents_by_ids` + `FileService.get_upload_files_by_ids` for ZIP download tests
- `FileService.get_upload_files_by_ids` for `UploadFile` lookups in single-file tests
- `services.dataset_service.file_helpers.get_signed_file_url` to return a deterministic URL
- Document mocks include `id` fields so batch lookups can map documents by id.
## Covered cases
- Success returns `{ "url": "<signed>" }` for upload-file documents.
- 404 when document is not `upload_file`.
- 404 when `upload_file_id` is missing.
- 404 when referenced `UploadFile` row does not exist.
- 403 when document tenant does not match current tenant.
- Batch ZIP download returns `application/zip` for upload-file documents.
- Batch ZIP download rejects non-upload-file documents.
- Batch ZIP download uses a random `.zip` attachment name (`download_name`), so tests only assert the suffix.
@@ -0,0 +1,18 @@
## Purpose
Unit tests for `api/services/file_service.py` helper methods that are not covered by higher-level controller tests.
## Whats covered
- `FileService.build_upload_files_zip_tempfile(...)`
- ZIP entry name sanitization (no directory components / traversal)
- name deduplication while preserving extensions
- writing streamed bytes from `storage.load(...)` into ZIP entries
- yields a tempfile path so callers can open/stream the ZIP without holding a live file handle
- `FileService.get_upload_files_by_ids(...)`
- returns `{}` for empty id lists
- returns an id-keyed mapping for non-empty lists
## Notes
- These tests intentionally stub `storage.load` and `db.session.scalars(...).all()` to avoid needing a real DB/storage.
+375 -1
View File
@@ -4,13 +4,15 @@ import json
import logging
import secrets
import time
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
import click
import sqlalchemy as sa
from flask import current_app
from pydantic import TypeAdapter
from sqlalchemy import select
from sqlalchemy import event, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import sessionmaker
@@ -55,6 +57,28 @@ from tasks.remove_app_and_related_data_task import delete_draft_variables_batch
logger = logging.getLogger(__name__)
@contextmanager
def _log_slow_queries(engine: sa.engine.Engine, threshold_ms: float) -> Iterator[None]:
def _before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
context._query_start_time = time.perf_counter()
def _after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
start_time = getattr(context, "_query_start_time", None)
if start_time is None:
return
elapsed_ms = (time.perf_counter() - start_time) * 1000
if elapsed_ms >= threshold_ms:
logger.warning("Slow SQL query (%.2fms): %s", elapsed_ms, statement)
event.listen(engine, "before_cursor_execute", _before_cursor_execute)
event.listen(engine, "after_cursor_execute", _after_cursor_execute)
try:
yield
finally:
event.remove(engine, "before_cursor_execute", _before_cursor_execute)
event.remove(engine, "after_cursor_execute", _after_cursor_execute)
@click.command("reset-password", help="Reset the account password.")
@click.option("--email", prompt=True, help="Account email to reset password for")
@click.option("--new-password", prompt=True, help="New password")
@@ -950,6 +974,356 @@ def clean_workflow_runs(
)
@click.command(
"archive-workflow-runs",
help="Archive workflow runs for paid plan tenants to S3-compatible storage.",
)
@click.option("--tenant-ids", default=None, help="Optional comma-separated tenant IDs for grayscale rollout.")
@click.option("--before-days", default=90, show_default=True, help="Archive runs older than N days.")
@click.option(
"--from-days-ago",
default=None,
type=click.IntRange(min=0),
help="Lower bound in days ago (older). Must be paired with --to-days-ago.",
)
@click.option(
"--to-days-ago",
default=None,
type=click.IntRange(min=0),
help="Upper bound in days ago (newer). Must be paired with --from-days-ago.",
)
@click.option(
"--start-from",
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
default=None,
help="Archive runs created at or after this timestamp (UTC if no timezone).",
)
@click.option(
"--end-before",
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
default=None,
help="Archive runs created before this timestamp (UTC if no timezone).",
)
@click.option("--batch-size", default=100, show_default=True, help="Batch size for processing.")
@click.option("--workers", default=1, show_default=True, type=int, help="Concurrent workflow runs to archive.")
@click.option("--limit", default=None, type=int, help="Maximum number of runs to archive.")
@click.option("--dry-run", is_flag=True, help="Preview without archiving.")
@click.option("--delete-after-archive", is_flag=True, help="Delete runs and related data after archiving.")
@click.option(
"--slow-ms",
default=1000,
show_default=True,
type=int,
help="Log SQL queries slower than this threshold (ms).",
)
def archive_workflow_runs(
tenant_ids: str | None,
before_days: int,
from_days_ago: int | None,
to_days_ago: int | None,
start_from: datetime.datetime | None,
end_before: datetime.datetime | None,
batch_size: int,
workers: int,
limit: int | None,
dry_run: bool,
delete_after_archive: bool,
slow_ms: int,
):
"""
Archive workflow runs for paid plan tenants older than the specified days.
This command archives the following tables to storage:
- workflow_node_executions
- workflow_node_execution_offload
- workflow_pauses
- workflow_pause_reasons
- workflow_trigger_logs
The workflow_runs and workflow_app_logs tables are preserved for UI listing.
"""
from services.retention.workflow_run.archive_paid_plan_workflow_run import WorkflowRunArchiver
run_started_at = datetime.datetime.now(datetime.UTC)
click.echo(
click.style(
f"Starting workflow run archiving at {run_started_at.isoformat()}.",
fg="white",
)
)
if (start_from is None) ^ (end_before is None):
click.echo(click.style("start-from and end-before must be provided together.", fg="red"))
return
if (from_days_ago is None) ^ (to_days_ago is None):
click.echo(click.style("from-days-ago and to-days-ago must be provided together.", fg="red"))
return
if from_days_ago is not None and to_days_ago is not None:
if start_from or end_before:
click.echo(click.style("Choose either day offsets or explicit dates, not both.", fg="red"))
return
if from_days_ago <= to_days_ago:
click.echo(click.style("from-days-ago must be greater than to-days-ago.", fg="red"))
return
now = datetime.datetime.now()
start_from = now - datetime.timedelta(days=from_days_ago)
end_before = now - datetime.timedelta(days=to_days_ago)
before_days = 0
if start_from and end_before and start_from >= end_before:
click.echo(click.style("start-from must be earlier than end-before.", fg="red"))
return
if workers < 1:
click.echo(click.style("workers must be at least 1.", fg="red"))
return
archiver = WorkflowRunArchiver(
days=before_days,
batch_size=batch_size,
start_from=start_from,
end_before=end_before,
workers=workers,
tenant_ids=[tid.strip() for tid in tenant_ids.split(",")] if tenant_ids else None,
limit=limit,
dry_run=dry_run,
delete_after_archive=delete_after_archive,
)
logger.info("Enable slow query logging for archive-workflow-runs (>=%sms).", slow_ms)
with _log_slow_queries(db.engine, slow_ms):
summary = archiver.run()
click.echo(
click.style(
f"Summary: processed={summary.total_runs_processed}, archived={summary.runs_archived}, "
f"skipped={summary.runs_skipped}, failed={summary.runs_failed}, "
f"time={summary.total_elapsed_time:.2f}s",
fg="cyan",
)
)
run_finished_at = datetime.datetime.now(datetime.UTC)
elapsed = run_finished_at - run_started_at
click.echo(
click.style(
f"Workflow run archiving completed. start={run_started_at.isoformat()} "
f"end={run_finished_at.isoformat()} duration={elapsed}",
fg="green",
)
)
@click.command(
"restore-workflow-runs",
help="Restore archived workflow runs from S3-compatible storage.",
)
@click.option(
"--tenant-ids",
required=False,
help="Tenant IDs (comma-separated).",
)
@click.option("--run-id", required=False, help="Workflow run ID to restore.")
@click.option(
"--start-from",
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
default=None,
help="Optional lower bound (inclusive) for created_at; must be paired with --end-before.",
)
@click.option(
"--end-before",
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
default=None,
help="Optional upper bound (exclusive) for created_at; must be paired with --start-from.",
)
@click.option("--workers", default=1, show_default=True, type=int, help="Concurrent workflow runs to restore.")
@click.option("--limit", type=int, default=100, show_default=True, help="Maximum number of runs to restore.")
@click.option("--dry-run", is_flag=True, help="Preview without restoring.")
def restore_workflow_runs(
tenant_ids: str | None,
run_id: str | None,
start_from: datetime.datetime | None,
end_before: datetime.datetime | None,
workers: int,
limit: int,
dry_run: bool,
):
"""
Restore an archived workflow run from storage to the database.
This restores the following tables:
- workflow_node_executions
- workflow_node_execution_offload
- workflow_pauses
- workflow_pause_reasons
- workflow_trigger_logs
"""
from services.retention.workflow_run.restore_archived_workflow_run import WorkflowRunRestore
parsed_tenant_ids = None
if tenant_ids:
parsed_tenant_ids = [tid.strip() for tid in tenant_ids.split(",") if tid.strip()]
if not parsed_tenant_ids:
raise click.BadParameter("tenant-ids must not be empty")
if (start_from is None) ^ (end_before is None):
raise click.UsageError("--start-from and --end-before must be provided together.")
if run_id is None and (start_from is None or end_before is None):
raise click.UsageError("--start-from and --end-before are required for batch restore.")
if workers < 1:
raise click.BadParameter("workers must be at least 1")
start_time = datetime.datetime.now(datetime.UTC)
click.echo(
click.style(
f"Starting restore of workflow run {run_id} at {start_time.isoformat()}.",
fg="white",
)
)
restorer = WorkflowRunRestore(dry_run=dry_run, workers=workers)
if run_id:
results = [restorer.restore_by_run_id(run_id)]
else:
assert start_from is not None
assert end_before is not None
results = restorer.restore_batch(
parsed_tenant_ids,
start_date=start_from,
end_date=end_before,
limit=limit,
)
end_time = datetime.datetime.now(datetime.UTC)
elapsed = end_time - start_time
successes = sum(1 for result in results if result.success)
failures = len(results) - successes
if failures == 0:
click.echo(
click.style(
f"Restore completed successfully. success={successes} duration={elapsed}",
fg="green",
)
)
else:
click.echo(
click.style(
f"Restore completed with failures. success={successes} failed={failures} duration={elapsed}",
fg="red",
)
)
@click.command(
"delete-archived-workflow-runs",
help="Delete archived workflow runs from the database.",
)
@click.option(
"--tenant-ids",
required=False,
help="Tenant IDs (comma-separated).",
)
@click.option("--run-id", required=False, help="Workflow run ID to delete.")
@click.option(
"--start-from",
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
default=None,
help="Optional lower bound (inclusive) for created_at; must be paired with --end-before.",
)
@click.option(
"--end-before",
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
default=None,
help="Optional upper bound (exclusive) for created_at; must be paired with --start-from.",
)
@click.option("--limit", type=int, default=100, show_default=True, help="Maximum number of runs to delete.")
@click.option("--dry-run", is_flag=True, help="Preview without deleting.")
def delete_archived_workflow_runs(
tenant_ids: str | None,
run_id: str | None,
start_from: datetime.datetime | None,
end_before: datetime.datetime | None,
limit: int,
dry_run: bool,
):
"""
Delete archived workflow runs from the database.
"""
from services.retention.workflow_run.delete_archived_workflow_run import ArchivedWorkflowRunDeletion
parsed_tenant_ids = None
if tenant_ids:
parsed_tenant_ids = [tid.strip() for tid in tenant_ids.split(",") if tid.strip()]
if not parsed_tenant_ids:
raise click.BadParameter("tenant-ids must not be empty")
if (start_from is None) ^ (end_before is None):
raise click.UsageError("--start-from and --end-before must be provided together.")
if run_id is None and (start_from is None or end_before is None):
raise click.UsageError("--start-from and --end-before are required for batch delete.")
start_time = datetime.datetime.now(datetime.UTC)
target_desc = f"workflow run {run_id}" if run_id else "workflow runs"
click.echo(
click.style(
f"Starting delete of {target_desc} at {start_time.isoformat()}.",
fg="white",
)
)
deleter = ArchivedWorkflowRunDeletion(dry_run=dry_run)
if run_id:
results = [deleter.delete_by_run_id(run_id)]
else:
assert start_from is not None
assert end_before is not None
results = deleter.delete_batch(
parsed_tenant_ids,
start_date=start_from,
end_date=end_before,
limit=limit,
)
for result in results:
if result.success:
click.echo(
click.style(
f"{'[DRY RUN] Would delete' if dry_run else 'Deleted'} "
f"workflow run {result.run_id} (tenant={result.tenant_id})",
fg="green",
)
)
else:
click.echo(
click.style(
f"Failed to delete workflow run {result.run_id}: {result.error}",
fg="red",
)
)
end_time = datetime.datetime.now(datetime.UTC)
elapsed = end_time - start_time
successes = sum(1 for result in results if result.success)
failures = len(results) - successes
if failures == 0:
click.echo(
click.style(
f"Delete completed successfully. success={successes} duration={elapsed}",
fg="green",
)
)
else:
click.echo(
click.style(
f"Delete completed with failures. success={successes} failed={failures} duration={elapsed}",
fg="red",
)
)
@click.option("-f", "--force", is_flag=True, help="Skip user confirmation and force the command to execute.")
@click.command("clear-orphaned-file-records", help="Clear orphaned file records.")
def clear_orphaned_file_records(force: bool):
-58
View File
@@ -1,4 +1,3 @@
import re
import uuid
from datetime import datetime
from typing import Any, Literal, TypeAlias
@@ -68,48 +67,6 @@ class AppListQuery(BaseModel):
raise ValueError("Invalid UUID format in tag_ids.") from exc
# XSS prevention: patterns that could lead to XSS attacks
# Includes: script tags, iframe tags, javascript: protocol, SVG with onload, etc.
_XSS_PATTERNS = [
r"<script[^>]*>.*?</script>", # Script tags
r"<iframe\b[^>]*?(?:/>|>.*?</iframe>)", # Iframe tags (including self-closing)
r"javascript:", # JavaScript protocol
r"<svg[^>]*?\s+onload\s*=[^>]*>", # SVG with onload handler (attribute-aware, flexible whitespace)
r"<.*?on\s*\w+\s*=", # Event handlers like onclick, onerror, etc.
r"<object\b[^>]*(?:\s*/>|>.*?</object\s*>)", # Object tags (opening tag)
r"<embed[^>]*>", # Embed tags (self-closing)
r"<link[^>]*>", # Link tags with javascript
]
def _validate_xss_safe(value: str | None, field_name: str = "Field") -> str | None:
"""
Validate that a string value doesn't contain potential XSS payloads.
Args:
value: The string value to validate
field_name: Name of the field for error messages
Returns:
The original value if safe
Raises:
ValueError: If the value contains XSS patterns
"""
if value is None:
return None
value_lower = value.lower()
for pattern in _XSS_PATTERNS:
if re.search(pattern, value_lower, re.DOTALL | re.IGNORECASE):
raise ValueError(
f"{field_name} contains invalid characters or patterns. "
"HTML tags, JavaScript, and other potentially dangerous content are not allowed."
)
return value
class CreateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name")
description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400)
@@ -118,11 +75,6 @@ class CreateAppPayload(BaseModel):
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class UpdateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name")
@@ -133,11 +85,6 @@ class UpdateAppPayload(BaseModel):
use_icon_as_answer_icon: bool | None = Field(default=None, description="Use icon as answer icon")
max_active_requests: int | None = Field(default=None, description="Maximum active requests")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class CopyAppPayload(BaseModel):
name: str | None = Field(default=None, description="Name for the copied app")
@@ -146,11 +93,6 @@ class CopyAppPayload(BaseModel):
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class AppExportQuery(BaseModel):
include_secret: bool = Field(default=False, description="Include secrets in export")
@@ -11,7 +11,10 @@ from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required
from core.workflow.enums import WorkflowExecutionStatus
from extensions.ext_database import db
from fields.workflow_app_log_fields import build_workflow_app_log_pagination_model
from fields.workflow_app_log_fields import (
build_workflow_app_log_pagination_model,
build_workflow_archived_log_pagination_model,
)
from libs.login import login_required
from models import App
from models.model import AppMode
@@ -61,6 +64,7 @@ console_ns.schema_model(
# Register model for flask_restx to avoid dict type issues in Swagger
workflow_app_log_pagination_model = build_workflow_app_log_pagination_model(console_ns)
workflow_archived_log_pagination_model = build_workflow_archived_log_pagination_model(console_ns)
@console_ns.route("/apps/<uuid:app_id>/workflow-app-logs")
@@ -99,3 +103,33 @@ class WorkflowAppLogApi(Resource):
)
return workflow_app_log_pagination
@console_ns.route("/apps/<uuid:app_id>/workflow-archived-logs")
class WorkflowArchivedLogApi(Resource):
@console_ns.doc("get_workflow_archived_logs")
@console_ns.doc(description="Get workflow archived execution logs")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[WorkflowAppLogQuery.__name__])
@console_ns.response(200, "Workflow archived logs retrieved successfully", workflow_archived_log_pagination_model)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW])
@marshal_with(workflow_archived_log_pagination_model)
def get(self, app_model: App):
"""
Get workflow archived logs
"""
args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
workflow_app_service = WorkflowAppService()
with Session(db.engine) as session:
workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_archive_logs(
session=session,
app_model=app_model,
page=args.page,
limit=args.limit,
)
return workflow_app_log_pagination
+66 -1
View File
@@ -1,12 +1,15 @@
from datetime import UTC, datetime, timedelta
from typing import Literal, cast
from flask import request
from flask_restx import Resource, fields, marshal_with
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required
from extensions.ext_database import db
from fields.end_user_fields import simple_end_user_fields
from fields.member_fields import simple_account_fields
from fields.workflow_run_fields import (
@@ -19,14 +22,17 @@ from fields.workflow_run_fields import (
workflow_run_node_execution_list_fields,
workflow_run_pagination_fields,
)
from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage
from libs.custom_inputs import time_duration
from libs.helper import uuid_value
from libs.login import current_user, login_required
from models import Account, App, AppMode, EndUser, WorkflowRunTriggeredFrom
from models import Account, App, AppMode, EndUser, WorkflowArchiveLog, WorkflowRunTriggeredFrom
from services.retention.workflow_run.constants import ARCHIVE_BUNDLE_NAME
from services.workflow_run_service import WorkflowRunService
# Workflow run status choices for filtering
WORKFLOW_RUN_STATUS_CHOICES = ["running", "succeeded", "failed", "stopped", "partial-succeeded"]
EXPORT_SIGNED_URL_EXPIRE_SECONDS = 3600
# Register models for flask_restx to avoid dict type issues in Swagger
# Register in dependency order: base models first, then dependent models
@@ -93,6 +99,15 @@ workflow_run_node_execution_list_model = console_ns.model(
"WorkflowRunNodeExecutionList", workflow_run_node_execution_list_fields_copy
)
workflow_run_export_fields = console_ns.model(
"WorkflowRunExport",
{
"status": fields.String(description="Export status: success/failed"),
"presigned_url": fields.String(description="Pre-signed URL for download", required=False),
"presigned_url_expires_at": fields.String(description="Pre-signed URL expiration time", required=False),
},
)
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
@@ -181,6 +196,56 @@ class AdvancedChatAppWorkflowRunListApi(Resource):
return result
@console_ns.route("/apps/<uuid:app_id>/workflow-runs/<uuid:run_id>/export")
class WorkflowRunExportApi(Resource):
@console_ns.doc("get_workflow_run_export_url")
@console_ns.doc(description="Generate a download URL for an archived workflow run.")
@console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"})
@console_ns.response(200, "Export URL generated", workflow_run_export_fields)
@setup_required
@login_required
@account_initialization_required
@get_app_model()
def get(self, app_model: App, run_id: str):
tenant_id = str(app_model.tenant_id)
app_id = str(app_model.id)
run_id_str = str(run_id)
run_created_at = db.session.scalar(
select(WorkflowArchiveLog.run_created_at)
.where(
WorkflowArchiveLog.tenant_id == tenant_id,
WorkflowArchiveLog.app_id == app_id,
WorkflowArchiveLog.workflow_run_id == run_id_str,
)
.limit(1)
)
if not run_created_at:
return {"code": "archive_log_not_found", "message": "workflow run archive not found"}, 404
prefix = (
f"{tenant_id}/app_id={app_id}/year={run_created_at.strftime('%Y')}/"
f"month={run_created_at.strftime('%m')}/workflow_run_id={run_id_str}"
)
archive_key = f"{prefix}/{ARCHIVE_BUNDLE_NAME}"
try:
archive_storage = get_archive_storage()
except ArchiveStorageNotConfiguredError as e:
return {"code": "archive_storage_not_configured", "message": str(e)}, 500
presigned_url = archive_storage.generate_presigned_url(
archive_key,
expires_in=EXPORT_SIGNED_URL_EXPIRE_SECONDS,
)
expires_at = datetime.now(UTC) + timedelta(seconds=EXPORT_SIGNED_URL_EXPIRE_SECONDS)
return {
"status": "success",
"presigned_url": presigned_url,
"presigned_url_expires_at": expires_at.isoformat(),
}, 200
@console_ns.route("/apps/<uuid:app_id>/advanced-chat/workflow-runs/count")
class AdvancedChatAppWorkflowRunCountApi(Resource):
@console_ns.doc("get_advanced_chat_workflow_runs_count")
@@ -2,10 +2,12 @@ import json
import logging
from argparse import ArgumentTypeError
from collections.abc import Sequence
from typing import Literal, cast
from contextlib import ExitStack
from typing import Any, Literal, cast
from uuid import UUID
import sqlalchemy as sa
from flask import request
from flask import request, send_file
from flask_restx import Resource, fields, marshal, marshal_with
from pydantic import BaseModel, Field
from sqlalchemy import asc, desc, select
@@ -42,6 +44,7 @@ from models import DatasetProcessRule, Document, DocumentSegment, UploadFile
from models.dataset import DocumentPipelineExecutionLog
from services.dataset_service import DatasetService, DocumentService
from services.entities.knowledge_entities.knowledge_entities import KnowledgeConfig, ProcessRule, RetrievalModel
from services.file_service import FileService
from ..app.error import (
ProviderModelCurrentlyNotSupportError,
@@ -65,6 +68,9 @@ from ..wraps import (
logger = logging.getLogger(__name__)
# NOTE: Keep constants near the top of the module for discoverability.
DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS = 100
def _get_or_create_model(model_name: str, field_def):
existing = console_ns.models.get(model_name)
@@ -104,6 +110,12 @@ class DocumentRenamePayload(BaseModel):
name: str
class DocumentBatchDownloadZipPayload(BaseModel):
"""Request payload for bulk downloading documents as a zip archive."""
document_ids: list[UUID] = Field(..., min_length=1, max_length=DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS)
class DocumentDatasetListParam(BaseModel):
page: int = Field(1, title="Page", description="Page number.")
limit: int = Field(20, title="Limit", description="Page size.")
@@ -120,6 +132,7 @@ register_schema_models(
RetrievalModel,
DocumentRetryPayload,
DocumentRenamePayload,
DocumentBatchDownloadZipPayload,
)
@@ -853,6 +866,62 @@ class DocumentApi(DocumentResource):
return {"result": "success"}, 204
@console_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/download")
class DocumentDownloadApi(DocumentResource):
"""Return a signed download URL for a dataset document's original uploaded file."""
@console_ns.doc("get_dataset_document_download_url")
@console_ns.doc(description="Get a signed download URL for a dataset document's original uploaded file")
@setup_required
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
def get(self, dataset_id: str, document_id: str) -> dict[str, Any]:
# Reuse the shared permission/tenant checks implemented in DocumentResource.
document = self.get_document(str(dataset_id), str(document_id))
return {"url": DocumentService.get_document_download_url(document)}
@console_ns.route("/datasets/<uuid:dataset_id>/documents/download-zip")
class DocumentBatchDownloadZipApi(DocumentResource):
"""Download multiple uploaded-file documents as a single ZIP (avoids browser multi-download limits)."""
@console_ns.doc("download_dataset_documents_as_zip")
@console_ns.doc(description="Download selected dataset documents as a single ZIP archive (upload-file only)")
@setup_required
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.expect(console_ns.models[DocumentBatchDownloadZipPayload.__name__])
def post(self, dataset_id: str):
"""Stream a ZIP archive containing the requested uploaded documents."""
# Parse and validate request payload.
payload = DocumentBatchDownloadZipPayload.model_validate(console_ns.payload or {})
current_user, current_tenant_id = current_account_with_tenant()
dataset_id = str(dataset_id)
document_ids: list[str] = [str(document_id) for document_id in payload.document_ids]
upload_files, download_name = DocumentService.prepare_document_batch_download_zip(
dataset_id=dataset_id,
document_ids=document_ids,
tenant_id=current_tenant_id,
current_user=current_user,
)
# Delegate ZIP packing to FileService, but keep Flask response+cleanup in the route.
with ExitStack() as stack:
zip_path = stack.enter_context(FileService.build_upload_files_zip_tempfile(upload_files=upload_files))
response = send_file(
zip_path,
mimetype="application/zip",
as_attachment=True,
download_name=download_name,
)
cleanup = stack.pop_all()
response.call_on_close(cleanup.close)
return response
@console_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/processing/<string:action>")
class DocumentProcessingApi(DocumentResource):
@console_ns.doc("update_document_processing")
+7 -8
View File
@@ -320,18 +320,17 @@ class BasePluginClient:
case PluginInvokeError.__name__:
error_object = json.loads(message)
invoke_error_type = error_object.get("error_type")
args = error_object.get("args")
match invoke_error_type:
case InvokeRateLimitError.__name__:
raise InvokeRateLimitError(description=args.get("description"))
raise InvokeRateLimitError(description=error_object.get("message"))
case InvokeAuthorizationError.__name__:
raise InvokeAuthorizationError(description=args.get("description"))
raise InvokeAuthorizationError(description=error_object.get("message"))
case InvokeBadRequestError.__name__:
raise InvokeBadRequestError(description=args.get("description"))
raise InvokeBadRequestError(description=error_object.get("message"))
case InvokeConnectionError.__name__:
raise InvokeConnectionError(description=args.get("description"))
raise InvokeConnectionError(description=error_object.get("message"))
case InvokeServerUnavailableError.__name__:
raise InvokeServerUnavailableError(description=args.get("description"))
raise InvokeServerUnavailableError(description=error_object.get("message"))
case CredentialsValidateFailedError.__name__:
raise CredentialsValidateFailedError(error_object.get("message"))
case EndpointSetupFailedError.__name__:
@@ -339,11 +338,11 @@ class BasePluginClient:
case TriggerProviderCredentialValidationError.__name__:
raise TriggerProviderCredentialValidationError(error_object.get("message"))
case TriggerPluginInvokeError.__name__:
raise TriggerPluginInvokeError(description=error_object.get("description"))
raise TriggerPluginInvokeError(description=error_object.get("message"))
case TriggerInvokeError.__name__:
raise TriggerInvokeError(error_object.get("message"))
case EventIgnoreError.__name__:
raise EventIgnoreError(description=error_object.get("description"))
raise EventIgnoreError(description=error_object.get("message"))
case _:
raise PluginInvokeError(description=message)
case PluginDaemonInternalServerError.__name__:
+6
View File
@@ -4,6 +4,7 @@ from dify_app import DifyApp
def init_app(app: DifyApp):
from commands import (
add_qdrant_index,
archive_workflow_runs,
clean_expired_messages,
clean_workflow_runs,
cleanup_orphaned_draft_variables,
@@ -11,6 +12,7 @@ def init_app(app: DifyApp):
clear_orphaned_file_records,
convert_to_agent_apps,
create_tenant,
delete_archived_workflow_runs,
extract_plugins,
extract_unique_plugins,
file_usage,
@@ -24,6 +26,7 @@ def init_app(app: DifyApp):
reset_email,
reset_encrypt_key_pair,
reset_password,
restore_workflow_runs,
setup_datasource_oauth_client,
setup_system_tool_oauth_client,
setup_system_trigger_oauth_client,
@@ -58,6 +61,9 @@ def init_app(app: DifyApp):
setup_datasource_oauth_client,
transform_datasource_credentials,
install_rag_pipeline_plugins,
archive_workflow_runs,
delete_archived_workflow_runs,
restore_workflow_runs,
clean_workflow_runs,
clean_expired_messages,
]
+51 -1
View File
@@ -2,7 +2,12 @@ from flask_restx import Namespace, fields
from fields.end_user_fields import build_simple_end_user_model, simple_end_user_fields
from fields.member_fields import build_simple_account_model, simple_account_fields
from fields.workflow_run_fields import build_workflow_run_for_log_model, workflow_run_for_log_fields
from fields.workflow_run_fields import (
build_workflow_run_for_archived_log_model,
build_workflow_run_for_log_model,
workflow_run_for_archived_log_fields,
workflow_run_for_log_fields,
)
from libs.helper import TimestampField
workflow_app_log_partial_fields = {
@@ -34,6 +39,33 @@ def build_workflow_app_log_partial_model(api_or_ns: Namespace):
return api_or_ns.model("WorkflowAppLogPartial", copied_fields)
workflow_archived_log_partial_fields = {
"id": fields.String,
"workflow_run": fields.Nested(workflow_run_for_archived_log_fields, allow_null=True),
"trigger_metadata": fields.Raw,
"created_by_account": fields.Nested(simple_account_fields, attribute="created_by_account", allow_null=True),
"created_by_end_user": fields.Nested(simple_end_user_fields, attribute="created_by_end_user", allow_null=True),
"created_at": TimestampField,
}
def build_workflow_archived_log_partial_model(api_or_ns: Namespace):
"""Build the workflow archived log partial model for the API or Namespace."""
workflow_run_model = build_workflow_run_for_archived_log_model(api_or_ns)
simple_account_model = build_simple_account_model(api_or_ns)
simple_end_user_model = build_simple_end_user_model(api_or_ns)
copied_fields = workflow_archived_log_partial_fields.copy()
copied_fields["workflow_run"] = fields.Nested(workflow_run_model, allow_null=True)
copied_fields["created_by_account"] = fields.Nested(
simple_account_model, attribute="created_by_account", allow_null=True
)
copied_fields["created_by_end_user"] = fields.Nested(
simple_end_user_model, attribute="created_by_end_user", allow_null=True
)
return api_or_ns.model("WorkflowArchivedLogPartial", copied_fields)
workflow_app_log_pagination_fields = {
"page": fields.Integer,
"limit": fields.Integer,
@@ -51,3 +83,21 @@ def build_workflow_app_log_pagination_model(api_or_ns: Namespace):
copied_fields = workflow_app_log_pagination_fields.copy()
copied_fields["data"] = fields.List(fields.Nested(workflow_app_log_partial_model))
return api_or_ns.model("WorkflowAppLogPagination", copied_fields)
workflow_archived_log_pagination_fields = {
"page": fields.Integer,
"limit": fields.Integer,
"total": fields.Integer,
"has_more": fields.Boolean,
"data": fields.List(fields.Nested(workflow_archived_log_partial_fields)),
}
def build_workflow_archived_log_pagination_model(api_or_ns: Namespace):
"""Build the workflow archived log pagination model for the API or Namespace."""
workflow_archived_log_partial_model = build_workflow_archived_log_partial_model(api_or_ns)
copied_fields = workflow_archived_log_pagination_fields.copy()
copied_fields["data"] = fields.List(fields.Nested(workflow_archived_log_partial_model))
return api_or_ns.model("WorkflowArchivedLogPagination", copied_fields)
+13
View File
@@ -23,6 +23,19 @@ def build_workflow_run_for_log_model(api_or_ns: Namespace):
return api_or_ns.model("WorkflowRunForLog", workflow_run_for_log_fields)
workflow_run_for_archived_log_fields = {
"id": fields.String,
"status": fields.String,
"triggered_from": fields.String,
"elapsed_time": fields.Float,
"total_tokens": fields.Integer,
}
def build_workflow_run_for_archived_log_model(api_or_ns: Namespace):
return api_or_ns.model("WorkflowRunForArchivedLog", workflow_run_for_archived_log_fields)
workflow_run_for_list_fields = {
"id": fields.String,
"version": fields.String,
+20 -14
View File
@@ -7,7 +7,6 @@ to S3-compatible object storage.
import base64
import datetime
import gzip
import hashlib
import logging
from collections.abc import Generator
@@ -39,7 +38,7 @@ class ArchiveStorage:
"""
S3-compatible storage client for archiving or exporting.
This client provides methods for storing and retrieving archived data in JSONL+gzip format.
This client provides methods for storing and retrieving archived data in JSONL format.
"""
def __init__(self, bucket: str):
@@ -69,7 +68,10 @@ class ArchiveStorage:
aws_access_key_id=dify_config.ARCHIVE_STORAGE_ACCESS_KEY,
aws_secret_access_key=dify_config.ARCHIVE_STORAGE_SECRET_KEY,
region_name=dify_config.ARCHIVE_STORAGE_REGION,
config=Config(s3={"addressing_style": "path"}),
config=Config(
s3={"addressing_style": "path"},
max_pool_connections=64,
),
)
# Verify bucket accessibility
@@ -100,12 +102,18 @@ class ArchiveStorage:
"""
checksum = hashlib.md5(data).hexdigest()
try:
self.client.put_object(
response = self.client.put_object(
Bucket=self.bucket,
Key=key,
Body=data,
ContentMD5=self._content_md5(data),
)
etag = response.get("ETag")
if not etag:
raise ArchiveStorageError(f"Missing ETag for '{key}'")
normalized_etag = etag.strip('"')
if normalized_etag != checksum:
raise ArchiveStorageError(f"ETag mismatch for '{key}': expected={checksum}, actual={normalized_etag}")
logger.debug("Uploaded object: %s (size=%d, checksum=%s)", key, len(data), checksum)
return checksum
except ClientError as e:
@@ -240,19 +248,18 @@ class ArchiveStorage:
return base64.b64encode(hashlib.md5(data).digest()).decode()
@staticmethod
def serialize_to_jsonl_gz(records: list[dict[str, Any]]) -> bytes:
def serialize_to_jsonl(records: list[dict[str, Any]]) -> bytes:
"""
Serialize records to gzipped JSONL format.
Serialize records to JSONL format.
Args:
records: List of dictionaries to serialize
Returns:
Gzipped JSONL bytes
JSONL bytes
"""
lines = []
for record in records:
# Convert datetime objects to ISO format strings
serialized = ArchiveStorage._serialize_record(record)
lines.append(orjson.dumps(serialized))
@@ -260,23 +267,22 @@ class ArchiveStorage:
if jsonl_content:
jsonl_content += b"\n"
return gzip.compress(jsonl_content)
return jsonl_content
@staticmethod
def deserialize_from_jsonl_gz(data: bytes) -> list[dict[str, Any]]:
def deserialize_from_jsonl(data: bytes) -> list[dict[str, Any]]:
"""
Deserialize gzipped JSONL data to records.
Deserialize JSONL data to records.
Args:
data: Gzipped JSONL bytes
data: JSONL bytes
Returns:
List of dictionaries
"""
jsonl_content = gzip.decompress(data)
records = []
for line in jsonl_content.splitlines():
for line in data.splitlines():
if line:
records.append(orjson.loads(line))
@@ -0,0 +1,95 @@
"""create workflow_archive_logs
Revision ID: 9d77545f524e
Revises: 7df29de0f6be
Create Date: 2026-01-06 17:18:56.292479
"""
from alembic import op
import models as models
import sqlalchemy as sa
def _is_pg(conn):
return conn.dialect.name == "postgresql"
# revision identifiers, used by Alembic.
revision = '9d77545f524e'
down_revision = '7df29de0f6be'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
conn = op.get_bind()
if _is_pg(conn):
op.create_table('workflow_archive_logs',
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), nullable=False),
sa.Column('log_id', models.types.StringUUID(), nullable=True),
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
sa.Column('app_id', models.types.StringUUID(), nullable=False),
sa.Column('workflow_id', models.types.StringUUID(), nullable=False),
sa.Column('workflow_run_id', models.types.StringUUID(), nullable=False),
sa.Column('created_by_role', sa.String(length=255), nullable=False),
sa.Column('created_by', models.types.StringUUID(), nullable=False),
sa.Column('log_created_at', sa.DateTime(), nullable=True),
sa.Column('log_created_from', sa.String(length=255), nullable=True),
sa.Column('run_version', sa.String(length=255), nullable=False),
sa.Column('run_status', sa.String(length=255), nullable=False),
sa.Column('run_triggered_from', sa.String(length=255), nullable=False),
sa.Column('run_error', models.types.LongText(), nullable=True),
sa.Column('run_elapsed_time', sa.Float(), server_default=sa.text('0'), nullable=False),
sa.Column('run_total_tokens', sa.BigInteger(), server_default=sa.text('0'), nullable=False),
sa.Column('run_total_steps', sa.Integer(), server_default=sa.text('0'), nullable=True),
sa.Column('run_created_at', sa.DateTime(), nullable=False),
sa.Column('run_finished_at', sa.DateTime(), nullable=True),
sa.Column('run_exceptions_count', sa.Integer(), server_default=sa.text('0'), nullable=True),
sa.Column('trigger_metadata', models.types.LongText(), nullable=True),
sa.Column('archived_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id', name='workflow_archive_log_pkey')
)
else:
op.create_table('workflow_archive_logs',
sa.Column('id', models.types.StringUUID(), nullable=False),
sa.Column('log_id', models.types.StringUUID(), nullable=True),
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
sa.Column('app_id', models.types.StringUUID(), nullable=False),
sa.Column('workflow_id', models.types.StringUUID(), nullable=False),
sa.Column('workflow_run_id', models.types.StringUUID(), nullable=False),
sa.Column('created_by_role', sa.String(length=255), nullable=False),
sa.Column('created_by', models.types.StringUUID(), nullable=False),
sa.Column('log_created_at', sa.DateTime(), nullable=True),
sa.Column('log_created_from', sa.String(length=255), nullable=True),
sa.Column('run_version', sa.String(length=255), nullable=False),
sa.Column('run_status', sa.String(length=255), nullable=False),
sa.Column('run_triggered_from', sa.String(length=255), nullable=False),
sa.Column('run_error', models.types.LongText(), nullable=True),
sa.Column('run_elapsed_time', sa.Float(), server_default=sa.text('0'), nullable=False),
sa.Column('run_total_tokens', sa.BigInteger(), server_default=sa.text('0'), nullable=False),
sa.Column('run_total_steps', sa.Integer(), server_default=sa.text('0'), nullable=True),
sa.Column('run_created_at', sa.DateTime(), nullable=False),
sa.Column('run_finished_at', sa.DateTime(), nullable=True),
sa.Column('run_exceptions_count', sa.Integer(), server_default=sa.text('0'), nullable=True),
sa.Column('trigger_metadata', models.types.LongText(), nullable=True),
sa.Column('archived_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id', name='workflow_archive_log_pkey')
)
with op.batch_alter_table('workflow_archive_logs', schema=None) as batch_op:
batch_op.create_index('workflow_archive_log_app_idx', ['tenant_id', 'app_id'], unique=False)
batch_op.create_index('workflow_archive_log_run_created_at_idx', ['run_created_at'], unique=False)
batch_op.create_index('workflow_archive_log_workflow_run_id_idx', ['workflow_run_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('workflow_archive_logs', schema=None) as batch_op:
batch_op.drop_index('workflow_archive_log_workflow_run_id_idx')
batch_op.drop_index('workflow_archive_log_run_created_at_idx')
batch_op.drop_index('workflow_archive_log_app_idx')
op.drop_table('workflow_archive_logs')
# ### end Alembic commands ###
+2
View File
@@ -100,6 +100,7 @@ from .workflow import (
Workflow,
WorkflowAppLog,
WorkflowAppLogCreatedFrom,
WorkflowArchiveLog,
WorkflowNodeExecutionModel,
WorkflowNodeExecutionOffload,
WorkflowNodeExecutionTriggeredFrom,
@@ -197,6 +198,7 @@ __all__ = [
"Workflow",
"WorkflowAppLog",
"WorkflowAppLogCreatedFrom",
"WorkflowArchiveLog",
"WorkflowNodeExecutionModel",
"WorkflowNodeExecutionOffload",
"WorkflowNodeExecutionTriggeredFrom",
+63
View File
@@ -1163,6 +1163,69 @@ class WorkflowAppLog(TypeBase):
}
class WorkflowArchiveLog(TypeBase):
"""
Workflow archive log.
Stores essential workflow run snapshot data for archived app logs.
Field sources:
- Shared fields (tenant/app/workflow/run ids, created_by*): from WorkflowRun for consistency.
- log_* fields: from WorkflowAppLog when present; null if the run has no app log.
- run_* fields: workflow run snapshot fields from WorkflowRun.
- trigger_metadata: snapshot from WorkflowTriggerLog when present.
"""
__tablename__ = "workflow_archive_logs"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="workflow_archive_log_pkey"),
sa.Index("workflow_archive_log_app_idx", "tenant_id", "app_id"),
sa.Index("workflow_archive_log_workflow_run_id_idx", "workflow_run_id"),
sa.Index("workflow_archive_log_run_created_at_idx", "run_created_at"),
)
id: Mapped[str] = mapped_column(
StringUUID, insert_default=lambda: str(uuidv7()), default_factory=lambda: str(uuidv7()), init=False
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
workflow_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
workflow_run_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
created_by_role: Mapped[str] = mapped_column(String(255), nullable=False)
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
log_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
log_created_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
log_created_from: Mapped[str | None] = mapped_column(String(255), nullable=True)
run_version: Mapped[str] = mapped_column(String(255), nullable=False)
run_status: Mapped[str] = mapped_column(String(255), nullable=False)
run_triggered_from: Mapped[str] = mapped_column(String(255), nullable=False)
run_error: Mapped[str | None] = mapped_column(LongText, nullable=True)
run_elapsed_time: Mapped[float] = mapped_column(sa.Float, nullable=False, server_default=sa.text("0"))
run_total_tokens: Mapped[int] = mapped_column(sa.BigInteger, server_default=sa.text("0"))
run_total_steps: Mapped[int] = mapped_column(sa.Integer, server_default=sa.text("0"), nullable=True)
run_created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
run_finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
run_exceptions_count: Mapped[int] = mapped_column(sa.Integer, server_default=sa.text("0"), nullable=True)
trigger_metadata: Mapped[str | None] = mapped_column(LongText, nullable=True)
archived_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.current_timestamp(), init=False
)
@property
def workflow_run_summary(self) -> dict[str, Any]:
return {
"id": self.workflow_run_id,
"status": self.run_status,
"triggered_from": self.run_triggered_from,
"elapsed_time": self.run_elapsed_time,
"total_tokens": self.run_total_tokens,
}
class ConversationVariable(TypeBase):
__tablename__ = "workflow_conversation_variables"
@@ -45,7 +45,7 @@ from core.workflow.enums import WorkflowType
from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from models.enums import WorkflowRunTriggeredFrom
from models.workflow import WorkflowRun
from models.workflow import WorkflowAppLog, WorkflowArchiveLog, WorkflowPause, WorkflowPauseReason, WorkflowRun
from repositories.entities.workflow_pause import WorkflowPauseEntity
from repositories.types import (
AverageInteractionStats,
@@ -270,6 +270,38 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol):
"""
...
def get_archived_run_ids(
self,
session: Session,
run_ids: Sequence[str],
) -> set[str]:
"""
Fetch workflow run IDs that already have archive log records.
"""
...
def get_archived_logs_by_time_range(
self,
session: Session,
tenant_ids: Sequence[str] | None,
start_date: datetime,
end_date: datetime,
limit: int,
) -> Sequence[WorkflowArchiveLog]:
"""
Fetch archived workflow logs by time range for restore.
"""
...
def get_archived_log_by_run_id(
self,
run_id: str,
) -> WorkflowArchiveLog | None:
"""
Fetch a workflow archive log by workflow run ID.
"""
...
def delete_runs_with_related(
self,
runs: Sequence[WorkflowRun],
@@ -282,6 +314,61 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol):
"""
...
def get_pause_records_by_run_id(
self,
session: Session,
run_id: str,
) -> Sequence[WorkflowPause]:
"""
Fetch workflow pause records by workflow run ID.
"""
...
def get_pause_reason_records_by_run_id(
self,
session: Session,
pause_ids: Sequence[str],
) -> Sequence[WorkflowPauseReason]:
"""
Fetch workflow pause reason records by pause IDs.
"""
...
def get_app_logs_by_run_id(
self,
session: Session,
run_id: str,
) -> Sequence[WorkflowAppLog]:
"""
Fetch workflow app logs by workflow run ID.
"""
...
def create_archive_logs(
self,
session: Session,
run: WorkflowRun,
app_logs: Sequence[WorkflowAppLog],
trigger_metadata: str | None,
) -> int:
"""
Create archive log records for a workflow run.
"""
...
def get_archived_runs_by_time_range(
self,
session: Session,
tenant_ids: Sequence[str] | None,
start_date: datetime,
end_date: datetime,
limit: int,
) -> Sequence[WorkflowRun]:
"""
Return workflow runs that already have archive logs, for cleanup of `workflow_runs`.
"""
...
def count_runs_with_related(
self,
runs: Sequence[WorkflowRun],
@@ -351,3 +351,27 @@ class DifyAPISQLAlchemyWorkflowNodeExecutionRepository(DifyAPIWorkflowNodeExecut
)
return int(node_executions_count), int(offloads_count)
@staticmethod
def get_by_run(
session: Session,
run_id: str,
) -> Sequence[WorkflowNodeExecutionModel]:
"""
Fetch node executions for a run using workflow_run_id.
"""
stmt = select(WorkflowNodeExecutionModel).where(WorkflowNodeExecutionModel.workflow_run_id == run_id)
return list(session.scalars(stmt))
@staticmethod
def get_offloads_by_execution_ids(
session: Session,
node_execution_ids: Sequence[str],
) -> Sequence[WorkflowNodeExecutionOffload]:
if not node_execution_ids:
return []
stmt = select(WorkflowNodeExecutionOffload).where(
WorkflowNodeExecutionOffload.node_execution_id.in_(node_execution_ids)
)
return list(session.scalars(stmt))
@@ -40,14 +40,7 @@ from libs.infinite_scroll_pagination import InfiniteScrollPagination
from libs.time_parser import get_time_threshold
from libs.uuid_utils import uuidv7
from models.enums import WorkflowRunTriggeredFrom
from models.workflow import (
WorkflowAppLog,
WorkflowPauseReason,
WorkflowRun,
)
from models.workflow import (
WorkflowPause as WorkflowPauseModel,
)
from models.workflow import WorkflowAppLog, WorkflowArchiveLog, WorkflowPause, WorkflowPauseReason, WorkflowRun
from repositories.api_workflow_run_repository import APIWorkflowRunRepository
from repositories.entities.workflow_pause import WorkflowPauseEntity
from repositories.types import (
@@ -369,6 +362,44 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
return session.scalars(stmt).all()
def get_archived_run_ids(
self,
session: Session,
run_ids: Sequence[str],
) -> set[str]:
if not run_ids:
return set()
stmt = select(WorkflowArchiveLog.workflow_run_id).where(WorkflowArchiveLog.workflow_run_id.in_(run_ids))
return set(session.scalars(stmt).all())
def get_archived_log_by_run_id(
self,
run_id: str,
) -> WorkflowArchiveLog | None:
with self._session_maker() as session:
stmt = select(WorkflowArchiveLog).where(WorkflowArchiveLog.workflow_run_id == run_id).limit(1)
return session.scalar(stmt)
def get_pause_records_by_run_id(
self,
session: Session,
run_id: str,
) -> Sequence[WorkflowPause]:
stmt = select(WorkflowPause).where(WorkflowPause.workflow_run_id == run_id)
return list(session.scalars(stmt))
def get_pause_reason_records_by_run_id(
self,
session: Session,
pause_ids: Sequence[str],
) -> Sequence[WorkflowPauseReason]:
if not pause_ids:
return []
stmt = select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id.in_(pause_ids))
return list(session.scalars(stmt))
def delete_runs_with_related(
self,
runs: Sequence[WorkflowRun],
@@ -396,9 +427,8 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
app_logs_result = session.execute(delete(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(run_ids)))
app_logs_deleted = cast(CursorResult, app_logs_result).rowcount or 0
pause_ids = session.scalars(
select(WorkflowPauseModel.id).where(WorkflowPauseModel.workflow_run_id.in_(run_ids))
).all()
pause_stmt = select(WorkflowPause.id).where(WorkflowPause.workflow_run_id.in_(run_ids))
pause_ids = session.scalars(pause_stmt).all()
pause_reasons_deleted = 0
pauses_deleted = 0
@@ -407,7 +437,7 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
delete(WorkflowPauseReason).where(WorkflowPauseReason.pause_id.in_(pause_ids))
)
pause_reasons_deleted = cast(CursorResult, pause_reasons_result).rowcount or 0
pauses_result = session.execute(delete(WorkflowPauseModel).where(WorkflowPauseModel.id.in_(pause_ids)))
pauses_result = session.execute(delete(WorkflowPause).where(WorkflowPause.id.in_(pause_ids)))
pauses_deleted = cast(CursorResult, pauses_result).rowcount or 0
trigger_logs_deleted = delete_trigger_logs(session, run_ids) if delete_trigger_logs else 0
@@ -427,6 +457,124 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
"pause_reasons": pause_reasons_deleted,
}
def get_app_logs_by_run_id(
self,
session: Session,
run_id: str,
) -> Sequence[WorkflowAppLog]:
stmt = select(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id == run_id)
return list(session.scalars(stmt))
def create_archive_logs(
self,
session: Session,
run: WorkflowRun,
app_logs: Sequence[WorkflowAppLog],
trigger_metadata: str | None,
) -> int:
if not app_logs:
archive_log = WorkflowArchiveLog(
log_id=None,
log_created_at=None,
log_created_from=None,
tenant_id=run.tenant_id,
app_id=run.app_id,
workflow_id=run.workflow_id,
workflow_run_id=run.id,
created_by_role=run.created_by_role,
created_by=run.created_by,
run_version=run.version,
run_status=run.status,
run_triggered_from=run.triggered_from,
run_error=run.error,
run_elapsed_time=run.elapsed_time,
run_total_tokens=run.total_tokens,
run_total_steps=run.total_steps,
run_created_at=run.created_at,
run_finished_at=run.finished_at,
run_exceptions_count=run.exceptions_count,
trigger_metadata=trigger_metadata,
)
session.add(archive_log)
return 1
archive_logs = [
WorkflowArchiveLog(
log_id=app_log.id,
log_created_at=app_log.created_at,
log_created_from=app_log.created_from,
tenant_id=run.tenant_id,
app_id=run.app_id,
workflow_id=run.workflow_id,
workflow_run_id=run.id,
created_by_role=run.created_by_role,
created_by=run.created_by,
run_version=run.version,
run_status=run.status,
run_triggered_from=run.triggered_from,
run_error=run.error,
run_elapsed_time=run.elapsed_time,
run_total_tokens=run.total_tokens,
run_total_steps=run.total_steps,
run_created_at=run.created_at,
run_finished_at=run.finished_at,
run_exceptions_count=run.exceptions_count,
trigger_metadata=trigger_metadata,
)
for app_log in app_logs
]
session.add_all(archive_logs)
return len(archive_logs)
def get_archived_runs_by_time_range(
self,
session: Session,
tenant_ids: Sequence[str] | None,
start_date: datetime,
end_date: datetime,
limit: int,
) -> Sequence[WorkflowRun]:
"""
Retrieves WorkflowRun records by joining workflow_archive_logs.
Used to identify runs that are already archived and ready for deletion.
"""
stmt = (
select(WorkflowRun)
.join(WorkflowArchiveLog, WorkflowArchiveLog.workflow_run_id == WorkflowRun.id)
.where(
WorkflowArchiveLog.run_created_at >= start_date,
WorkflowArchiveLog.run_created_at < end_date,
)
.order_by(WorkflowArchiveLog.run_created_at.asc(), WorkflowArchiveLog.workflow_run_id.asc())
.limit(limit)
)
if tenant_ids:
stmt = stmt.where(WorkflowArchiveLog.tenant_id.in_(tenant_ids))
return list(session.scalars(stmt))
def get_archived_logs_by_time_range(
self,
session: Session,
tenant_ids: Sequence[str] | None,
start_date: datetime,
end_date: datetime,
limit: int,
) -> Sequence[WorkflowArchiveLog]:
# Returns WorkflowArchiveLog rows directly; use this when workflow_runs may be deleted.
stmt = (
select(WorkflowArchiveLog)
.where(
WorkflowArchiveLog.run_created_at >= start_date,
WorkflowArchiveLog.run_created_at < end_date,
)
.order_by(WorkflowArchiveLog.run_created_at.asc(), WorkflowArchiveLog.workflow_run_id.asc())
.limit(limit)
)
if tenant_ids:
stmt = stmt.where(WorkflowArchiveLog.tenant_id.in_(tenant_ids))
return list(session.scalars(stmt))
def count_runs_with_related(
self,
runs: Sequence[WorkflowRun],
@@ -459,7 +607,7 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
)
pause_ids = session.scalars(
select(WorkflowPauseModel.id).where(WorkflowPauseModel.workflow_run_id.in_(run_ids))
select(WorkflowPause.id).where(WorkflowPause.workflow_run_id.in_(run_ids))
).all()
pauses_count = len(pause_ids)
pause_reasons_count = 0
@@ -511,9 +659,7 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
ValueError: If workflow_run_id is invalid or workflow run doesn't exist
RuntimeError: If workflow is already paused or in invalid state
"""
previous_pause_model_query = select(WorkflowPauseModel).where(
WorkflowPauseModel.workflow_run_id == workflow_run_id
)
previous_pause_model_query = select(WorkflowPause).where(WorkflowPause.workflow_run_id == workflow_run_id)
with self._session_maker() as session, session.begin():
# Get the workflow run
workflow_run = session.get(WorkflowRun, workflow_run_id)
@@ -538,7 +684,7 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
# Upload the state file
# Create the pause record
pause_model = WorkflowPauseModel()
pause_model = WorkflowPause()
pause_model.id = str(uuidv7())
pause_model.workflow_id = workflow_run.workflow_id
pause_model.workflow_run_id = workflow_run.id
@@ -710,13 +856,13 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
"""
with self._session_maker() as session, session.begin():
# Get the pause model by ID
pause_model = session.get(WorkflowPauseModel, pause_entity.id)
pause_model = session.get(WorkflowPause, pause_entity.id)
if pause_model is None:
raise _WorkflowRunError(f"WorkflowPause not found: {pause_entity.id}")
self._delete_pause_model(session, pause_model)
@staticmethod
def _delete_pause_model(session: Session, pause_model: WorkflowPauseModel):
def _delete_pause_model(session: Session, pause_model: WorkflowPause):
storage.delete(pause_model.state_object_key)
# Delete the pause record
@@ -751,15 +897,15 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
_limit: int = limit or 1000
pruned_record_ids: list[str] = []
cond = or_(
WorkflowPauseModel.created_at < expiration,
WorkflowPause.created_at < expiration,
and_(
WorkflowPauseModel.resumed_at.is_not(null()),
WorkflowPauseModel.resumed_at < resumption_expiration,
WorkflowPause.resumed_at.is_not(null()),
WorkflowPause.resumed_at < resumption_expiration,
),
)
# First, collect pause records to delete with their state files
# Expired pauses (created before expiration time)
stmt = select(WorkflowPauseModel).where(cond).limit(_limit)
stmt = select(WorkflowPause).where(cond).limit(_limit)
with self._session_maker(expire_on_commit=False) as session:
# Old resumed pauses (resumed more than resumption_duration ago)
@@ -770,7 +916,7 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
# Delete state files from storage
for pause in pauses_to_delete:
with self._session_maker(expire_on_commit=False) as session, session.begin():
# todo: this issues a separate query for each WorkflowPauseModel record.
# todo: this issues a separate query for each WorkflowPause record.
# consider batching this lookup.
try:
storage.delete(pause.state_object_key)
@@ -1022,7 +1168,7 @@ class _PrivateWorkflowPauseEntity(WorkflowPauseEntity):
def __init__(
self,
*,
pause_model: WorkflowPauseModel,
pause_model: WorkflowPause,
reason_models: Sequence[WorkflowPauseReason],
human_input_form: Sequence = (),
) -> None:
@@ -46,6 +46,11 @@ class SQLAlchemyWorkflowTriggerLogRepository(WorkflowTriggerLogRepository):
return self.session.scalar(query)
def list_by_run_id(self, run_id: str) -> Sequence[WorkflowTriggerLog]:
"""List trigger logs for a workflow run."""
query = select(WorkflowTriggerLog).where(WorkflowTriggerLog.workflow_run_id == run_id)
return list(self.session.scalars(query).all())
def get_failed_for_retry(
self, tenant_id: str, max_retry_count: int = 3, limit: int = 100
) -> Sequence[WorkflowTriggerLog]:
+141 -1
View File
@@ -13,10 +13,11 @@ import sqlalchemy as sa
from redis.exceptions import LockNotOwnedError
from sqlalchemy import exists, func, select
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
from werkzeug.exceptions import Forbidden, NotFound
from configs import dify_config
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
from core.file import helpers as file_helpers
from core.helper.name_generator import generate_incremental_name
from core.model_manager import ModelManager
from core.model_runtime.entities.model_entities import ModelFeature, ModelType
@@ -73,6 +74,7 @@ from services.errors.document import DocumentIndexingError
from services.errors.file import FileNotExistsError
from services.external_knowledge_service import ExternalDatasetService
from services.feature_service import FeatureModel, FeatureService
from services.file_service import FileService
from services.rag_pipeline.rag_pipeline import RagPipelineService
from services.tag_service import TagService
from services.vector_service import VectorService
@@ -1162,6 +1164,7 @@ class DocumentService:
Document.archived.is_(True),
),
}
DOCUMENT_BATCH_DOWNLOAD_ZIP_FILENAME_EXTENSION = ".zip"
@classmethod
def normalize_display_status(cls, status: str | None) -> str | None:
@@ -1288,6 +1291,143 @@ class DocumentService:
else:
return None
@staticmethod
def get_documents_by_ids(dataset_id: str, document_ids: Sequence[str]) -> Sequence[Document]:
"""Fetch documents for a dataset in a single batch query."""
if not document_ids:
return []
document_id_list: list[str] = [str(document_id) for document_id in document_ids]
# Fetch all requested documents in one query to avoid N+1 lookups.
documents: Sequence[Document] = db.session.scalars(
select(Document).where(
Document.dataset_id == dataset_id,
Document.id.in_(document_id_list),
)
).all()
return documents
@staticmethod
def get_document_download_url(document: Document) -> str:
"""
Return a signed download URL for an upload-file document.
"""
upload_file = DocumentService._get_upload_file_for_upload_file_document(document)
return file_helpers.get_signed_file_url(upload_file_id=upload_file.id, as_attachment=True)
@staticmethod
def prepare_document_batch_download_zip(
*,
dataset_id: str,
document_ids: Sequence[str],
tenant_id: str,
current_user: Account,
) -> tuple[list[UploadFile], str]:
"""
Resolve upload files for batch ZIP downloads and generate a client-visible filename.
"""
dataset = DatasetService.get_dataset(dataset_id)
if not dataset:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user)
except NoPermissionError as e:
raise Forbidden(str(e))
upload_files_by_document_id = DocumentService._get_upload_files_by_document_id_for_zip_download(
dataset_id=dataset_id,
document_ids=document_ids,
tenant_id=tenant_id,
)
upload_files = [upload_files_by_document_id[document_id] for document_id in document_ids]
download_name = DocumentService._generate_document_batch_download_zip_filename()
return upload_files, download_name
@staticmethod
def _generate_document_batch_download_zip_filename() -> str:
"""
Generate a random attachment filename for the batch download ZIP.
"""
return f"{uuid.uuid4().hex}{DocumentService.DOCUMENT_BATCH_DOWNLOAD_ZIP_FILENAME_EXTENSION}"
@staticmethod
def _get_upload_file_id_for_upload_file_document(
document: Document,
*,
invalid_source_message: str,
missing_file_message: str,
) -> str:
"""
Normalize and validate `Document -> UploadFile` linkage for download flows.
"""
if document.data_source_type != "upload_file":
raise NotFound(invalid_source_message)
data_source_info: dict[str, Any] = document.data_source_info_dict or {}
upload_file_id: str | None = data_source_info.get("upload_file_id")
if not upload_file_id:
raise NotFound(missing_file_message)
return str(upload_file_id)
@staticmethod
def _get_upload_file_for_upload_file_document(document: Document) -> UploadFile:
"""
Load the `UploadFile` row for an upload-file document.
"""
upload_file_id = DocumentService._get_upload_file_id_for_upload_file_document(
document,
invalid_source_message="Document does not have an uploaded file to download.",
missing_file_message="Uploaded file not found.",
)
upload_files_by_id = FileService.get_upload_files_by_ids(document.tenant_id, [upload_file_id])
upload_file = upload_files_by_id.get(upload_file_id)
if not upload_file:
raise NotFound("Uploaded file not found.")
return upload_file
@staticmethod
def _get_upload_files_by_document_id_for_zip_download(
*,
dataset_id: str,
document_ids: Sequence[str],
tenant_id: str,
) -> dict[str, UploadFile]:
"""
Batch load upload files keyed by document id for ZIP downloads.
"""
document_id_list: list[str] = [str(document_id) for document_id in document_ids]
documents = DocumentService.get_documents_by_ids(dataset_id, document_id_list)
documents_by_id: dict[str, Document] = {str(document.id): document for document in documents}
missing_document_ids: set[str] = set(document_id_list) - set(documents_by_id.keys())
if missing_document_ids:
raise NotFound("Document not found.")
upload_file_ids: list[str] = []
upload_file_ids_by_document_id: dict[str, str] = {}
for document_id, document in documents_by_id.items():
if document.tenant_id != tenant_id:
raise Forbidden("No permission.")
upload_file_id = DocumentService._get_upload_file_id_for_upload_file_document(
document,
invalid_source_message="Only uploaded-file documents can be downloaded as ZIP.",
missing_file_message="Only uploaded-file documents can be downloaded as ZIP.",
)
upload_file_ids.append(upload_file_id)
upload_file_ids_by_document_id[document_id] = upload_file_id
upload_files_by_id = FileService.get_upload_files_by_ids(tenant_id, upload_file_ids)
missing_upload_file_ids: set[str] = set(upload_file_ids) - set(upload_files_by_id.keys())
if missing_upload_file_ids:
raise NotFound("Only uploaded-file documents can be downloaded as ZIP.")
return {
document_id: upload_files_by_id[upload_file_id]
for document_id, upload_file_id in upload_file_ids_by_document_id.items()
}
@staticmethod
def get_document_by_id(document_id: str) -> Document | None:
document = db.session.query(Document).where(Document.id == document_id).first()
+106
View File
@@ -2,7 +2,11 @@ import base64
import hashlib
import os
import uuid
from collections.abc import Iterator, Sequence
from contextlib import contextmanager, suppress
from tempfile import NamedTemporaryFile
from typing import Literal, Union
from zipfile import ZIP_DEFLATED, ZipFile
from sqlalchemy import Engine, select
from sqlalchemy.orm import Session, sessionmaker
@@ -17,6 +21,7 @@ from constants import (
)
from core.file import helpers as file_helpers
from core.rag.extractor.extract_processor import ExtractProcessor
from extensions.ext_database import db
from extensions.ext_storage import storage
from libs.datetime_utils import naive_utc_now
from libs.helper import extract_tenant_id
@@ -167,6 +172,9 @@ class FileService:
return upload_file
def get_file_preview(self, file_id: str):
"""
Return a short text preview extracted from a document file.
"""
with self._session_maker(expire_on_commit=False) as session:
upload_file = session.query(UploadFile).where(UploadFile.id == file_id).first()
@@ -253,3 +261,101 @@ class FileService:
return
storage.delete(upload_file.key)
session.delete(upload_file)
@staticmethod
def get_upload_files_by_ids(tenant_id: str, upload_file_ids: Sequence[str]) -> dict[str, UploadFile]:
"""
Fetch `UploadFile` rows for a tenant in a single batch query.
This is a generic `UploadFile` lookup helper (not dataset/document specific), so it lives in `FileService`.
"""
if not upload_file_ids:
return {}
# Normalize and deduplicate ids before using them in the IN clause.
upload_file_id_list: list[str] = [str(upload_file_id) for upload_file_id in upload_file_ids]
unique_upload_file_ids: list[str] = list(set(upload_file_id_list))
# Fetch upload files in one query for efficient batch access.
upload_files: Sequence[UploadFile] = db.session.scalars(
select(UploadFile).where(
UploadFile.tenant_id == tenant_id,
UploadFile.id.in_(unique_upload_file_ids),
)
).all()
return {str(upload_file.id): upload_file for upload_file in upload_files}
@staticmethod
def _sanitize_zip_entry_name(name: str) -> str:
"""
Sanitize a ZIP entry name to avoid path traversal and weird separators.
We keep this conservative: the upload flow already rejects `/` and `\\`, but older rows (or imported data)
could still contain unsafe names.
"""
# Drop any directory components and prevent empty names.
base = os.path.basename(name).strip() or "file"
# ZIP uses forward slashes as separators; remove any residual separator characters.
return base.replace("/", "_").replace("\\", "_")
@staticmethod
def _dedupe_zip_entry_name(original_name: str, used_names: set[str]) -> str:
"""
Return a unique ZIP entry name, inserting suffixes before the extension.
"""
# Keep the original name when it's not already used.
if original_name not in used_names:
return original_name
# Insert suffixes before the extension (e.g., "doc.txt" -> "doc (1).txt").
stem, extension = os.path.splitext(original_name)
suffix = 1
while True:
candidate = f"{stem} ({suffix}){extension}"
if candidate not in used_names:
return candidate
suffix += 1
@staticmethod
@contextmanager
def build_upload_files_zip_tempfile(
*,
upload_files: Sequence[UploadFile],
) -> Iterator[str]:
"""
Build a ZIP from `UploadFile`s and yield a tempfile path.
We yield a path (rather than an open file handle) to avoid "read of closed file" issues when Flask/Werkzeug
streams responses. The caller is expected to keep this context open until the response is fully sent, then
close it (e.g., via `response.call_on_close(...)`) to delete the tempfile.
"""
used_names: set[str] = set()
# Build a ZIP in a temp file and keep it on disk until the caller finishes streaming it.
tmp_path: str | None = None
try:
with NamedTemporaryFile(mode="w+b", suffix=".zip", delete=False) as tmp:
tmp_path = tmp.name
with ZipFile(tmp, mode="w", compression=ZIP_DEFLATED) as zf:
for upload_file in upload_files:
# Ensure the entry name is safe and unique.
safe_name = FileService._sanitize_zip_entry_name(upload_file.name)
arcname = FileService._dedupe_zip_entry_name(safe_name, used_names)
used_names.add(arcname)
# Stream file bytes from storage into the ZIP entry.
with zf.open(arcname, "w") as entry:
for chunk in storage.load(upload_file.key, stream=True):
entry.write(chunk)
# Flush so `send_file(path, ...)` can re-open it safely on all platforms.
tmp.flush()
assert tmp_path is not None
yield tmp_path
finally:
# Remove the temp file when the context is closed (typically after the response finishes streaming).
if tmp_path is not None:
with suppress(FileNotFoundError):
os.remove(tmp_path)
@@ -0,0 +1 @@
"""Workflow run retention services."""
@@ -0,0 +1,519 @@
"""
Archive Paid Plan Workflow Run Logs Service.
This service archives workflow run logs for paid plan users older than the configured
retention period (default: 90 days) to S3-compatible storage.
Archived tables:
- workflow_runs
- workflow_app_logs
- workflow_node_executions
- workflow_node_execution_offload
- workflow_pauses
- workflow_pause_reasons
- workflow_trigger_logs
"""
import datetime
import io
import json
import logging
import time
import zipfile
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from typing import Any
import click
from sqlalchemy import inspect
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from core.workflow.enums import WorkflowType
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from libs.archive_storage import (
ArchiveStorage,
ArchiveStorageNotConfiguredError,
get_archive_storage,
)
from models.workflow import WorkflowAppLog, WorkflowRun
from repositories.api_workflow_run_repository import APIWorkflowRunRepository
from repositories.sqlalchemy_api_workflow_node_execution_repository import (
DifyAPISQLAlchemyWorkflowNodeExecutionRepository,
)
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
from services.billing_service import BillingService
from services.retention.workflow_run.constants import ARCHIVE_BUNDLE_NAME, ARCHIVE_SCHEMA_VERSION
logger = logging.getLogger(__name__)
@dataclass
class TableStats:
"""Statistics for a single archived table."""
table_name: str
row_count: int
checksum: str
size_bytes: int
@dataclass
class ArchiveResult:
"""Result of archiving a single workflow run."""
run_id: str
tenant_id: str
success: bool
tables: list[TableStats] = field(default_factory=list)
error: str | None = None
elapsed_time: float = 0.0
@dataclass
class ArchiveSummary:
"""Summary of the entire archive operation."""
total_runs_processed: int = 0
runs_archived: int = 0
runs_skipped: int = 0
runs_failed: int = 0
total_elapsed_time: float = 0.0
class WorkflowRunArchiver:
"""
Archive workflow run logs for paid plan users.
Storage Layout:
{tenant_id}/app_id={app_id}/year={YYYY}/month={MM}/workflow_run_id={run_id}/
archive.v1.0.zip
manifest.json
workflow_runs.jsonl
workflow_app_logs.jsonl
workflow_node_executions.jsonl
workflow_node_execution_offload.jsonl
workflow_pauses.jsonl
workflow_pause_reasons.jsonl
workflow_trigger_logs.jsonl
"""
ARCHIVED_TYPE = [
WorkflowType.WORKFLOW,
WorkflowType.RAG_PIPELINE,
]
ARCHIVED_TABLES = [
"workflow_runs",
"workflow_app_logs",
"workflow_node_executions",
"workflow_node_execution_offload",
"workflow_pauses",
"workflow_pause_reasons",
"workflow_trigger_logs",
]
def __init__(
self,
days: int = 90,
batch_size: int = 100,
start_from: datetime.datetime | None = None,
end_before: datetime.datetime | None = None,
workers: int = 1,
tenant_ids: Sequence[str] | None = None,
limit: int | None = None,
dry_run: bool = False,
delete_after_archive: bool = False,
workflow_run_repo: APIWorkflowRunRepository | None = None,
):
"""
Initialize the archiver.
Args:
days: Archive runs older than this many days
batch_size: Number of runs to process per batch
start_from: Optional start time (inclusive) for archiving
end_before: Optional end time (exclusive) for archiving
workers: Number of concurrent workflow runs to archive
tenant_ids: Optional tenant IDs for grayscale rollout
limit: Maximum number of runs to archive (None for unlimited)
dry_run: If True, only preview without making changes
delete_after_archive: If True, delete runs and related data after archiving
"""
self.days = days
self.batch_size = batch_size
if start_from or end_before:
if start_from is None or end_before is None:
raise ValueError("start_from and end_before must be provided together")
if start_from >= end_before:
raise ValueError("start_from must be earlier than end_before")
self.start_from = start_from.replace(tzinfo=datetime.UTC)
self.end_before = end_before.replace(tzinfo=datetime.UTC)
else:
self.start_from = None
self.end_before = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=days)
if workers < 1:
raise ValueError("workers must be at least 1")
self.workers = workers
self.tenant_ids = sorted(set(tenant_ids)) if tenant_ids else []
self.limit = limit
self.dry_run = dry_run
self.delete_after_archive = delete_after_archive
self.workflow_run_repo = workflow_run_repo
def run(self) -> ArchiveSummary:
"""
Main archiving loop.
Returns:
ArchiveSummary with statistics about the operation
"""
summary = ArchiveSummary()
start_time = time.time()
click.echo(
click.style(
self._build_start_message(),
fg="white",
)
)
# Initialize archive storage (will raise if not configured)
try:
if not self.dry_run:
storage = get_archive_storage()
else:
storage = None
except ArchiveStorageNotConfiguredError as e:
click.echo(click.style(f"Archive storage not configured: {e}", fg="red"))
return summary
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
repo = self._get_workflow_run_repo()
def _archive_with_session(run: WorkflowRun) -> ArchiveResult:
with session_maker() as session:
return self._archive_run(session, storage, run)
last_seen: tuple[datetime.datetime, str] | None = None
archived_count = 0
with ThreadPoolExecutor(max_workers=self.workers) as executor:
while True:
# Check limit
if self.limit and archived_count >= self.limit:
click.echo(click.style(f"Reached limit of {self.limit} runs", fg="yellow"))
break
# Fetch batch of runs
runs = self._get_runs_batch(last_seen)
if not runs:
break
run_ids = [run.id for run in runs]
with session_maker() as session:
archived_run_ids = repo.get_archived_run_ids(session, run_ids)
last_seen = (runs[-1].created_at, runs[-1].id)
# Filter to paid tenants only
tenant_ids = {run.tenant_id for run in runs}
paid_tenants = self._filter_paid_tenants(tenant_ids)
runs_to_process: list[WorkflowRun] = []
for run in runs:
summary.total_runs_processed += 1
# Skip non-paid tenants
if run.tenant_id not in paid_tenants:
summary.runs_skipped += 1
continue
# Skip already archived runs
if run.id in archived_run_ids:
summary.runs_skipped += 1
continue
# Check limit
if self.limit and archived_count + len(runs_to_process) >= self.limit:
break
runs_to_process.append(run)
if not runs_to_process:
continue
results = list(executor.map(_archive_with_session, runs_to_process))
for run, result in zip(runs_to_process, results):
if result.success:
summary.runs_archived += 1
archived_count += 1
click.echo(
click.style(
f"{'[DRY RUN] Would archive' if self.dry_run else 'Archived'} "
f"run {run.id} (tenant={run.tenant_id}, "
f"tables={len(result.tables)}, time={result.elapsed_time:.2f}s)",
fg="green",
)
)
else:
summary.runs_failed += 1
click.echo(
click.style(
f"Failed to archive run {run.id}: {result.error}",
fg="red",
)
)
summary.total_elapsed_time = time.time() - start_time
click.echo(
click.style(
f"{'[DRY RUN] ' if self.dry_run else ''}Archive complete: "
f"processed={summary.total_runs_processed}, archived={summary.runs_archived}, "
f"skipped={summary.runs_skipped}, failed={summary.runs_failed}, "
f"time={summary.total_elapsed_time:.2f}s",
fg="white",
)
)
return summary
def _get_runs_batch(
self,
last_seen: tuple[datetime.datetime, str] | None,
) -> Sequence[WorkflowRun]:
"""Fetch a batch of workflow runs to archive."""
repo = self._get_workflow_run_repo()
return repo.get_runs_batch_by_time_range(
start_from=self.start_from,
end_before=self.end_before,
last_seen=last_seen,
batch_size=self.batch_size,
run_types=self.ARCHIVED_TYPE,
tenant_ids=self.tenant_ids or None,
)
def _build_start_message(self) -> str:
range_desc = f"before {self.end_before.isoformat()}"
if self.start_from:
range_desc = f"between {self.start_from.isoformat()} and {self.end_before.isoformat()}"
return (
f"{'[DRY RUN] ' if self.dry_run else ''}Starting workflow run archiving "
f"for runs {range_desc} "
f"(batch_size={self.batch_size}, tenant_ids={','.join(self.tenant_ids) or 'all'})"
)
def _filter_paid_tenants(self, tenant_ids: set[str]) -> set[str]:
"""Filter tenant IDs to only include paid tenants."""
if not dify_config.BILLING_ENABLED:
# If billing is not enabled, treat all tenants as paid
return tenant_ids
if not tenant_ids:
return set()
try:
bulk_info = BillingService.get_plan_bulk_with_cache(list(tenant_ids))
except Exception:
logger.exception("Failed to fetch billing plans for tenants")
# On error, skip all tenants in this batch
return set()
# Filter to paid tenants (any plan except SANDBOX)
paid = set()
for tid, info in bulk_info.items():
if info and info.get("plan") in (CloudPlan.PROFESSIONAL, CloudPlan.TEAM):
paid.add(tid)
return paid
def _archive_run(
self,
session: Session,
storage: ArchiveStorage | None,
run: WorkflowRun,
) -> ArchiveResult:
"""Archive a single workflow run."""
start_time = time.time()
result = ArchiveResult(run_id=run.id, tenant_id=run.tenant_id, success=False)
try:
# Extract data from all tables
table_data, app_logs, trigger_metadata = self._extract_data(session, run)
if self.dry_run:
# In dry run, just report what would be archived
for table_name in self.ARCHIVED_TABLES:
records = table_data.get(table_name, [])
result.tables.append(
TableStats(
table_name=table_name,
row_count=len(records),
checksum="",
size_bytes=0,
)
)
result.success = True
else:
if storage is None:
raise ArchiveStorageNotConfiguredError("Archive storage not configured")
archive_key = self._get_archive_key(run)
# Serialize tables for the archive bundle
table_stats: list[TableStats] = []
table_payloads: dict[str, bytes] = {}
for table_name in self.ARCHIVED_TABLES:
records = table_data.get(table_name, [])
data = ArchiveStorage.serialize_to_jsonl(records)
table_payloads[table_name] = data
checksum = ArchiveStorage.compute_checksum(data)
table_stats.append(
TableStats(
table_name=table_name,
row_count=len(records),
checksum=checksum,
size_bytes=len(data),
)
)
# Generate and upload archive bundle
manifest = self._generate_manifest(run, table_stats)
manifest_data = json.dumps(manifest, indent=2, default=str).encode("utf-8")
archive_data = self._build_archive_bundle(manifest_data, table_payloads)
storage.put_object(archive_key, archive_data)
repo = self._get_workflow_run_repo()
archived_log_count = repo.create_archive_logs(session, run, app_logs, trigger_metadata)
session.commit()
deleted_counts = None
if self.delete_after_archive:
deleted_counts = repo.delete_runs_with_related(
[run],
delete_node_executions=self._delete_node_executions,
delete_trigger_logs=self._delete_trigger_logs,
)
logger.info(
"Archived workflow run %s: tables=%s, archived_logs=%s, deleted=%s",
run.id,
{s.table_name: s.row_count for s in table_stats},
archived_log_count,
deleted_counts,
)
result.tables = table_stats
result.success = True
except Exception as e:
logger.exception("Failed to archive workflow run %s", run.id)
result.error = str(e)
session.rollback()
result.elapsed_time = time.time() - start_time
return result
def _extract_data(
self,
session: Session,
run: WorkflowRun,
) -> tuple[dict[str, list[dict[str, Any]]], Sequence[WorkflowAppLog], str | None]:
table_data: dict[str, list[dict[str, Any]]] = {}
table_data["workflow_runs"] = [self._row_to_dict(run)]
repo = self._get_workflow_run_repo()
app_logs = repo.get_app_logs_by_run_id(session, run.id)
table_data["workflow_app_logs"] = [self._row_to_dict(row) for row in app_logs]
node_exec_records = DifyAPISQLAlchemyWorkflowNodeExecutionRepository.get_by_run(session, run.id)
node_exec_ids = [record.id for record in node_exec_records]
offload_records = DifyAPISQLAlchemyWorkflowNodeExecutionRepository.get_offloads_by_execution_ids(
session,
node_exec_ids,
)
table_data["workflow_node_executions"] = [self._row_to_dict(row) for row in node_exec_records]
table_data["workflow_node_execution_offload"] = [self._row_to_dict(row) for row in offload_records]
repo = self._get_workflow_run_repo()
pause_records = repo.get_pause_records_by_run_id(session, run.id)
pause_ids = [pause.id for pause in pause_records]
pause_reason_records = repo.get_pause_reason_records_by_run_id(
session,
pause_ids,
)
table_data["workflow_pauses"] = [self._row_to_dict(row) for row in pause_records]
table_data["workflow_pause_reasons"] = [self._row_to_dict(row) for row in pause_reason_records]
trigger_repo = SQLAlchemyWorkflowTriggerLogRepository(session)
trigger_records = trigger_repo.list_by_run_id(run.id)
table_data["workflow_trigger_logs"] = [self._row_to_dict(row) for row in trigger_records]
trigger_metadata = trigger_records[0].trigger_metadata if trigger_records else None
return table_data, app_logs, trigger_metadata
@staticmethod
def _row_to_dict(row: Any) -> dict[str, Any]:
mapper = inspect(row).mapper
return {str(column.name): getattr(row, mapper.get_property_by_column(column).key) for column in mapper.columns}
def _get_archive_key(self, run: WorkflowRun) -> str:
"""Get the storage key for the archive bundle."""
created_at = run.created_at
prefix = (
f"{run.tenant_id}/app_id={run.app_id}/year={created_at.strftime('%Y')}/"
f"month={created_at.strftime('%m')}/workflow_run_id={run.id}"
)
return f"{prefix}/{ARCHIVE_BUNDLE_NAME}"
def _generate_manifest(
self,
run: WorkflowRun,
table_stats: list[TableStats],
) -> dict[str, Any]:
"""Generate a manifest for the archived workflow run."""
return {
"schema_version": ARCHIVE_SCHEMA_VERSION,
"workflow_run_id": run.id,
"tenant_id": run.tenant_id,
"app_id": run.app_id,
"workflow_id": run.workflow_id,
"created_at": run.created_at.isoformat(),
"archived_at": datetime.datetime.now(datetime.UTC).isoformat(),
"tables": {
stat.table_name: {
"row_count": stat.row_count,
"checksum": stat.checksum,
"size_bytes": stat.size_bytes,
}
for stat in table_stats
},
}
def _build_archive_bundle(self, manifest_data: bytes, table_payloads: dict[str, bytes]) -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr("manifest.json", manifest_data)
for table_name in self.ARCHIVED_TABLES:
data = table_payloads.get(table_name)
if data is None:
raise ValueError(f"Missing archive payload for {table_name}")
archive.writestr(f"{table_name}.jsonl", data)
return buffer.getvalue()
def _delete_trigger_logs(self, session: Session, run_ids: Sequence[str]) -> int:
trigger_repo = SQLAlchemyWorkflowTriggerLogRepository(session)
return trigger_repo.delete_by_run_ids(run_ids)
def _delete_node_executions(self, session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]:
run_ids = [run.id for run in runs]
return DifyAPISQLAlchemyWorkflowNodeExecutionRepository.delete_by_runs(session, run_ids)
def _get_workflow_run_repo(self) -> APIWorkflowRunRepository:
if self.workflow_run_repo is not None:
return self.workflow_run_repo
from repositories.factory import DifyAPIRepositoryFactory
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
self.workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker)
return self.workflow_run_repo
@@ -0,0 +1,2 @@
ARCHIVE_SCHEMA_VERSION = "1.0"
ARCHIVE_BUNDLE_NAME = f"archive.v{ARCHIVE_SCHEMA_VERSION}.zip"
@@ -0,0 +1,132 @@
"""
Delete Archived Workflow Run Service.
This service deletes archived workflow run data from the database while keeping
archive logs intact.
"""
import time
from collections.abc import Sequence
from dataclasses import dataclass, field
from datetime import datetime
from sqlalchemy.orm import Session, sessionmaker
from extensions.ext_database import db
from models.workflow import WorkflowRun
from repositories.api_workflow_run_repository import APIWorkflowRunRepository
from repositories.sqlalchemy_api_workflow_node_execution_repository import (
DifyAPISQLAlchemyWorkflowNodeExecutionRepository,
)
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
@dataclass
class DeleteResult:
run_id: str
tenant_id: str
success: bool
deleted_counts: dict[str, int] = field(default_factory=dict)
error: str | None = None
elapsed_time: float = 0.0
class ArchivedWorkflowRunDeletion:
def __init__(self, dry_run: bool = False):
self.dry_run = dry_run
self.workflow_run_repo: APIWorkflowRunRepository | None = None
def delete_by_run_id(self, run_id: str) -> DeleteResult:
start_time = time.time()
result = DeleteResult(run_id=run_id, tenant_id="", success=False)
repo = self._get_workflow_run_repo()
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
with session_maker() as session:
run = session.get(WorkflowRun, run_id)
if not run:
result.error = f"Workflow run {run_id} not found"
result.elapsed_time = time.time() - start_time
return result
result.tenant_id = run.tenant_id
if not repo.get_archived_run_ids(session, [run.id]):
result.error = f"Workflow run {run_id} is not archived"
result.elapsed_time = time.time() - start_time
return result
result = self._delete_run(run)
result.elapsed_time = time.time() - start_time
return result
def delete_batch(
self,
tenant_ids: list[str] | None,
start_date: datetime,
end_date: datetime,
limit: int = 100,
) -> list[DeleteResult]:
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
results: list[DeleteResult] = []
repo = self._get_workflow_run_repo()
with session_maker() as session:
runs = list(
repo.get_archived_runs_by_time_range(
session=session,
tenant_ids=tenant_ids,
start_date=start_date,
end_date=end_date,
limit=limit,
)
)
for run in runs:
results.append(self._delete_run(run))
return results
def _delete_run(self, run: WorkflowRun) -> DeleteResult:
start_time = time.time()
result = DeleteResult(run_id=run.id, tenant_id=run.tenant_id, success=False)
if self.dry_run:
result.success = True
result.elapsed_time = time.time() - start_time
return result
repo = self._get_workflow_run_repo()
try:
deleted_counts = repo.delete_runs_with_related(
[run],
delete_node_executions=self._delete_node_executions,
delete_trigger_logs=self._delete_trigger_logs,
)
result.deleted_counts = deleted_counts
result.success = True
except Exception as e:
result.error = str(e)
result.elapsed_time = time.time() - start_time
return result
@staticmethod
def _delete_trigger_logs(session: Session, run_ids: Sequence[str]) -> int:
trigger_repo = SQLAlchemyWorkflowTriggerLogRepository(session)
return trigger_repo.delete_by_run_ids(run_ids)
@staticmethod
def _delete_node_executions(
session: Session,
runs: Sequence[WorkflowRun],
) -> tuple[int, int]:
run_ids = [run.id for run in runs]
return DifyAPISQLAlchemyWorkflowNodeExecutionRepository.delete_by_runs(session, run_ids)
def _get_workflow_run_repo(self) -> APIWorkflowRunRepository:
if self.workflow_run_repo is not None:
return self.workflow_run_repo
from repositories.factory import DifyAPIRepositoryFactory
self.workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(
sessionmaker(bind=db.engine, expire_on_commit=False)
)
return self.workflow_run_repo
@@ -0,0 +1,476 @@
"""
Restore Archived Workflow Run Service.
This service restores archived workflow run data from S3-compatible storage
back to the database.
"""
import io
import json
import logging
import time
import zipfile
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import datetime
from typing import Any, cast
import click
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.engine import CursorResult
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from extensions.ext_database import db
from libs.archive_storage import (
ArchiveStorage,
ArchiveStorageNotConfiguredError,
get_archive_storage,
)
from models.trigger import WorkflowTriggerLog
from models.workflow import (
WorkflowAppLog,
WorkflowArchiveLog,
WorkflowNodeExecutionModel,
WorkflowNodeExecutionOffload,
WorkflowPause,
WorkflowPauseReason,
WorkflowRun,
)
from repositories.api_workflow_run_repository import APIWorkflowRunRepository
from repositories.factory import DifyAPIRepositoryFactory
from services.retention.workflow_run.constants import ARCHIVE_BUNDLE_NAME
logger = logging.getLogger(__name__)
# Mapping of table names to SQLAlchemy models
TABLE_MODELS = {
"workflow_runs": WorkflowRun,
"workflow_app_logs": WorkflowAppLog,
"workflow_node_executions": WorkflowNodeExecutionModel,
"workflow_node_execution_offload": WorkflowNodeExecutionOffload,
"workflow_pauses": WorkflowPause,
"workflow_pause_reasons": WorkflowPauseReason,
"workflow_trigger_logs": WorkflowTriggerLog,
}
SchemaMapper = Callable[[dict[str, Any]], dict[str, Any]]
SCHEMA_MAPPERS: dict[str, dict[str, SchemaMapper]] = {
"1.0": {},
}
@dataclass
class RestoreResult:
"""Result of restoring a single workflow run."""
run_id: str
tenant_id: str
success: bool
restored_counts: dict[str, int]
error: str | None = None
elapsed_time: float = 0.0
class WorkflowRunRestore:
"""
Restore archived workflow run data from storage to database.
This service reads archived data from storage and restores it to the
database tables. It handles idempotency by skipping records that already
exist in the database.
"""
def __init__(self, dry_run: bool = False, workers: int = 1):
"""
Initialize the restore service.
Args:
dry_run: If True, only preview without making changes
workers: Number of concurrent workflow runs to restore
"""
self.dry_run = dry_run
if workers < 1:
raise ValueError("workers must be at least 1")
self.workers = workers
self.workflow_run_repo: APIWorkflowRunRepository | None = None
def _restore_from_run(
self,
run: WorkflowRun | WorkflowArchiveLog,
*,
session_maker: sessionmaker,
) -> RestoreResult:
start_time = time.time()
run_id = run.workflow_run_id if isinstance(run, WorkflowArchiveLog) else run.id
created_at = run.run_created_at if isinstance(run, WorkflowArchiveLog) else run.created_at
result = RestoreResult(
run_id=run_id,
tenant_id=run.tenant_id,
success=False,
restored_counts={},
)
if not self.dry_run:
click.echo(
click.style(
f"Starting restore for workflow run {run_id} (tenant={run.tenant_id})",
fg="white",
)
)
try:
storage = get_archive_storage()
except ArchiveStorageNotConfiguredError as e:
result.error = str(e)
click.echo(click.style(f"Archive storage not configured: {e}", fg="red"))
result.elapsed_time = time.time() - start_time
return result
prefix = (
f"{run.tenant_id}/app_id={run.app_id}/year={created_at.strftime('%Y')}/"
f"month={created_at.strftime('%m')}/workflow_run_id={run_id}"
)
archive_key = f"{prefix}/{ARCHIVE_BUNDLE_NAME}"
try:
archive_data = storage.get_object(archive_key)
except FileNotFoundError:
result.error = f"Archive bundle not found: {archive_key}"
click.echo(click.style(result.error, fg="red"))
result.elapsed_time = time.time() - start_time
return result
with session_maker() as session:
try:
with zipfile.ZipFile(io.BytesIO(archive_data), mode="r") as archive:
try:
manifest = self._load_manifest_from_zip(archive)
except ValueError as e:
result.error = f"Archive bundle invalid: {e}"
click.echo(click.style(result.error, fg="red"))
return result
tables = manifest.get("tables", {})
schema_version = self._get_schema_version(manifest)
for table_name, info in tables.items():
row_count = info.get("row_count", 0)
if row_count == 0:
result.restored_counts[table_name] = 0
continue
if self.dry_run:
result.restored_counts[table_name] = row_count
continue
member_path = f"{table_name}.jsonl"
try:
data = archive.read(member_path)
except KeyError:
click.echo(
click.style(
f" Warning: Table data not found in archive: {member_path}",
fg="yellow",
)
)
result.restored_counts[table_name] = 0
continue
records = ArchiveStorage.deserialize_from_jsonl(data)
restored = self._restore_table_records(
session,
table_name,
records,
schema_version=schema_version,
)
result.restored_counts[table_name] = restored
if not self.dry_run:
click.echo(
click.style(
f" Restored {restored}/{len(records)} records to {table_name}",
fg="white",
)
)
# Verify row counts match manifest
manifest_total = sum(info.get("row_count", 0) for info in tables.values())
restored_total = sum(result.restored_counts.values())
if not self.dry_run:
# Note: restored count might be less than manifest count if records already exist
logger.info(
"Restore verification: manifest_total=%d, restored_total=%d",
manifest_total,
restored_total,
)
session.commit()
result.success = True
if not self.dry_run:
click.echo(
click.style(
f"Completed restore for workflow run {run_id}: restored={result.restored_counts}",
fg="green",
)
)
except Exception as e:
logger.exception("Failed to restore workflow run %s", run_id)
result.error = str(e)
session.rollback()
click.echo(click.style(f"Restore failed: {e}", fg="red"))
result.elapsed_time = time.time() - start_time
return result
def _get_workflow_run_repo(self) -> APIWorkflowRunRepository:
if self.workflow_run_repo is not None:
return self.workflow_run_repo
self.workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(
sessionmaker(bind=db.engine, expire_on_commit=False)
)
return self.workflow_run_repo
@staticmethod
def _load_manifest_from_zip(archive: zipfile.ZipFile) -> dict[str, Any]:
try:
data = archive.read("manifest.json")
except KeyError as e:
raise ValueError("manifest.json missing from archive bundle") from e
return json.loads(data.decode("utf-8"))
def _restore_table_records(
self,
session: Session,
table_name: str,
records: list[dict[str, Any]],
*,
schema_version: str,
) -> int:
"""
Restore records to a table.
Uses INSERT ... ON CONFLICT DO NOTHING for idempotency.
Args:
session: Database session
table_name: Name of the table
records: List of record dictionaries
schema_version: Archived schema version from manifest
Returns:
Number of records actually inserted
"""
if not records:
return 0
model = TABLE_MODELS.get(table_name)
if not model:
logger.warning("Unknown table: %s", table_name)
return 0
column_names, required_columns, non_nullable_with_default = self._get_model_column_info(model)
unknown_fields: set[str] = set()
# Apply schema mapping, filter to current columns, then convert datetimes
converted_records = []
for record in records:
mapped = self._apply_schema_mapping(table_name, schema_version, record)
unknown_fields.update(set(mapped.keys()) - column_names)
filtered = {key: value for key, value in mapped.items() if key in column_names}
for key in non_nullable_with_default:
if key in filtered and filtered[key] is None:
filtered.pop(key)
missing_required = [key for key in required_columns if key not in filtered or filtered.get(key) is None]
if missing_required:
missing_cols = ", ".join(sorted(missing_required))
raise ValueError(
f"Missing required columns for {table_name} (schema_version={schema_version}): {missing_cols}"
)
converted = self._convert_datetime_fields(filtered, model)
converted_records.append(converted)
if unknown_fields:
logger.warning(
"Dropped unknown columns for %s (schema_version=%s): %s",
table_name,
schema_version,
", ".join(sorted(unknown_fields)),
)
# Use INSERT ... ON CONFLICT DO NOTHING for idempotency
stmt = pg_insert(model).values(converted_records)
stmt = stmt.on_conflict_do_nothing(index_elements=["id"])
result = session.execute(stmt)
return cast(CursorResult, result).rowcount or 0
def _convert_datetime_fields(
self,
record: dict[str, Any],
model: type[DeclarativeBase] | Any,
) -> dict[str, Any]:
"""Convert ISO datetime strings to datetime objects."""
from sqlalchemy import DateTime
result = dict(record)
for column in model.__table__.columns:
if isinstance(column.type, DateTime):
value = result.get(column.key)
if isinstance(value, str):
try:
result[column.key] = datetime.fromisoformat(value)
except ValueError:
pass
return result
def _get_schema_version(self, manifest: dict[str, Any]) -> str:
schema_version = manifest.get("schema_version")
if not schema_version:
logger.warning("Manifest missing schema_version; defaulting to 1.0")
schema_version = "1.0"
schema_version = str(schema_version)
if schema_version not in SCHEMA_MAPPERS:
raise ValueError(f"Unsupported schema_version {schema_version}. Add a mapping before restoring.")
return schema_version
def _apply_schema_mapping(
self,
table_name: str,
schema_version: str,
record: dict[str, Any],
) -> dict[str, Any]:
# Keep hook for forward/backward compatibility when schema evolves.
mapper = SCHEMA_MAPPERS.get(schema_version, {}).get(table_name)
if mapper is None:
return dict(record)
return mapper(record)
def _get_model_column_info(
self,
model: type[DeclarativeBase] | Any,
) -> tuple[set[str], set[str], set[str]]:
columns = list(model.__table__.columns)
column_names = {column.key for column in columns}
required_columns = {
column.key
for column in columns
if not column.nullable
and column.default is None
and column.server_default is None
and not column.autoincrement
}
non_nullable_with_default = {
column.key
for column in columns
if not column.nullable
and (column.default is not None or column.server_default is not None or column.autoincrement)
}
return column_names, required_columns, non_nullable_with_default
def restore_batch(
self,
tenant_ids: list[str] | None,
start_date: datetime,
end_date: datetime,
limit: int = 100,
) -> list[RestoreResult]:
"""
Restore multiple workflow runs by time range.
Args:
tenant_ids: Optional tenant IDs
start_date: Start date filter
end_date: End date filter
limit: Maximum number of runs to restore (default: 100)
Returns:
List of RestoreResult objects
"""
results: list[RestoreResult] = []
if tenant_ids is not None and not tenant_ids:
return results
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
repo = self._get_workflow_run_repo()
with session_maker() as session:
archive_logs = repo.get_archived_logs_by_time_range(
session=session,
tenant_ids=tenant_ids,
start_date=start_date,
end_date=end_date,
limit=limit,
)
click.echo(
click.style(
f"Found {len(archive_logs)} archived workflow runs to restore",
fg="white",
)
)
def _restore_with_session(archive_log: WorkflowArchiveLog) -> RestoreResult:
return self._restore_from_run(
archive_log,
session_maker=session_maker,
)
with ThreadPoolExecutor(max_workers=self.workers) as executor:
results = list(executor.map(_restore_with_session, archive_logs))
total_counts: dict[str, int] = {}
for result in results:
for table_name, count in result.restored_counts.items():
total_counts[table_name] = total_counts.get(table_name, 0) + count
success_count = sum(1 for result in results if result.success)
if self.dry_run:
click.echo(
click.style(
f"[DRY RUN] Would restore {len(results)} workflow runs: totals={total_counts}",
fg="yellow",
)
)
else:
click.echo(
click.style(
f"Restored {success_count}/{len(results)} workflow runs: totals={total_counts}",
fg="green",
)
)
return results
def restore_by_run_id(
self,
run_id: str,
) -> RestoreResult:
"""
Restore a single workflow run by run ID.
"""
repo = self._get_workflow_run_repo()
archive_log = repo.get_archived_log_by_run_id(run_id)
if not archive_log:
click.echo(click.style(f"Workflow run archive {run_id} not found", fg="red"))
return RestoreResult(
run_id=run_id,
tenant_id="",
success=False,
restored_counts={},
error=f"Workflow run archive {run_id} not found",
)
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
result = self._restore_from_run(archive_log, session_maker=session_maker)
if self.dry_run and result.success:
click.echo(
click.style(
f"[DRY RUN] Would restore workflow run {run_id}: totals={result.restored_counts}",
fg="yellow",
)
)
return result
+75 -2
View File
@@ -7,7 +7,7 @@ from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
from core.workflow.enums import WorkflowExecutionStatus
from models import Account, App, EndUser, WorkflowAppLog, WorkflowRun
from models import Account, App, EndUser, WorkflowAppLog, WorkflowArchiveLog, WorkflowRun
from models.enums import AppTriggerType, CreatorUserRole
from models.trigger import WorkflowTriggerLog
from services.plugin.plugin_service import PluginService
@@ -173,7 +173,80 @@ class WorkflowAppService:
"data": items,
}
def handle_trigger_metadata(self, tenant_id: str, meta_val: str) -> dict[str, Any]:
def get_paginate_workflow_archive_logs(
self,
*,
session: Session,
app_model: App,
page: int = 1,
limit: int = 20,
):
"""
Get paginate workflow archive logs using SQLAlchemy 2.0 style.
"""
stmt = select(WorkflowArchiveLog).where(
WorkflowArchiveLog.tenant_id == app_model.tenant_id,
WorkflowArchiveLog.app_id == app_model.id,
WorkflowArchiveLog.log_id.isnot(None),
)
stmt = stmt.order_by(WorkflowArchiveLog.run_created_at.desc())
count_stmt = select(func.count()).select_from(stmt.subquery())
total = session.scalar(count_stmt) or 0
offset_stmt = stmt.offset((page - 1) * limit).limit(limit)
logs = list(session.scalars(offset_stmt).all())
account_ids = {log.created_by for log in logs if log.created_by_role == CreatorUserRole.ACCOUNT}
end_user_ids = {log.created_by for log in logs if log.created_by_role == CreatorUserRole.END_USER}
accounts_by_id = {}
if account_ids:
accounts_by_id = {
account.id: account
for account in session.scalars(select(Account).where(Account.id.in_(account_ids))).all()
}
end_users_by_id = {}
if end_user_ids:
end_users_by_id = {
end_user.id: end_user
for end_user in session.scalars(select(EndUser).where(EndUser.id.in_(end_user_ids))).all()
}
items = []
for log in logs:
if log.created_by_role == CreatorUserRole.ACCOUNT:
created_by_account = accounts_by_id.get(log.created_by)
created_by_end_user = None
elif log.created_by_role == CreatorUserRole.END_USER:
created_by_account = None
created_by_end_user = end_users_by_id.get(log.created_by)
else:
created_by_account = None
created_by_end_user = None
items.append(
{
"id": log.id,
"workflow_run": log.workflow_run_summary,
"trigger_metadata": self.handle_trigger_metadata(app_model.tenant_id, log.trigger_metadata),
"created_by_account": created_by_account,
"created_by_end_user": created_by_end_user,
"created_at": log.log_created_at,
}
)
return {
"page": page,
"limit": limit,
"total": total,
"has_more": total > page * limit,
"data": items,
}
def handle_trigger_metadata(self, tenant_id: str, meta_val: str | None) -> dict[str, Any]:
metadata: dict[str, Any] | None = self._safe_json_loads(meta_val)
if not metadata:
return {}
@@ -9,7 +9,9 @@ from sqlalchemy import delete
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import sessionmaker
from configs import dify_config
from extensions.ext_database import db
from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage
from models import (
ApiToken,
AppAnnotationHitHistory,
@@ -40,6 +42,7 @@ from models.workflow import (
ConversationVariable,
Workflow,
WorkflowAppLog,
WorkflowArchiveLog,
)
from repositories.factory import DifyAPIRepositoryFactory
@@ -64,6 +67,9 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str):
_delete_app_workflow_runs(tenant_id, app_id)
_delete_app_workflow_node_executions(tenant_id, app_id)
_delete_app_workflow_app_logs(tenant_id, app_id)
if dify_config.BILLING_ENABLED and dify_config.ARCHIVE_STORAGE_ENABLED:
_delete_app_workflow_archive_logs(tenant_id, app_id)
_delete_archived_workflow_run_files(tenant_id, app_id)
_delete_app_conversations(tenant_id, app_id)
_delete_app_messages(tenant_id, app_id)
_delete_workflow_tool_providers(tenant_id, app_id)
@@ -254,6 +260,45 @@ def _delete_app_workflow_app_logs(tenant_id: str, app_id: str):
)
def _delete_app_workflow_archive_logs(tenant_id: str, app_id: str):
def del_workflow_archive_log(workflow_archive_log_id: str):
db.session.query(WorkflowArchiveLog).where(WorkflowArchiveLog.id == workflow_archive_log_id).delete(
synchronize_session=False
)
_delete_records(
"""select id from workflow_archive_logs where tenant_id=:tenant_id and app_id=:app_id limit 1000""",
{"tenant_id": tenant_id, "app_id": app_id},
del_workflow_archive_log,
"workflow archive log",
)
def _delete_archived_workflow_run_files(tenant_id: str, app_id: str):
prefix = f"{tenant_id}/app_id={app_id}/"
try:
archive_storage = get_archive_storage()
except ArchiveStorageNotConfiguredError as e:
logger.info("Archive storage not configured, skipping archive file cleanup: %s", e)
return
try:
keys = archive_storage.list_objects(prefix)
except Exception:
logger.exception("Failed to list archive files for app %s", app_id)
return
deleted = 0
for key in keys:
try:
archive_storage.delete_object(key)
deleted += 1
except Exception:
logger.exception("Failed to delete archive object %s", key)
logger.info("Deleted %s archive objects for app %s", deleted, app_id)
def _delete_app_conversations(tenant_id: str, app_id: str):
def del_conversation(conversation_id: str):
db.session.query(PinnedConversation).where(PinnedConversation.conversation_id == conversation_id).delete(
@@ -1,254 +0,0 @@
"""
Unit tests for XSS prevention in App payloads.
This test module validates that HTML tags, JavaScript, and other potentially
dangerous content are rejected in App names and descriptions.
"""
import pytest
from controllers.console.app.app import CopyAppPayload, CreateAppPayload, UpdateAppPayload
class TestXSSPreventionUnit:
"""Unit tests for XSS prevention in App payloads."""
def test_create_app_valid_names(self):
"""Test CreateAppPayload with valid app names."""
# Normal app names should be valid
valid_names = [
"My App",
"Test App 123",
"App with - dash",
"App with _ underscore",
"App with + plus",
"App with () parentheses",
"App with [] brackets",
"App with {} braces",
"App with ! exclamation",
"App with @ at",
"App with # hash",
"App with $ dollar",
"App with % percent",
"App with ^ caret",
"App with & ampersand",
"App with * asterisk",
"Unicode: 测试应用",
"Emoji: 🤖",
"Mixed: Test 测试 123",
]
for name in valid_names:
payload = CreateAppPayload(
name=name,
mode="chat",
)
assert payload.name == name
def test_create_app_xss_script_tags(self):
"""Test CreateAppPayload rejects script tags."""
xss_payloads = [
"<script>alert(document.cookie)</script>",
"<Script>alert(1)</Script>",
"<SCRIPT>alert('XSS')</SCRIPT>",
"<script>alert(String.fromCharCode(88,83,83))</script>",
"<script src='evil.js'></script>",
"<script>document.location='http://evil.com'</script>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_iframe_tags(self):
"""Test CreateAppPayload rejects iframe tags."""
xss_payloads = [
"<iframe src='evil.com'></iframe>",
"<Iframe srcdoc='<script>alert(1)</script>'></iframe>",
"<IFRAME src='javascript:alert(1)'></iframe>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_javascript_protocol(self):
"""Test CreateAppPayload rejects javascript: protocol."""
xss_payloads = [
"javascript:alert(1)",
"JAVASCRIPT:alert(1)",
"JavaScript:alert(document.cookie)",
"javascript:void(0)",
"javascript://comment%0Aalert(1)",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_svg_onload(self):
"""Test CreateAppPayload rejects SVG with onload."""
xss_payloads = [
"<svg onload=alert(1)>",
"<SVG ONLOAD=alert(1)>",
"<svg/x/onload=alert(1)>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_event_handlers(self):
"""Test CreateAppPayload rejects HTML event handlers."""
xss_payloads = [
"<div onclick=alert(1)>",
"<img onerror=alert(1)>",
"<body onload=alert(1)>",
"<input onfocus=alert(1)>",
"<a onmouseover=alert(1)>",
"<DIV ONCLICK=alert(1)>",
"<img src=x onerror=alert(1)>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_object_embed(self):
"""Test CreateAppPayload rejects object and embed tags."""
xss_payloads = [
"<object data='evil.swf'></object>",
"<embed src='evil.swf'>",
"<OBJECT data='javascript:alert(1)'></OBJECT>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_link_javascript(self):
"""Test CreateAppPayload rejects link tags with javascript."""
xss_payloads = [
"<link href='javascript:alert(1)'>",
"<LINK HREF='javascript:alert(1)'>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_in_description(self):
"""Test CreateAppPayload rejects XSS in description."""
xss_descriptions = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"<img onerror=alert(1)>",
]
for description in xss_descriptions:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(
name="Valid Name",
mode="chat",
description=description,
)
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_valid_descriptions(self):
"""Test CreateAppPayload with valid descriptions."""
valid_descriptions = [
"A simple description",
"Description with < and > symbols",
"Description with & ampersand",
"Description with 'quotes' and \"double quotes\"",
"Description with / slashes",
"Description with \\ backslashes",
"Description with ; semicolons",
"Unicode: 这是一个描述",
"Emoji: 🎉🚀",
]
for description in valid_descriptions:
payload = CreateAppPayload(
name="Valid App Name",
mode="chat",
description=description,
)
assert payload.description == description
def test_create_app_none_description(self):
"""Test CreateAppPayload with None description."""
payload = CreateAppPayload(
name="Valid App Name",
mode="chat",
description=None,
)
assert payload.description is None
def test_update_app_xss_prevention(self):
"""Test UpdateAppPayload also prevents XSS."""
xss_names = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"<img onerror=alert(1)>",
]
for name in xss_names:
with pytest.raises(ValueError) as exc_info:
UpdateAppPayload(name=name)
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_update_app_valid_names(self):
"""Test UpdateAppPayload with valid names."""
payload = UpdateAppPayload(name="Valid Updated Name")
assert payload.name == "Valid Updated Name"
def test_copy_app_xss_prevention(self):
"""Test CopyAppPayload also prevents XSS."""
xss_names = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"<img onerror=alert(1)>",
]
for name in xss_names:
with pytest.raises(ValueError) as exc_info:
CopyAppPayload(name=name)
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_copy_app_valid_names(self):
"""Test CopyAppPayload with valid names."""
payload = CopyAppPayload(name="Valid Copy Name")
assert payload.name == "Valid Copy Name"
def test_copy_app_none_name(self):
"""Test CopyAppPayload with None name (should be allowed)."""
payload = CopyAppPayload(name=None)
assert payload.name is None
def test_edge_case_angle_brackets_content(self):
"""Test that angle brackets with actual content are rejected."""
# Angle brackets without valid HTML-like patterns should be checked
# The regex pattern <.*?on\w+\s*= should catch event handlers
# But let's verify other patterns too
# Valid: angle brackets used as symbols (not matched by our patterns)
# Our patterns specifically look for dangerous constructs
# Invalid: actual HTML tags with event handlers
invalid_names = [
"<div onclick=xss>",
"<img src=x onerror=alert(1)>",
]
for name in invalid_names:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
@@ -0,0 +1,430 @@
"""
Unit tests for the dataset document download endpoint.
These tests validate that the controller returns a signed download URL for
upload-file documents, and rejects unsupported or missing file cases.
"""
from __future__ import annotations
import importlib
import sys
from collections import UserDict
from io import BytesIO
from types import SimpleNamespace
from typing import Any
from zipfile import ZipFile
import pytest
from flask import Flask
from werkzeug.exceptions import Forbidden, NotFound
@pytest.fixture
def app() -> Flask:
"""Create a minimal Flask app for request-context based controller tests."""
app = Flask(__name__)
app.config["TESTING"] = True
return app
@pytest.fixture
def datasets_document_module(monkeypatch: pytest.MonkeyPatch):
"""
Reload `controllers.console.datasets.datasets_document` with lightweight decorators.
We patch auth / setup / rate-limit decorators to no-ops so we can unit test the
controller logic without requiring the full console stack.
"""
from controllers.console import console_ns, wraps
from libs import login
def _noop(func): # type: ignore[no-untyped-def]
return func
# Bypass login/setup/account checks in unit tests.
monkeypatch.setattr(login, "login_required", _noop)
monkeypatch.setattr(wraps, "setup_required", _noop)
monkeypatch.setattr(wraps, "account_initialization_required", _noop)
# Bypass billing-related decorators used by other endpoints in this module.
monkeypatch.setattr(wraps, "cloud_edition_billing_resource_check", lambda *_args, **_kwargs: (lambda f: f))
monkeypatch.setattr(wraps, "cloud_edition_billing_rate_limit_check", lambda *_args, **_kwargs: (lambda f: f))
# Avoid Flask-RESTX route registration side effects during import.
def _noop_route(*_args, **_kwargs): # type: ignore[override]
def _decorator(cls):
return cls
return _decorator
monkeypatch.setattr(console_ns, "route", _noop_route)
module_name = "controllers.console.datasets.datasets_document"
sys.modules.pop(module_name, None)
return importlib.import_module(module_name)
def _mock_user(*, is_dataset_editor: bool = True) -> SimpleNamespace:
"""Build a minimal user object compatible with dataset permission checks."""
return SimpleNamespace(is_dataset_editor=is_dataset_editor, id="user-123")
def _mock_document(
*,
document_id: str,
tenant_id: str,
data_source_type: str,
upload_file_id: str | None,
) -> SimpleNamespace:
"""Build a minimal document object used by the controller."""
data_source_info_dict: dict[str, Any] | None = None
if upload_file_id is not None:
data_source_info_dict = {"upload_file_id": upload_file_id}
else:
data_source_info_dict = {}
return SimpleNamespace(
id=document_id,
tenant_id=tenant_id,
data_source_type=data_source_type,
data_source_info_dict=data_source_info_dict,
)
def _wire_common_success_mocks(
*,
module,
monkeypatch: pytest.MonkeyPatch,
current_tenant_id: str,
document_tenant_id: str,
data_source_type: str,
upload_file_id: str | None,
upload_file_exists: bool,
signed_url: str,
) -> None:
"""Patch controller dependencies to create a deterministic test environment."""
import services.dataset_service as dataset_service_module
# Make `current_account_with_tenant()` return a known user + tenant id.
monkeypatch.setattr(module, "current_account_with_tenant", lambda: (_mock_user(), current_tenant_id))
# Return a dataset object and allow permission checks to pass.
monkeypatch.setattr(module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1"))
monkeypatch.setattr(module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None)
# Return a document that will be validated inside DocumentResource.get_document.
document = _mock_document(
document_id="doc-1",
tenant_id=document_tenant_id,
data_source_type=data_source_type,
upload_file_id=upload_file_id,
)
monkeypatch.setattr(module.DocumentService, "get_document", lambda *_args, **_kwargs: document)
# Mock UploadFile lookup via FileService batch helper.
upload_files_by_id: dict[str, Any] = {}
if upload_file_exists and upload_file_id is not None:
upload_files_by_id[str(upload_file_id)] = SimpleNamespace(id=str(upload_file_id))
monkeypatch.setattr(module.FileService, "get_upload_files_by_ids", lambda *_args, **_kwargs: upload_files_by_id)
# Mock signing helper so the returned URL is deterministic.
monkeypatch.setattr(dataset_service_module.file_helpers, "get_signed_file_url", lambda **_kwargs: signed_url)
def _mock_send_file(obj, **kwargs): # type: ignore[no-untyped-def]
"""Return a lightweight representation of `send_file(...)` for unit tests."""
class _ResponseMock(UserDict):
def __init__(self, sent_file: object, send_file_kwargs: dict[str, object]) -> None:
super().__init__({"_sent_file": sent_file, "_send_file_kwargs": send_file_kwargs})
self._on_close: object | None = None
def call_on_close(self, func): # type: ignore[no-untyped-def]
self._on_close = func
return func
return _ResponseMock(obj, kwargs)
def test_batch_download_zip_returns_send_file(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure batch ZIP download returns a zip attachment via `send_file`."""
# Arrange common permission mocks.
monkeypatch.setattr(datasets_document_module, "current_account_with_tenant", lambda: (_mock_user(), "tenant-123"))
monkeypatch.setattr(
datasets_document_module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1")
)
monkeypatch.setattr(
datasets_document_module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None
)
# Two upload-file documents, each referencing an UploadFile.
doc1 = _mock_document(
document_id="11111111-1111-1111-1111-111111111111",
tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id="file-1",
)
doc2 = _mock_document(
document_id="22222222-2222-2222-2222-222222222222",
tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id="file-2",
)
monkeypatch.setattr(
datasets_document_module.DocumentService,
"get_documents_by_ids",
lambda *_args, **_kwargs: [doc1, doc2],
)
monkeypatch.setattr(
datasets_document_module.FileService,
"get_upload_files_by_ids",
lambda *_args, **_kwargs: {
"file-1": SimpleNamespace(id="file-1", name="a.txt", key="k1"),
"file-2": SimpleNamespace(id="file-2", name="b.txt", key="k2"),
},
)
# Mock storage streaming content.
import services.file_service as file_service_module
monkeypatch.setattr(file_service_module.storage, "load", lambda _key, stream=True: [b"hello"])
# Replace send_file used by the controller to avoid a real Flask response object.
monkeypatch.setattr(datasets_document_module, "send_file", _mock_send_file)
# Act
with app.test_request_context(
"/datasets/ds-1/documents/download-zip",
method="POST",
json={"document_ids": ["11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"]},
):
api = datasets_document_module.DocumentBatchDownloadZipApi()
result = api.post(dataset_id="ds-1")
# Assert: we returned via send_file with correct mime type and attachment.
assert result["_send_file_kwargs"]["mimetype"] == "application/zip"
assert result["_send_file_kwargs"]["as_attachment"] is True
assert isinstance(result["_send_file_kwargs"]["download_name"], str)
assert result["_send_file_kwargs"]["download_name"].endswith(".zip")
# Ensure our cleanup hook is registered and execute it to avoid temp file leaks in unit tests.
assert getattr(result, "_on_close", None) is not None
result._on_close() # type: ignore[attr-defined]
def test_batch_download_zip_response_is_openable_zip(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure the real Flask `send_file` response body is a valid ZIP that can be opened."""
# Arrange: same controller mocks as the lightweight send_file test, but we keep the real `send_file`.
monkeypatch.setattr(datasets_document_module, "current_account_with_tenant", lambda: (_mock_user(), "tenant-123"))
monkeypatch.setattr(
datasets_document_module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1")
)
monkeypatch.setattr(
datasets_document_module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None
)
doc1 = _mock_document(
document_id="33333333-3333-3333-3333-333333333333",
tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id="file-1",
)
doc2 = _mock_document(
document_id="44444444-4444-4444-4444-444444444444",
tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id="file-2",
)
monkeypatch.setattr(
datasets_document_module.DocumentService,
"get_documents_by_ids",
lambda *_args, **_kwargs: [doc1, doc2],
)
monkeypatch.setattr(
datasets_document_module.FileService,
"get_upload_files_by_ids",
lambda *_args, **_kwargs: {
"file-1": SimpleNamespace(id="file-1", name="a.txt", key="k1"),
"file-2": SimpleNamespace(id="file-2", name="b.txt", key="k2"),
},
)
# Stream distinct bytes per key so we can verify both ZIP entries.
import services.file_service as file_service_module
monkeypatch.setattr(
file_service_module.storage, "load", lambda key, stream=True: [b"one"] if key == "k1" else [b"two"]
)
# Act
with app.test_request_context(
"/datasets/ds-1/documents/download-zip",
method="POST",
json={"document_ids": ["33333333-3333-3333-3333-333333333333", "44444444-4444-4444-4444-444444444444"]},
):
api = datasets_document_module.DocumentBatchDownloadZipApi()
response = api.post(dataset_id="ds-1")
# Assert: response body is a valid ZIP and contains the expected entries.
response.direct_passthrough = False
data = response.get_data()
response.close()
with ZipFile(BytesIO(data), mode="r") as zf:
assert zf.namelist() == ["a.txt", "b.txt"]
assert zf.read("a.txt") == b"one"
assert zf.read("b.txt") == b"two"
def test_batch_download_zip_rejects_non_upload_file_document(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure batch ZIP download rejects non upload-file documents."""
monkeypatch.setattr(datasets_document_module, "current_account_with_tenant", lambda: (_mock_user(), "tenant-123"))
monkeypatch.setattr(
datasets_document_module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1")
)
monkeypatch.setattr(
datasets_document_module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None
)
doc = _mock_document(
document_id="55555555-5555-5555-5555-555555555555",
tenant_id="tenant-123",
data_source_type="website_crawl",
upload_file_id="file-1",
)
monkeypatch.setattr(
datasets_document_module.DocumentService,
"get_documents_by_ids",
lambda *_args, **_kwargs: [doc],
)
with app.test_request_context(
"/datasets/ds-1/documents/download-zip",
method="POST",
json={"document_ids": ["55555555-5555-5555-5555-555555555555"]},
):
api = datasets_document_module.DocumentBatchDownloadZipApi()
with pytest.raises(NotFound):
api.post(dataset_id="ds-1")
def test_document_download_returns_url_for_upload_file_document(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure upload-file documents return a `{url}` JSON payload."""
_wire_common_success_mocks(
module=datasets_document_module,
monkeypatch=monkeypatch,
current_tenant_id="tenant-123",
document_tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id="file-123",
upload_file_exists=True,
signed_url="https://example.com/signed",
)
# Build a request context then call the resource method directly.
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
api = datasets_document_module.DocumentDownloadApi()
result = api.get(dataset_id="ds-1", document_id="doc-1")
assert result == {"url": "https://example.com/signed"}
def test_document_download_rejects_non_upload_file_document(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure non-upload documents raise 404 (no file to download)."""
_wire_common_success_mocks(
module=datasets_document_module,
monkeypatch=monkeypatch,
current_tenant_id="tenant-123",
document_tenant_id="tenant-123",
data_source_type="website_crawl",
upload_file_id="file-123",
upload_file_exists=True,
signed_url="https://example.com/signed",
)
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
api = datasets_document_module.DocumentDownloadApi()
with pytest.raises(NotFound):
api.get(dataset_id="ds-1", document_id="doc-1")
def test_document_download_rejects_missing_upload_file_id(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure missing `upload_file_id` raises 404."""
_wire_common_success_mocks(
module=datasets_document_module,
monkeypatch=monkeypatch,
current_tenant_id="tenant-123",
document_tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id=None,
upload_file_exists=False,
signed_url="https://example.com/signed",
)
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
api = datasets_document_module.DocumentDownloadApi()
with pytest.raises(NotFound):
api.get(dataset_id="ds-1", document_id="doc-1")
def test_document_download_rejects_when_upload_file_record_missing(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure missing UploadFile row raises 404."""
_wire_common_success_mocks(
module=datasets_document_module,
monkeypatch=monkeypatch,
current_tenant_id="tenant-123",
document_tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id="file-123",
upload_file_exists=False,
signed_url="https://example.com/signed",
)
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
api = datasets_document_module.DocumentDownloadApi()
with pytest.raises(NotFound):
api.get(dataset_id="ds-1", document_id="doc-1")
def test_document_download_rejects_tenant_mismatch(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure tenant mismatch is rejected by the shared `get_document()` permission check."""
_wire_common_success_mocks(
module=datasets_document_module,
monkeypatch=monkeypatch,
current_tenant_id="tenant-123",
document_tenant_id="tenant-999",
data_source_type="upload_file",
upload_file_id="file-123",
upload_file_exists=True,
signed_url="https://example.com/signed",
)
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
api = datasets_document_module.DocumentDownloadApi()
with pytest.raises(Forbidden):
api.get(dataset_id="ds-1", document_id="doc-1")
@@ -346,6 +346,7 @@ class TestPluginRuntimeErrorHandling:
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeRateLimitError",
"message": "Rate limit exceeded",
"args": {"description": "Rate limit exceeded"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})
@@ -364,6 +365,7 @@ class TestPluginRuntimeErrorHandling:
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeAuthorizationError",
"message": "Invalid credentials",
"args": {"description": "Invalid credentials"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})
@@ -382,6 +384,7 @@ class TestPluginRuntimeErrorHandling:
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeBadRequestError",
"message": "Invalid parameters",
"args": {"description": "Invalid parameters"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})
@@ -400,6 +403,7 @@ class TestPluginRuntimeErrorHandling:
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeConnectionError",
"message": "Connection to external service failed",
"args": {"description": "Connection to external service failed"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})
@@ -418,6 +422,7 @@ class TestPluginRuntimeErrorHandling:
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeServerUnavailableError",
"message": "Service temporarily unavailable",
"args": {"description": "Service temporarily unavailable"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})
@@ -30,3 +30,12 @@ class TestWorkflowExecutionStatus:
for status in non_ended_statuses:
assert not status.is_ended(), f"{status} should not be considered ended"
def test_ended_values(self):
"""Test ended_values returns the expected status values."""
assert set(WorkflowExecutionStatus.ended_values()) == {
WorkflowExecutionStatus.SUCCEEDED.value,
WorkflowExecutionStatus.FAILED.value,
WorkflowExecutionStatus.PARTIAL_SUCCEEDED.value,
WorkflowExecutionStatus.STOPPED.value,
}
@@ -37,6 +37,20 @@ def _client_error(code: str) -> ClientError:
def _mock_client(monkeypatch):
client = MagicMock()
client.head_bucket.return_value = None
# Configure put_object to return a proper ETag that matches the MD5 hash
# The ETag format is typically the MD5 hash wrapped in quotes
def mock_put_object(**kwargs):
md5_hash = kwargs.get("Body", b"")
if isinstance(md5_hash, bytes):
md5_hash = hashlib.md5(md5_hash).hexdigest()
else:
md5_hash = hashlib.md5(md5_hash.encode()).hexdigest()
response = MagicMock()
response.get.return_value = f'"{md5_hash}"'
return response
client.put_object.side_effect = mock_put_object
boto_client = MagicMock(return_value=client)
monkeypatch.setattr(storage_module.boto3, "client", boto_client)
return client, boto_client
@@ -254,8 +268,8 @@ def test_serialization_roundtrip():
{"id": "2", "value": 123},
]
data = ArchiveStorage.serialize_to_jsonl_gz(records)
decoded = ArchiveStorage.deserialize_from_jsonl_gz(data)
data = ArchiveStorage.serialize_to_jsonl(records)
decoded = ArchiveStorage.deserialize_from_jsonl(data)
assert decoded[0]["id"] == "1"
assert decoded[0]["payload"]["nested"] == "value"
@@ -6,6 +6,7 @@ from datetime import datetime
from typing import Any
from unittest.mock import MagicMock, Mock
import pytest
from sqlalchemy.orm import sessionmaker
from core.repositories.sqlalchemy_workflow_node_execution_repository import (
@@ -13,7 +14,15 @@ from core.repositories.sqlalchemy_workflow_node_execution_repository import (
)
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecution
from core.workflow.enums import NodeType
from models import Account, WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom
from models import (
Account,
WorkflowNodeExecutionModel,
WorkflowNodeExecutionTriggeredFrom,
WorkflowRunTriggeredFrom,
)
from repositories.sqlalchemy_api_workflow_node_execution_repository import (
DifyAPISQLAlchemyWorkflowNodeExecutionRepository,
)
class TestSQLAlchemyWorkflowNodeExecutionRepositoryProcessData:
@@ -104,3 +113,43 @@ class TestSQLAlchemyWorkflowNodeExecutionRepositoryProcessData:
# Should not be truncated
assert domain_model.process_data_truncated is False
assert domain_model.get_truncated_process_data() is None
class TestDifyAPISQLAlchemyWorkflowNodeExecutionRepositoryTriggeredFromMapping:
@pytest.mark.parametrize(
"run_triggered_from",
[
WorkflowRunTriggeredFrom.APP_RUN.value,
WorkflowRunTriggeredFrom.DEBUGGING.value,
WorkflowRunTriggeredFrom.SCHEDULE.value,
WorkflowRunTriggeredFrom.PLUGIN.value,
WorkflowRunTriggeredFrom.WEBHOOK.value,
],
)
def test_maps_workflow_run_group(self, run_triggered_from: str) -> None:
result = DifyAPISQLAlchemyWorkflowNodeExecutionRepository._map_run_triggered_from_to_node_triggered_from(
run_triggered_from
)
assert result == WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN.value
@pytest.mark.parametrize(
"run_triggered_from",
[
WorkflowRunTriggeredFrom.RAG_PIPELINE_RUN.value,
WorkflowRunTriggeredFrom.RAG_PIPELINE_DEBUGGING.value,
],
)
def test_maps_rag_pipeline_group(self, run_triggered_from: str) -> None:
result = DifyAPISQLAlchemyWorkflowNodeExecutionRepository._map_run_triggered_from_to_node_triggered_from(
run_triggered_from
)
assert result == WorkflowNodeExecutionTriggeredFrom.RAG_PIPELINE_RUN.value
def test_maps_unknown_value_to_empty_string(self) -> None:
result = DifyAPISQLAlchemyWorkflowNodeExecutionRepository._map_run_triggered_from_to_node_triggered_from(
"unknown-source"
)
assert result == ""
@@ -0,0 +1,54 @@
"""
Unit tests for workflow run archiving functionality.
This module contains tests for:
- Archive service
- Rollback service
"""
from datetime import datetime
from unittest.mock import MagicMock, patch
from services.retention.workflow_run.constants import ARCHIVE_BUNDLE_NAME
class TestWorkflowRunArchiver:
"""Tests for the WorkflowRunArchiver class."""
@patch("services.retention.workflow_run.archive_paid_plan_workflow_run.dify_config")
@patch("services.retention.workflow_run.archive_paid_plan_workflow_run.get_archive_storage")
def test_archiver_initialization(self, mock_get_storage, mock_config):
"""Test archiver can be initialized with various options."""
from services.retention.workflow_run.archive_paid_plan_workflow_run import WorkflowRunArchiver
mock_config.BILLING_ENABLED = False
archiver = WorkflowRunArchiver(
days=90,
batch_size=100,
tenant_ids=["test-tenant"],
limit=50,
dry_run=True,
)
assert archiver.days == 90
assert archiver.batch_size == 100
assert archiver.tenant_ids == ["test-tenant"]
assert archiver.limit == 50
assert archiver.dry_run is True
def test_get_archive_key(self):
"""Test archive key generation."""
from services.retention.workflow_run.archive_paid_plan_workflow_run import WorkflowRunArchiver
archiver = WorkflowRunArchiver.__new__(WorkflowRunArchiver)
mock_run = MagicMock()
mock_run.tenant_id = "tenant-123"
mock_run.app_id = "app-999"
mock_run.id = "run-456"
mock_run.created_at = datetime(2024, 1, 15, 12, 0, 0)
key = archiver._get_archive_key(mock_run)
assert key == f"tenant-123/app_id=app-999/year=2024/month=01/workflow_run_id=run-456/{ARCHIVE_BUNDLE_NAME}"
@@ -0,0 +1,180 @@
"""
Unit tests for archived workflow run deletion service.
"""
from unittest.mock import MagicMock, patch
class TestArchivedWorkflowRunDeletion:
def test_delete_by_run_id_returns_error_when_run_missing(self):
from services.retention.workflow_run.delete_archived_workflow_run import ArchivedWorkflowRunDeletion
deleter = ArchivedWorkflowRunDeletion()
repo = MagicMock()
session = MagicMock()
session.get.return_value = None
session_maker = MagicMock()
session_maker.return_value.__enter__.return_value = session
session_maker.return_value.__exit__.return_value = None
mock_db = MagicMock()
mock_db.engine = MagicMock()
with (
patch("services.retention.workflow_run.delete_archived_workflow_run.db", mock_db),
patch(
"services.retention.workflow_run.delete_archived_workflow_run.sessionmaker", return_value=session_maker
),
patch.object(deleter, "_get_workflow_run_repo", return_value=repo),
):
result = deleter.delete_by_run_id("run-1")
assert result.success is False
assert result.error == "Workflow run run-1 not found"
repo.get_archived_run_ids.assert_not_called()
def test_delete_by_run_id_returns_error_when_not_archived(self):
from services.retention.workflow_run.delete_archived_workflow_run import ArchivedWorkflowRunDeletion
deleter = ArchivedWorkflowRunDeletion()
repo = MagicMock()
repo.get_archived_run_ids.return_value = set()
run = MagicMock()
run.id = "run-1"
run.tenant_id = "tenant-1"
session = MagicMock()
session.get.return_value = run
session_maker = MagicMock()
session_maker.return_value.__enter__.return_value = session
session_maker.return_value.__exit__.return_value = None
mock_db = MagicMock()
mock_db.engine = MagicMock()
with (
patch("services.retention.workflow_run.delete_archived_workflow_run.db", mock_db),
patch(
"services.retention.workflow_run.delete_archived_workflow_run.sessionmaker", return_value=session_maker
),
patch.object(deleter, "_get_workflow_run_repo", return_value=repo),
patch.object(deleter, "_delete_run") as mock_delete_run,
):
result = deleter.delete_by_run_id("run-1")
assert result.success is False
assert result.error == "Workflow run run-1 is not archived"
mock_delete_run.assert_not_called()
def test_delete_by_run_id_calls_delete_run(self):
from services.retention.workflow_run.delete_archived_workflow_run import ArchivedWorkflowRunDeletion
deleter = ArchivedWorkflowRunDeletion()
repo = MagicMock()
repo.get_archived_run_ids.return_value = {"run-1"}
run = MagicMock()
run.id = "run-1"
run.tenant_id = "tenant-1"
session = MagicMock()
session.get.return_value = run
session_maker = MagicMock()
session_maker.return_value.__enter__.return_value = session
session_maker.return_value.__exit__.return_value = None
mock_db = MagicMock()
mock_db.engine = MagicMock()
with (
patch("services.retention.workflow_run.delete_archived_workflow_run.db", mock_db),
patch(
"services.retention.workflow_run.delete_archived_workflow_run.sessionmaker", return_value=session_maker
),
patch.object(deleter, "_get_workflow_run_repo", return_value=repo),
patch.object(deleter, "_delete_run", return_value=MagicMock(success=True)) as mock_delete_run,
):
result = deleter.delete_by_run_id("run-1")
assert result.success is True
mock_delete_run.assert_called_once_with(run)
def test_delete_batch_uses_repo(self):
from services.retention.workflow_run.delete_archived_workflow_run import ArchivedWorkflowRunDeletion
deleter = ArchivedWorkflowRunDeletion()
repo = MagicMock()
run1 = MagicMock()
run1.id = "run-1"
run1.tenant_id = "tenant-1"
run2 = MagicMock()
run2.id = "run-2"
run2.tenant_id = "tenant-1"
repo.get_archived_runs_by_time_range.return_value = [run1, run2]
session = MagicMock()
session_maker = MagicMock()
session_maker.return_value.__enter__.return_value = session
session_maker.return_value.__exit__.return_value = None
start_date = MagicMock()
end_date = MagicMock()
mock_db = MagicMock()
mock_db.engine = MagicMock()
with (
patch("services.retention.workflow_run.delete_archived_workflow_run.db", mock_db),
patch(
"services.retention.workflow_run.delete_archived_workflow_run.sessionmaker", return_value=session_maker
),
patch.object(deleter, "_get_workflow_run_repo", return_value=repo),
patch.object(
deleter, "_delete_run", side_effect=[MagicMock(success=True), MagicMock(success=True)]
) as mock_delete_run,
):
results = deleter.delete_batch(
tenant_ids=["tenant-1"],
start_date=start_date,
end_date=end_date,
limit=2,
)
assert len(results) == 2
repo.get_archived_runs_by_time_range.assert_called_once_with(
session=session,
tenant_ids=["tenant-1"],
start_date=start_date,
end_date=end_date,
limit=2,
)
assert mock_delete_run.call_count == 2
def test_delete_run_dry_run(self):
from services.retention.workflow_run.delete_archived_workflow_run import ArchivedWorkflowRunDeletion
deleter = ArchivedWorkflowRunDeletion(dry_run=True)
run = MagicMock()
run.id = "run-1"
run.tenant_id = "tenant-1"
with patch.object(deleter, "_get_workflow_run_repo") as mock_get_repo:
result = deleter._delete_run(run)
assert result.success is True
mock_get_repo.assert_not_called()
def test_delete_run_calls_repo(self):
from services.retention.workflow_run.delete_archived_workflow_run import ArchivedWorkflowRunDeletion
deleter = ArchivedWorkflowRunDeletion()
run = MagicMock()
run.id = "run-1"
run.tenant_id = "tenant-1"
repo = MagicMock()
repo.delete_runs_with_related.return_value = {"runs": 1}
with patch.object(deleter, "_get_workflow_run_repo", return_value=repo):
result = deleter._delete_run(run)
assert result.success is True
assert result.deleted_counts == {"runs": 1}
repo.delete_runs_with_related.assert_called_once()
@@ -0,0 +1,99 @@
"""
Unit tests for `services.file_service.FileService` helpers.
We keep these tests focused on:
- ZIP tempfile building (sanitization + deduplication + content writes)
- tenant-scoped batch lookup behavior (`get_upload_files_by_ids`)
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from zipfile import ZipFile
import pytest
import services.file_service as file_service_module
from services.file_service import FileService
def test_build_upload_files_zip_tempfile_sanitizes_and_dedupes_names(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ensure ZIP entry names are safe and unique while preserving extensions."""
# Arrange: three upload files that all sanitize down to the same basename ("b.txt").
upload_files: list[Any] = [
SimpleNamespace(name="a/b.txt", key="k1"),
SimpleNamespace(name="c/b.txt", key="k2"),
SimpleNamespace(name="../b.txt", key="k3"),
]
# Stream distinct bytes per key so we can verify content is written to the right entry.
data_by_key: dict[str, list[bytes]] = {"k1": [b"one"], "k2": [b"two"], "k3": [b"three"]}
def _load(key: str, stream: bool = True) -> list[bytes]:
# Return the corresponding chunks for this key (the production code iterates chunks).
assert stream is True
return data_by_key[key]
monkeypatch.setattr(file_service_module.storage, "load", _load)
# Act: build zip in a tempfile.
with FileService.build_upload_files_zip_tempfile(upload_files=upload_files) as tmp:
with ZipFile(tmp, mode="r") as zf:
# Assert: names are sanitized (no directory components) and deduped with suffixes.
assert zf.namelist() == ["b.txt", "b (1).txt", "b (2).txt"]
# Assert: each entry contains the correct bytes from storage.
assert zf.read("b.txt") == b"one"
assert zf.read("b (1).txt") == b"two"
assert zf.read("b (2).txt") == b"three"
def test_get_upload_files_by_ids_returns_empty_when_no_ids(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ensure empty input returns an empty mapping without hitting the database."""
class _Session:
def scalars(self, _stmt): # type: ignore[no-untyped-def]
raise AssertionError("db.session.scalars should not be called for empty id lists")
monkeypatch.setattr(file_service_module, "db", SimpleNamespace(session=_Session()))
assert FileService.get_upload_files_by_ids("tenant-1", []) == {}
def test_get_upload_files_by_ids_returns_id_keyed_mapping(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ensure batch lookup returns a dict keyed by stringified UploadFile ids."""
upload_files: list[Any] = [
SimpleNamespace(id="file-1", tenant_id="tenant-1"),
SimpleNamespace(id="file-2", tenant_id="tenant-1"),
]
class _ScalarResult:
def __init__(self, items: list[Any]) -> None:
self._items = items
def all(self) -> list[Any]:
return self._items
class _Session:
def __init__(self, items: list[Any]) -> None:
self._items = items
self.calls: list[object] = []
def scalars(self, stmt): # type: ignore[no-untyped-def]
# Capture the statement so we can at least assert the query path is taken.
self.calls.append(stmt)
return _ScalarResult(self._items)
session = _Session(upload_files)
monkeypatch.setattr(file_service_module, "db", SimpleNamespace(session=session))
# Provide duplicates to ensure callers can safely pass repeated ids.
result = FileService.get_upload_files_by_ids("tenant-1", ["file-1", "file-1", "file-2"])
assert set(result.keys()) == {"file-1", "file-2"}
assert result["file-1"].id == "file-1"
assert result["file-2"].id == "file-2"
assert len(session.calls) == 1
@@ -0,0 +1,65 @@
"""
Unit tests for workflow run restore functionality.
"""
from datetime import datetime
from unittest.mock import MagicMock
class TestWorkflowRunRestore:
"""Tests for the WorkflowRunRestore class."""
def test_restore_initialization(self):
"""Restore service should respect dry_run flag."""
from services.retention.workflow_run.restore_archived_workflow_run import WorkflowRunRestore
restore = WorkflowRunRestore(dry_run=True)
assert restore.dry_run is True
def test_convert_datetime_fields(self):
"""ISO datetime strings should be converted to datetime objects."""
from models.workflow import WorkflowRun
from services.retention.workflow_run.restore_archived_workflow_run import WorkflowRunRestore
record = {
"id": "test-id",
"created_at": "2024-01-01T12:00:00",
"finished_at": "2024-01-01T12:05:00",
"name": "test",
}
restore = WorkflowRunRestore()
result = restore._convert_datetime_fields(record, WorkflowRun)
assert isinstance(result["created_at"], datetime)
assert result["created_at"].year == 2024
assert result["created_at"].month == 1
assert result["name"] == "test"
def test_restore_table_records_returns_rowcount(self):
"""Restore should return inserted rowcount."""
from services.retention.workflow_run.restore_archived_workflow_run import WorkflowRunRestore
session = MagicMock()
session.execute.return_value = MagicMock(rowcount=2)
restore = WorkflowRunRestore()
records = [{"id": "p1", "workflow_run_id": "r1", "created_at": "2024-01-01T00:00:00"}]
restored = restore._restore_table_records(session, "workflow_pauses", records, schema_version="1.0")
assert restored == 2
session.execute.assert_called_once()
def test_restore_table_records_unknown_table(self):
"""Unknown table names should be ignored gracefully."""
from services.retention.workflow_run.restore_archived_workflow_run import WorkflowRunRestore
session = MagicMock()
restore = WorkflowRunRestore()
restored = restore._restore_table_records(session, "unknown_table", [{"id": "x1"}], schema_version="1.0")
assert restored == 0
session.execute.assert_not_called()
@@ -2,7 +2,11 @@ from unittest.mock import ANY, MagicMock, call, patch
import pytest
from libs.archive_storage import ArchiveStorageNotConfiguredError
from models.workflow import WorkflowArchiveLog
from tasks.remove_app_and_related_data_task import (
_delete_app_workflow_archive_logs,
_delete_archived_workflow_run_files,
_delete_draft_variable_offload_data,
_delete_draft_variables,
delete_draft_variables_batch,
@@ -335,3 +339,68 @@ class TestDeleteDraftVariableOffloadData:
# Verify error was logged
mock_logging.exception.assert_called_once_with("Error deleting draft variable offload data:")
class TestDeleteWorkflowArchiveLogs:
@patch("tasks.remove_app_and_related_data_task._delete_records")
@patch("tasks.remove_app_and_related_data_task.db")
def test_delete_app_workflow_archive_logs_calls_delete_records(self, mock_db, mock_delete_records):
tenant_id = "tenant-1"
app_id = "app-1"
_delete_app_workflow_archive_logs(tenant_id, app_id)
mock_delete_records.assert_called_once()
query_sql, params, delete_func, name = mock_delete_records.call_args[0]
assert "workflow_archive_logs" in query_sql
assert params == {"tenant_id": tenant_id, "app_id": app_id}
assert name == "workflow archive log"
mock_query = MagicMock()
mock_delete_query = MagicMock()
mock_query.where.return_value = mock_delete_query
mock_db.session.query.return_value = mock_query
delete_func("log-1")
mock_db.session.query.assert_called_once_with(WorkflowArchiveLog)
mock_query.where.assert_called_once()
mock_delete_query.delete.assert_called_once_with(synchronize_session=False)
class TestDeleteArchivedWorkflowRunFiles:
@patch("tasks.remove_app_and_related_data_task.get_archive_storage")
@patch("tasks.remove_app_and_related_data_task.logger")
def test_delete_archived_workflow_run_files_not_configured(self, mock_logger, mock_get_storage):
mock_get_storage.side_effect = ArchiveStorageNotConfiguredError("missing config")
_delete_archived_workflow_run_files("tenant-1", "app-1")
assert mock_logger.info.call_count == 1
assert "Archive storage not configured" in mock_logger.info.call_args[0][0]
@patch("tasks.remove_app_and_related_data_task.get_archive_storage")
@patch("tasks.remove_app_and_related_data_task.logger")
def test_delete_archived_workflow_run_files_list_failure(self, mock_logger, mock_get_storage):
storage = MagicMock()
storage.list_objects.side_effect = Exception("list failed")
mock_get_storage.return_value = storage
_delete_archived_workflow_run_files("tenant-1", "app-1")
storage.list_objects.assert_called_once_with("tenant-1/app_id=app-1/")
storage.delete_object.assert_not_called()
mock_logger.exception.assert_called_once_with("Failed to list archive files for app %s", "app-1")
@patch("tasks.remove_app_and_related_data_task.get_archive_storage")
@patch("tasks.remove_app_and_related_data_task.logger")
def test_delete_archived_workflow_run_files_success(self, mock_logger, mock_get_storage):
storage = MagicMock()
storage.list_objects.return_value = ["key-1", "key-2"]
mock_get_storage.return_value = storage
_delete_archived_workflow_run_files("tenant-1", "app-1")
storage.list_objects.assert_called_once_with("tenant-1/app_id=app-1/")
storage.delete_object.assert_has_calls([call("key-1"), call("key-2")], any_order=False)
mock_logger.info.assert_called_with("Deleted %s archive objects for app %s", 2, "app-1")
+1
View File
@@ -0,0 +1 @@
save-exact=true
+3 -1
View File
@@ -1,6 +1,8 @@
{
"recommendations": [
"bradlc.vscode-tailwindcss",
"kisstkondoros.vscode-codemetrics"
"kisstkondoros.vscode-codemetrics",
"johnsoncodehk.vscode-tsslint",
"dbaeumer.vscode-eslint"
]
}
+1 -1
View File
@@ -203,7 +203,7 @@ const Annotation: FC<Props> = (props) => {
</Filter>
{isLoading
? <Loading type="app" />
// eslint-disable-next-line sonarjs/no-nested-conditional
: total > 0
? (
<List
@@ -134,7 +134,6 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
},
] as const
// eslint-disable-next-line sonarjs/no-nested-template-literals, sonarjs/no-nested-conditional
const [instructionFromSessionStorage, setInstruction] = useSessionStorageState<string>(`improve-instruction-${flowId}${isBasicMode ? '' : `-${nodeId}${editorId ? `-${editorId}` : ''}`}`)
const instruction = instructionFromSessionStorage || ''
const [ideaOutput, setIdeaOutput] = useState<string>('')
@@ -175,7 +175,7 @@ describe('SettingsModal', () => {
renderSettingsModal()
fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry'))
const privacyInput = screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')
// eslint-disable-next-line sonarjs/no-clear-text-protocols
fireEvent.change(privacyInput, { target: { value: 'ftp://invalid-url' } })
fireEvent.click(screen.getByText('common.operation.save'))
@@ -1,4 +1,4 @@
import type { FC } from 'react'
import type { FC, MouseEvent } from 'react'
import type { Resources } from './index'
import Link from 'next/link'
import { Fragment, useState } from 'react'
@@ -18,6 +18,8 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useDocumentDownload } from '@/service/knowledge/use-document'
import { downloadUrl } from '@/utils/download'
import ProgressTooltip from './progress-tooltip'
import Tooltip from './tooltip'
@@ -36,6 +38,30 @@ const Popup: FC<PopupProps> = ({
? (/\.([^.]*)$/.exec(data.documentName)?.[1] || '')
: 'notion'
const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload()
/**
* Download the original uploaded file for citations whose data source is upload-file.
* We request a signed URL from the dataset document download endpoint, then trigger browser download.
*/
const handleDownloadUploadFile = async (e: MouseEvent<HTMLElement>) => {
// Prevent toggling the citation popup when user clicks the download link.
e.preventDefault()
e.stopPropagation()
// Only upload-file citations can be downloaded this way (needs dataset/document ids).
const isUploadFile = data.dataSourceType === 'upload_file' || data.dataSourceType === 'file'
const datasetId = data.sources?.[0]?.dataset_id
const documentId = data.documentId || data.sources?.[0]?.document_id
if (!isUploadFile || !datasetId || !documentId || isDownloading)
return
// Fetch signed URL (usually points to `/files/<id>/file-preview?...&as_attachment=true`).
const res = await downloadDocument({ datasetId, documentId })
if (res?.url)
downloadUrl({ url: res.url, fileName: data.documentName })
}
return (
<PortalToFollowElem
open={open}
@@ -49,6 +75,7 @@ const Popup: FC<PopupProps> = ({
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
{/* Keep the trigger purely for opening the popup (no download link here). */}
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
</div>
</PortalToFollowElemTrigger>
@@ -57,7 +84,21 @@ const Popup: FC<PopupProps> = ({
<div className="px-4 pb-2 pt-3">
<div className="flex h-[18px] items-center">
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
<div className="system-xs-medium truncate text-text-tertiary">{data.documentName}</div>
<div className="system-xs-medium truncate text-text-tertiary">
{/* If it's an upload-file reference, the title becomes a download link. */}
{(data.dataSourceType === 'upload_file' || data.dataSourceType === 'file') && !!data.sources?.[0]?.dataset_id
? (
<button
type="button"
className="cursor-pointer truncate text-text-tertiary hover:underline"
onClick={handleDownloadUploadFile}
disabled={isDownloading}
>
{data.documentName}
</button>
)
: data.documentName}
</div>
</div>
</div>
<div className="max-h-[450px] overflow-y-auto rounded-lg bg-components-panel-bg px-4 py-0.5">
@@ -205,7 +205,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
}
catch {
try {
// eslint-disable-next-line no-new-func, sonarjs/code-eval
// eslint-disable-next-line no-new-func
const result = new Function(`return ${trimmedContent}`)()
if (typeof result === 'object' && result !== null) {
setFinalChartOption(result)
@@ -250,7 +250,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
}
catch {
try {
// eslint-disable-next-line no-new-func, sonarjs/code-eval
// eslint-disable-next-line no-new-func
const result = new Function(`return ${trimmedContent}`)()
if (typeof result === 'object' && result !== null) {
setFinalChartOption(result)
@@ -30,9 +30,10 @@ import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '
import useTimestamp from '@/hooks/use-timestamp'
import { ChunkingMode, DataSourceType, DocumentActionType } from '@/models/datasets'
import { DatasourceType } from '@/models/pipeline'
import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useDocumentDisable, useDocumentEnable } from '@/service/knowledge/use-document'
import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useDocumentDisable, useDocumentDownloadZip, useDocumentEnable } from '@/service/knowledge/use-document'
import { asyncRunSafe } from '@/utils'
import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
import { formatNumber } from '@/utils/format'
import BatchAction from '../detail/completed/common/batch-action'
import StatusItem from '../status-item'
@@ -222,6 +223,7 @@ const DocumentList: FC<IDocumentListProps> = ({
const { mutateAsync: disableDocument } = useDocumentDisable()
const { mutateAsync: deleteDocument } = useDocumentDelete()
const { mutateAsync: retryIndexDocument } = useDocumentBatchRetryIndex()
const { mutateAsync: requestDocumentsZip, isPending: isDownloadingZip } = useDocumentDownloadZip()
const handleAction = (actionName: DocumentActionType) => {
return async () => {
@@ -300,6 +302,39 @@ const DocumentList: FC<IDocumentListProps> = ({
return dataSourceType === DatasourceType.onlineDrive
}, [])
const downloadableSelectedIds = useMemo(() => {
const selectedSet = new Set(selectedIds)
return localDocs
.filter(doc => selectedSet.has(doc.id) && doc.data_source_type === DataSourceType.FILE)
.map(doc => doc.id)
}, [localDocs, selectedIds])
/**
* Generate a random ZIP filename for bulk document downloads.
* We intentionally avoid leaking dataset info in the exported archive name.
*/
const generateDocsZipFileName = useCallback((): string => {
// Prefer UUID for uniqueness; fall back to time+random when unavailable.
const randomPart = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
? crypto.randomUUID()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
return `${randomPart}-docs.zip`
}, [])
const handleBatchDownload = useCallback(async () => {
if (isDownloadingZip)
return
// Download as a single ZIP to avoid browser caps on multiple automatic downloads.
const [e, blob] = await asyncRunSafe(requestDocumentsZip({ datasetId, documentIds: downloadableSelectedIds }))
if (e || !blob) {
Toast.notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) })
return
}
downloadBlob({ data: blob, fileName: generateDocsZipFileName() })
}, [datasetId, downloadableSelectedIds, generateDocsZipFileName, isDownloadingZip, requestDocumentsZip, t])
return (
<div className="relative mt-3 flex h-full w-full flex-col">
<div className="relative h-0 grow overflow-x-auto">
@@ -463,6 +498,7 @@ const DocumentList: FC<IDocumentListProps> = ({
onArchive={handleAction(DocumentActionType.archive)}
onBatchEnable={handleAction(DocumentActionType.enable)}
onBatchDisable={handleAction(DocumentActionType.disable)}
onBatchDownload={downloadableSelectedIds.length > 0 ? handleBatchDownload : undefined}
onBatchDelete={handleAction(DocumentActionType.delete)}
onEditMetadata={showEditModal}
onBatchReIndex={hasErrorDocumentsSelected ? handleBatchReIndex : undefined}
@@ -1,8 +1,10 @@
import type { OperationName } from '../types'
import type { CommonResponse } from '@/models/common'
import type { DocumentDownloadResponse } from '@/service/datasets'
import {
RiArchive2Line,
RiDeleteBinLine,
RiDownload2Line,
RiEditLine,
RiEqualizer2Line,
RiLoopLeftLine,
@@ -28,6 +30,7 @@ import {
useDocumentArchive,
useDocumentDelete,
useDocumentDisable,
useDocumentDownload,
useDocumentEnable,
useDocumentPause,
useDocumentResume,
@@ -37,6 +40,7 @@ import {
} from '@/service/knowledge/use-document'
import { asyncRunSafe } from '@/utils'
import { cn } from '@/utils/classnames'
import { downloadUrl } from '@/utils/download'
import s from '../style.module.css'
import RenameModal from './rename-modal'
@@ -69,7 +73,7 @@ const Operations = ({
scene = 'list',
className = '',
}: OperationsProps) => {
const { id, enabled = false, archived = false, data_source_type, display_status } = detail || {}
const { id, name, enabled = false, archived = false, data_source_type, display_status } = detail || {}
const [showModal, setShowModal] = useState(false)
const [deleting, setDeleting] = useState(false)
const { notify } = useContext(ToastContext)
@@ -80,6 +84,7 @@ const Operations = ({
const { mutateAsync: enableDocument } = useDocumentEnable()
const { mutateAsync: disableDocument } = useDocumentDisable()
const { mutateAsync: deleteDocument } = useDocumentDelete()
const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload()
const { mutateAsync: syncDocument } = useSyncDocument()
const { mutateAsync: syncWebsite } = useSyncWebsite()
const { mutateAsync: pauseDocument } = useDocumentPause()
@@ -158,6 +163,24 @@ const Operations = ({
onUpdate()
}, [onUpdate])
const handleDownload = useCallback(async () => {
// Avoid repeated clicks while the signed URL request is in-flight.
if (isDownloading)
return
// Request a signed URL first (it points to `/files/<id>/file-preview?...&as_attachment=true`).
const [e, res] = await asyncRunSafe<DocumentDownloadResponse>(
downloadDocument({ datasetId, documentId: id }) as Promise<DocumentDownloadResponse>,
)
if (e || !res?.url) {
notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) })
return
}
// Trigger download without navigating away (helps avoid duplicate downloads in some browsers).
downloadUrl({ url: res.url, fileName: name })
}, [datasetId, downloadDocument, id, isDownloading, name, notify, t])
return (
<div className="flex items-center" onClick={e => e.stopPropagation()}>
{isListScene && !embeddingAvailable && (
@@ -214,6 +237,20 @@ const Operations = ({
<RiEditLine className="h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.table.rename', { ns: 'datasetDocuments' })}</span>
</div>
{data_source_type === DataSourceType.FILE && (
<div
className={s.actionItem}
onClick={(evt) => {
evt.preventDefault()
evt.stopPropagation()
evt.nativeEvent.stopImmediatePropagation?.()
handleDownload()
}}
>
<RiDownload2Line className="h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
</div>
)}
{['notion_import', DataSourceType.WEB].includes(data_source_type) && (
<div className={s.actionItem} onClick={() => onOperate('sync')}>
<RiLoopLeftLine className="h-4 w-4 text-text-tertiary" />
@@ -223,6 +260,23 @@ const Operations = ({
<Divider className="my-1" />
</>
)}
{archived && data_source_type === DataSourceType.FILE && (
<>
<div
className={s.actionItem}
onClick={(evt) => {
evt.preventDefault()
evt.stopPropagation()
evt.nativeEvent.stopImmediatePropagation?.()
handleDownload()
}}
>
<RiDownload2Line className="h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
</div>
<Divider className="my-1" />
</>
)}
{!archived && display_status?.toLowerCase() === 'indexing' && (
<div className={s.actionItem} onClick={() => onOperate('pause')}>
<RiPauseCircleLine className="h-4 w-4 text-text-tertiary" />
@@ -28,10 +28,10 @@ const RuleDetail = ({
case 'mode':
value = !sourceData?.mode
? value
// eslint-disable-next-line sonarjs/no-nested-conditional
: sourceData.mode === ProcessMode.general
? (t('embedding.custom', { ns: 'datasetDocuments' }) as string)
// eslint-disable-next-line sonarjs/no-nested-conditional
: `${t('embedding.hierarchical', { ns: 'datasetDocuments' })} · ${sourceData?.rules?.parent_mode === 'paragraph'
? t('parentMode.paragraph', { ns: 'dataset' })
: t('parentMode.fullDoc', { ns: 'dataset' })}`
@@ -70,7 +70,7 @@ const RuleDetail = ({
src={
retrievalMethod === RETRIEVE_METHOD.fullText
? retrievalIcon.fullText
// eslint-disable-next-line sonarjs/no-nested-conditional
: retrievalMethod === RETRIEVE_METHOD.hybrid
? retrievalIcon.hybrid
: retrievalIcon.vector
@@ -1,5 +1,5 @@
import type { FC } from 'react'
import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLine, RiDraftLine, RiRefreshLine } from '@remixicon/react'
import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLine, RiDownload2Line, RiDraftLine, RiRefreshLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
@@ -14,6 +14,7 @@ type IBatchActionProps = {
selectedIds: string[]
onBatchEnable: () => void
onBatchDisable: () => void
onBatchDownload?: () => void
onBatchDelete: () => Promise<void>
onArchive?: () => void
onEditMetadata?: () => void
@@ -26,6 +27,7 @@ const BatchAction: FC<IBatchActionProps> = ({
selectedIds,
onBatchEnable,
onBatchDisable,
onBatchDownload,
onArchive,
onBatchDelete,
onEditMetadata,
@@ -103,6 +105,16 @@ const BatchAction: FC<IBatchActionProps> = ({
<span className="px-0.5">{t(`${i18nPrefix}.reIndex`, { ns: 'dataset' })}</span>
</Button>
)}
{onBatchDownload && (
<Button
variant="ghost"
className="gap-x-0.5 px-3"
onClick={onBatchDownload}
>
<RiDownload2Line className="size-4" />
<span className="px-0.5">{t(`${i18nPrefix}.download`, { ns: 'dataset' })}</span>
</Button>
)}
<Button
variant="ghost"
destructive
@@ -0,0 +1,239 @@
import type { CreateExternalAPIReq, FormSchema } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Form from './Form'
// Mock context for i18n doc link
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
describe('Form', () => {
const defaultFormSchemas: FormSchema[] = [
{
variable: 'name',
type: 'text',
label: { en_US: 'Name', zh_CN: '名称' },
required: true,
},
{
variable: 'endpoint',
type: 'text',
label: { en_US: 'API Endpoint', zh_CN: 'API 端点' },
required: true,
},
{
variable: 'api_key',
type: 'secret',
label: { en_US: 'API Key', zh_CN: 'API 密钥' },
required: true,
},
]
const defaultValue: CreateExternalAPIReq = {
name: '',
settings: {
endpoint: '',
api_key: '',
},
}
const defaultProps = {
value: defaultValue,
onChange: vi.fn(),
formSchemas: defaultFormSchemas,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<Form {...defaultProps} />)
expect(container.querySelector('form')).toBeInTheDocument()
})
it('should render all form fields based on formSchemas', () => {
render(<Form {...defaultProps} />)
expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api endpoint/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api key/i)).toBeInTheDocument()
})
it('should render required indicator for required fields', () => {
render(<Form {...defaultProps} />)
const labels = screen.getAllByText('*')
expect(labels.length).toBe(3) // All 3 fields are required
})
it('should render documentation link for endpoint field', () => {
render(<Form {...defaultProps} />)
const docLink = screen.getByText('dataset.externalAPIPanelDocumentation')
expect(docLink).toBeInTheDocument()
expect(docLink.closest('a')).toHaveAttribute('href', expect.stringContaining('docs.example.com'))
})
it('should render password type input for secret fields', () => {
render(<Form {...defaultProps} />)
const apiKeyInput = screen.getByLabelText(/api key/i)
expect(apiKeyInput).toHaveAttribute('type', 'password')
})
it('should render text type input for text fields', () => {
render(<Form {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
expect(nameInput).toHaveAttribute('type', 'text')
})
})
describe('Props', () => {
it('should apply custom className to form', () => {
const { container } = render(<Form {...defaultProps} className="custom-form-class" />)
expect(container.querySelector('form')).toHaveClass('custom-form-class')
})
it('should apply itemClassName to form items', () => {
const { container } = render(<Form {...defaultProps} itemClassName="custom-item-class" />)
const items = container.querySelectorAll('.custom-item-class')
expect(items.length).toBe(3)
})
it('should apply fieldLabelClassName to labels', () => {
const { container } = render(<Form {...defaultProps} fieldLabelClassName="custom-label-class" />)
const labels = container.querySelectorAll('label.custom-label-class')
expect(labels.length).toBe(3)
})
it('should apply inputClassName to inputs', () => {
render(<Form {...defaultProps} inputClassName="custom-input-class" />)
const inputs = screen.getAllByRole('textbox')
inputs.forEach((input) => {
expect(input).toHaveClass('custom-input-class')
})
})
it('should display initial values', () => {
const valueWithData: CreateExternalAPIReq = {
name: 'Test API',
settings: {
endpoint: 'https://api.example.com',
api_key: 'secret-key',
},
}
render(<Form {...defaultProps} value={valueWithData} />)
expect(screen.getByLabelText(/name/i)).toHaveValue('Test API')
expect(screen.getByLabelText(/api endpoint/i)).toHaveValue('https://api.example.com')
expect(screen.getByLabelText(/api key/i)).toHaveValue('secret-key')
})
})
describe('User Interactions', () => {
it('should call onChange when name field changes', () => {
const onChange = vi.fn()
render(<Form {...defaultProps} onChange={onChange} />)
const nameInput = screen.getByLabelText(/name/i)
fireEvent.change(nameInput, { target: { value: 'New API Name' } })
expect(onChange).toHaveBeenCalledWith({
name: 'New API Name',
settings: { endpoint: '', api_key: '' },
})
})
it('should call onChange when endpoint field changes', () => {
const onChange = vi.fn()
render(<Form {...defaultProps} onChange={onChange} />)
const endpointInput = screen.getByLabelText(/api endpoint/i)
fireEvent.change(endpointInput, { target: { value: 'https://new-api.example.com' } })
expect(onChange).toHaveBeenCalledWith({
name: '',
settings: { endpoint: 'https://new-api.example.com', api_key: '' },
})
})
it('should call onChange when api_key field changes', () => {
const onChange = vi.fn()
render(<Form {...defaultProps} onChange={onChange} />)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(apiKeyInput, { target: { value: 'new-secret-key' } })
expect(onChange).toHaveBeenCalledWith({
name: '',
settings: { endpoint: '', api_key: 'new-secret-key' },
})
})
it('should update settings without affecting name', () => {
const onChange = vi.fn()
const initialValue: CreateExternalAPIReq = {
name: 'Existing Name',
settings: { endpoint: '', api_key: '' },
}
render(<Form {...defaultProps} value={initialValue} onChange={onChange} />)
const endpointInput = screen.getByLabelText(/api endpoint/i)
fireEvent.change(endpointInput, { target: { value: 'https://api.example.com' } })
expect(onChange).toHaveBeenCalledWith({
name: 'Existing Name',
settings: { endpoint: 'https://api.example.com', api_key: '' },
})
})
})
describe('Edge Cases', () => {
it('should handle empty formSchemas', () => {
const { container } = render(<Form {...defaultProps} formSchemas={[]} />)
expect(container.querySelector('form')).toBeInTheDocument()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should handle optional field (required: false)', () => {
const schemasWithOptional: FormSchema[] = [
{
variable: 'description',
type: 'text',
label: { en_US: 'Description' },
required: false,
},
]
render(<Form {...defaultProps} formSchemas={schemasWithOptional} />)
expect(screen.queryByText('*')).not.toBeInTheDocument()
})
it('should fallback to en_US label when current language label is not available', () => {
const schemasWithEnOnly: FormSchema[] = [
{
variable: 'test',
type: 'text',
label: { en_US: 'Test Field' },
required: false,
},
]
render(<Form {...defaultProps} formSchemas={schemasWithEnOnly} />)
expect(screen.getByLabelText(/test field/i)).toBeInTheDocument()
})
it('should preserve existing settings when updating one field', () => {
const onChange = vi.fn()
const initialValue: CreateExternalAPIReq = {
name: '',
settings: { endpoint: 'https://existing.com', api_key: 'existing-key' },
}
render(<Form {...defaultProps} value={initialValue} onChange={onChange} />)
const endpointInput = screen.getByLabelText(/api endpoint/i)
fireEvent.change(endpointInput, { target: { value: 'https://new.com' } })
expect(onChange).toHaveBeenCalledWith({
name: '',
settings: { endpoint: 'https://new.com', api_key: 'existing-key' },
})
})
})
})
@@ -0,0 +1,424 @@
import type { CreateExternalAPIReq } from '../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import mocked service
import { createExternalAPI } from '@/service/datasets'
import AddExternalAPIModal from './index'
// Mock API service
vi.mock('@/service/datasets', () => ({
createExternalAPI: vi.fn(),
}))
// Mock toast context
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
describe('AddExternalAPIModal', () => {
const defaultProps = {
onSave: vi.fn(),
onCancel: vi.fn(),
isEditMode: false,
}
const initialData: CreateExternalAPIReq = {
name: 'Test API',
settings: {
endpoint: 'https://api.example.com',
api_key: 'test-key-12345',
},
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
})
it('should render create title when not in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={false} />)
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
})
it('should render edit title when in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={initialData} />)
expect(screen.getByText('dataset.editExternalAPIFormTitle')).toBeInTheDocument()
})
it('should render form fields', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api endpoint/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api key/i)).toBeInTheDocument()
})
it('should render cancel and save buttons', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByText('dataset.externalAPIForm.cancel')).toBeInTheDocument()
expect(screen.getByText('dataset.externalAPIForm.save')).toBeInTheDocument()
})
it('should render encryption notice', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByText('PKCS1_OAEP')).toBeInTheDocument()
})
it('should render close button', () => {
render(<AddExternalAPIModal {...defaultProps} />)
// Close button is rendered in a portal
const closeButton = document.body.querySelector('.action-btn')
expect(closeButton).toBeInTheDocument()
})
})
describe('Edit Mode with Dataset Bindings', () => {
it('should show warning when editing with dataset bindings', () => {
const datasetBindings = [
{ id: 'ds-1', name: 'Dataset 1' },
{ id: 'ds-2', name: 'Dataset 2' },
]
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
/>,
)
expect(screen.getByText('dataset.editExternalAPIFormWarning.front')).toBeInTheDocument()
// Verify the count is displayed in the warning section
const warningElement = screen.getByText('dataset.editExternalAPIFormWarning.front').parentElement
expect(warningElement?.textContent).toContain('2')
})
it('should not show warning when no dataset bindings', () => {
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={[]}
/>,
)
expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument()
})
})
describe('Form Interactions', () => {
it('should update form values when input changes', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
fireEvent.change(nameInput, { target: { value: 'New API Name' } })
expect(nameInput).toHaveValue('New API Name')
})
it('should initialize form with data in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={initialData} />)
expect(screen.getByLabelText(/name/i)).toHaveValue('Test API')
expect(screen.getByLabelText(/api endpoint/i)).toHaveValue('https://api.example.com')
expect(screen.getByLabelText(/api key/i)).toHaveValue('test-key-12345')
})
it('should disable save button when form has empty inputs', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')
expect(saveButton).toBeDisabled()
})
it('should enable save button when all fields are filled', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')
expect(saveButton).not.toBeDisabled()
})
})
describe('Create Mode - Save', () => {
it('should create API and call onSave on success', async () => {
const mockResponse = {
id: 'new-api-123',
tenant_id: 'tenant-1',
name: 'Test',
description: '',
settings: { endpoint: 'https://test.com', api_key: 'key12345' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
vi.mocked(createExternalAPI).mockResolvedValue(mockResponse)
const onSave = vi.fn()
const onCancel = vi.fn()
render(<AddExternalAPIModal {...defaultProps} onSave={onSave} onCancel={onCancel} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(createExternalAPI).toHaveBeenCalledWith({
body: {
name: 'Test',
settings: { endpoint: 'https://test.com', api_key: 'key12345' },
},
})
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'External API saved successfully',
})
expect(onSave).toHaveBeenCalledWith(mockResponse)
expect(onCancel).toHaveBeenCalled()
})
})
it('should show error notification when API key is too short', async () => {
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key' } }) // Less than 5 characters
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.apiBasedExtension.modal.apiKey.lengthError',
})
})
})
it('should handle create API error', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(createExternalAPI).mockRejectedValue(new Error('Create failed'))
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'Failed to save/update External API',
})
})
consoleSpy.mockRestore()
})
})
describe('Edit Mode - Save', () => {
it('should call onEdit directly when editing without dataset bindings', async () => {
const onEdit = vi.fn().mockResolvedValue(undefined)
const onCancel = vi.fn()
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={[]}
onEdit={onEdit}
onCancel={onCancel}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
// When no datasetBindings, onEdit is called directly with original form data
expect(onEdit).toHaveBeenCalledWith({
name: 'Test API',
settings: {
endpoint: 'https://api.example.com',
api_key: 'test-key-12345',
},
})
})
})
it('should show confirm dialog when editing with dataset bindings', async () => {
const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
const onEdit = vi.fn().mockResolvedValue(undefined)
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
onEdit={onEdit}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
})
it('should proceed with save after confirming in edit mode with bindings', async () => {
vi.mocked(createExternalAPI).mockResolvedValue({
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test API',
description: '',
settings: { endpoint: 'https://api.example.com', api_key: 'test-key-12345' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
})
const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
const onCancel = vi.fn()
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
onCancel={onCancel}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
const confirmButton = screen.getByRole('button', { name: /confirm/i })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'success' }),
)
})
})
it('should close confirm dialog when cancel is clicked', async () => {
const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
// There are multiple cancel buttons, find the one in the confirm dialog
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i })
const confirmDialogCancelButton = cancelButtons[cancelButtons.length - 1]
fireEvent.click(confirmDialogCancelButton)
await waitFor(() => {
// Confirm button should be gone after canceling
expect(screen.queryAllByRole('button', { name: /confirm/i })).toHaveLength(0)
})
})
})
describe('Cancel', () => {
it('should call onCancel when cancel button is clicked', () => {
const onCancel = vi.fn()
render(<AddExternalAPIModal {...defaultProps} onCancel={onCancel} />)
const cancelButton = screen.getByText('dataset.externalAPIForm.cancel').closest('button')!
fireEvent.click(cancelButton)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should call onCancel when close button is clicked', () => {
const onCancel = vi.fn()
render(<AddExternalAPIModal {...defaultProps} onCancel={onCancel} />)
// Close button is rendered in a portal
const closeButton = document.body.querySelector('.action-btn')!
fireEvent.click(closeButton)
expect(onCancel).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle undefined data in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={undefined} />)
expect(screen.getByLabelText(/name/i)).toHaveValue('')
})
it('should handle null datasetBindings', () => {
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={undefined}
/>,
)
expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument()
})
it('should render documentation link in encryption notice', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const link = screen.getByRole('link', { name: 'PKCS1_OAEP' })
expect(link).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html')
expect(link).toHaveAttribute('target', '_blank')
})
})
})
@@ -0,0 +1,207 @@
import type { ExternalAPIItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ExternalAPIPanel from './index'
// Mock external contexts (only mock context providers, not base components)
const mockSetShowExternalKnowledgeAPIModal = vi.fn()
const mockMutateExternalKnowledgeApis = vi.fn()
let mockIsLoading = false
let mockExternalKnowledgeApiList: ExternalAPIItem[] = []
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal,
}),
}))
vi.mock('@/context/external-knowledge-api-context', () => ({
useExternalKnowledgeApi: () => ({
externalKnowledgeApiList: mockExternalKnowledgeApiList,
mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis,
isLoading: mockIsLoading,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
// Mock the ExternalKnowledgeAPICard to avoid mocking its internal dependencies
vi.mock('../external-knowledge-api-card', () => ({
default: ({ api }: { api: ExternalAPIItem }) => (
<div data-testid={`api-card-${api.id}`}>{api.name}</div>
),
}))
// i18n mock returns 'namespace.key' format
describe('ExternalAPIPanel', () => {
const defaultProps = {
onClose: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockIsLoading = false
mockExternalKnowledgeApiList = []
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument()
})
it('should render panel title and description', () => {
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument()
expect(screen.getByText('dataset.externalAPIPanelDescription')).toBeInTheDocument()
})
it('should render documentation link', () => {
render(<ExternalAPIPanel {...defaultProps} />)
const docLink = screen.getByText('dataset.externalAPIPanelDocumentation')
expect(docLink).toBeInTheDocument()
expect(docLink.closest('a')).toHaveAttribute('href', 'https://docs.example.com/guides/knowledge-base/connect-external-knowledge-base')
})
it('should render create button', () => {
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
})
it('should render close button', () => {
const { container } = render(<ExternalAPIPanel {...defaultProps} />)
const closeButton = container.querySelector('[class*="action-button"]') || screen.getAllByRole('button')[0]
expect(closeButton).toBeInTheDocument()
})
})
describe('Loading State', () => {
it('should render loading indicator when isLoading is true', () => {
mockIsLoading = true
const { container } = render(<ExternalAPIPanel {...defaultProps} />)
// Loading component should be rendered
const loadingElement = container.querySelector('[class*="loading"]')
|| container.querySelector('.animate-spin')
|| screen.queryByRole('status')
expect(loadingElement || container.textContent).toBeTruthy()
})
})
describe('API List Rendering', () => {
it('should render empty list when no APIs exist', () => {
mockExternalKnowledgeApiList = []
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.queryByTestId(/api-card-/)).not.toBeInTheDocument()
})
it('should render API cards when APIs exist', () => {
mockExternalKnowledgeApiList = [
{
id: 'api-1',
tenant_id: 'tenant-1',
name: 'Test API 1',
description: '',
settings: { endpoint: 'https://api1.example.com', api_key: 'key1' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
},
{
id: 'api-2',
tenant_id: 'tenant-1',
name: 'Test API 2',
description: '',
settings: { endpoint: 'https://api2.example.com', api_key: 'key2' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
},
]
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByTestId('api-card-api-1')).toBeInTheDocument()
expect(screen.getByTestId('api-card-api-2')).toBeInTheDocument()
expect(screen.getByText('Test API 1')).toBeInTheDocument()
expect(screen.getByText('Test API 2')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClose when close button is clicked', () => {
const onClose = vi.fn()
render(<ExternalAPIPanel onClose={onClose} />)
// Find the close button (ActionButton with close icon)
const buttons = screen.getAllByRole('button')
const closeButton = buttons.find(btn => btn.querySelector('svg[class*="ri-close"]'))
|| buttons[0]
fireEvent.click(closeButton)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should open external API modal when create button is clicked', async () => {
render(<ExternalAPIPanel {...defaultProps} />)
const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
fireEvent.click(createButton)
await waitFor(() => {
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledTimes(1)
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith(
expect.objectContaining({
payload: { name: '', settings: { endpoint: '', api_key: '' } },
datasetBindings: [],
isEditMode: false,
}),
)
})
})
it('should call mutateExternalKnowledgeApis in onSaveCallback', async () => {
render(<ExternalAPIPanel {...defaultProps} />)
const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
fireEvent.click(createButton)
const callArgs = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
callArgs.onSaveCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
it('should call mutateExternalKnowledgeApis in onCancelCallback', async () => {
render(<ExternalAPIPanel {...defaultProps} />)
const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
fireEvent.click(createButton)
const callArgs = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
callArgs.onCancelCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle single API in list', () => {
mockExternalKnowledgeApiList = [
{
id: 'single-api',
tenant_id: 'tenant-1',
name: 'Single API',
description: '',
settings: { endpoint: 'https://single.example.com', api_key: 'key' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
},
]
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByTestId('api-card-single-api')).toBeInTheDocument()
})
it('should render documentation link with correct target', () => {
render(<ExternalAPIPanel {...defaultProps} />)
const docLink = screen.getByText('dataset.externalAPIPanelDocumentation').closest('a')
expect(docLink).toHaveAttribute('target', '_blank')
})
})
})
@@ -0,0 +1,382 @@
import type { ExternalAPIItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import mocked services
import { checkUsageExternalAPI, deleteExternalAPI, fetchExternalAPI } from '@/service/datasets'
import ExternalKnowledgeAPICard from './index'
// Mock API services
vi.mock('@/service/datasets', () => ({
fetchExternalAPI: vi.fn(),
updateExternalAPI: vi.fn(),
deleteExternalAPI: vi.fn(),
checkUsageExternalAPI: vi.fn(),
}))
// Mock contexts
const mockSetShowExternalKnowledgeAPIModal = vi.fn()
const mockMutateExternalKnowledgeApis = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal,
}),
}))
vi.mock('@/context/external-knowledge-api-context', () => ({
useExternalKnowledgeApi: () => ({
mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis,
}),
}))
describe('ExternalKnowledgeAPICard', () => {
const mockApi: ExternalAPIItem = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
const defaultProps = {
api: mockApi,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ExternalKnowledgeAPICard {...defaultProps} />)
expect(screen.getByText('Test External API')).toBeInTheDocument()
})
it('should render API name', () => {
render(<ExternalKnowledgeAPICard {...defaultProps} />)
expect(screen.getByText('Test External API')).toBeInTheDocument()
})
it('should render API endpoint', () => {
render(<ExternalKnowledgeAPICard {...defaultProps} />)
expect(screen.getByText('https://api.example.com/knowledge')).toBeInTheDocument()
})
it('should render edit and delete buttons', () => {
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const buttons = container.querySelectorAll('button')
expect(buttons.length).toBe(2)
})
it('should render API connection icon', () => {
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
})
describe('User Interactions - Edit', () => {
it('should fetch API details and open modal when edit button is clicked', async () => {
const mockResponse: ExternalAPIItem = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
dataset_bindings: [{ id: 'ds-1', name: 'Dataset 1' }],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const buttons = container.querySelectorAll('button')
const editButton = buttons[0]
fireEvent.click(editButton)
await waitFor(() => {
expect(fetchExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith(
expect.objectContaining({
payload: {
name: 'Test External API',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
},
isEditMode: true,
datasetBindings: [{ id: 'ds-1', name: 'Dataset 1' }],
}),
)
})
})
it('should handle fetch error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(fetchExternalAPI).mockRejectedValue(new Error('Fetch failed'))
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const buttons = container.querySelectorAll('button')
const editButton = buttons[0]
fireEvent.click(editButton)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Error fetching external knowledge API data:',
expect.any(Error),
)
})
consoleSpy.mockRestore()
})
it('should call mutate on save callback', async () => {
const mockResponse: ExternalAPIItem = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const editButton = container.querySelectorAll('button')[0]
fireEvent.click(editButton)
await waitFor(() => {
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled()
})
// Simulate save callback
const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
modalCall.onSaveCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
it('should call mutate on cancel callback', async () => {
const mockResponse: ExternalAPIItem = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const editButton = container.querySelectorAll('button')[0]
fireEvent.click(editButton)
await waitFor(() => {
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled()
})
// Simulate cancel callback
const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
modalCall.onCancelCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
})
describe('User Interactions - Delete', () => {
it('should check usage and show confirm dialog when delete button is clicked', async () => {
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const buttons = container.querySelectorAll('button')
const deleteButton = buttons[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(checkUsageExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
})
// Confirm dialog should be shown
await waitFor(() => {
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
})
it('should show usage count in confirm dialog when API is in use', async () => {
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: true, count: 3 })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByText(/3/)).toBeInTheDocument()
})
})
it('should delete API and refresh list when confirmed', async () => {
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
vi.mocked(deleteExternalAPI).mockResolvedValue({ result: 'success' })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
const confirmButton = screen.getByRole('button', { name: /confirm/i })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(deleteExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
})
it('should close confirm dialog when cancel is clicked', async () => {
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
const cancelButton = screen.getByRole('button', { name: /cancel/i })
fireEvent.click(cancelButton)
await waitFor(() => {
expect(screen.queryByRole('button', { name: /confirm/i })).not.toBeInTheDocument()
})
})
it('should handle delete error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
vi.mocked(deleteExternalAPI).mockRejectedValue(new Error('Delete failed'))
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
const confirmButton = screen.getByRole('button', { name: /confirm/i })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Error deleting external knowledge API:',
expect.any(Error),
)
})
consoleSpy.mockRestore()
})
it('should handle check usage error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(checkUsageExternalAPI).mockRejectedValue(new Error('Check failed'))
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Error checking external API usage:',
expect.any(Error),
)
})
consoleSpy.mockRestore()
})
})
describe('Hover State', () => {
it('should apply hover styles when delete button is hovered', () => {
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
const cardContainer = container.querySelector('[class*="shadows-shadow"]')
fireEvent.mouseEnter(deleteButton)
expect(cardContainer).toHaveClass('border-state-destructive-border')
expect(cardContainer).toHaveClass('bg-state-destructive-hover')
fireEvent.mouseLeave(deleteButton)
expect(cardContainer).not.toHaveClass('border-state-destructive-border')
})
})
describe('Edge Cases', () => {
it('should handle API with empty endpoint', () => {
const apiWithEmptyEndpoint: ExternalAPIItem = {
...mockApi,
settings: { endpoint: '', api_key: 'key' },
}
render(<ExternalKnowledgeAPICard api={apiWithEmptyEndpoint} />)
expect(screen.getByText('Test External API')).toBeInTheDocument()
})
it('should handle delete response with unsuccessful result', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
vi.mocked(deleteExternalAPI).mockResolvedValue({ result: 'error' })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
const confirmButton = screen.getByRole('button', { name: /confirm/i })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Failed to delete external API')
})
consoleSpy.mockRestore()
})
})
})
@@ -0,0 +1,92 @@
import { RiArrowRightUpLine, RiBookOpenLine } from '@remixicon/react'
import Link from 'next/link'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Switch from '@/app/components/base/switch'
import Indicator from '@/app/components/header/indicator'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import { useDisableDatasetServiceApi, useEnableDatasetServiceApi } from '@/service/knowledge/use-dataset'
import { cn } from '@/utils/classnames'
type CardProps = {
apiEnabled: boolean
}
const Card = ({
apiEnabled,
}: CardProps) => {
const { t } = useTranslation()
const datasetId = useDatasetDetailContextWithSelector(state => state.dataset?.id)
const mutateDatasetRes = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
const { mutateAsync: enableDatasetServiceApi } = useEnableDatasetServiceApi()
const { mutateAsync: disableDatasetServiceApi } = useDisableDatasetServiceApi()
const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager)
const apiReferenceUrl = useDatasetApiAccessUrl()
const onToggle = useCallback(async (state: boolean) => {
let result: 'success' | 'fail'
if (state)
result = (await enableDatasetServiceApi(datasetId ?? '')).result
else
result = (await disableDatasetServiceApi(datasetId ?? '')).result
if (result === 'success')
mutateDatasetRes?.()
}, [datasetId, enableDatasetServiceApi, mutateDatasetRes, disableDatasetServiceApi])
return (
<div className="w-[208px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<div className="p-1">
<div className="p-2">
<div className="mb-1.5 flex justify-between">
<div className="flex items-center gap-1">
<Indicator
className="shrink-0"
color={apiEnabled ? 'green' : 'yellow'}
/>
<div
className={cn(
'system-xs-semibold-uppercase',
apiEnabled ? 'text-text-success' : 'text-text-warning',
)}
>
{apiEnabled
? t('serviceApi.enabled', { ns: 'dataset' })
: t('serviceApi.disabled', { ns: 'dataset' })}
</div>
</div>
<Switch
defaultValue={apiEnabled}
onChange={onToggle}
disabled={!isCurrentWorkspaceManager}
/>
</div>
<div className="system-xs-regular text-text-tertiary">
{t('appMenus.apiAccessTip', { ns: 'common' })}
</div>
</div>
</div>
<div className="h-px bg-divider-subtle"></div>
<div className="p-1">
<Link
href={apiReferenceUrl}
target="_blank"
rel="noopener noreferrer"
className="flex h-8 items-center space-x-[7px] rounded-lg px-2 text-text-tertiary hover:bg-state-base-hover"
>
<RiBookOpenLine className="size-3.5 shrink-0" />
<div className="system-sm-regular grow truncate">
{t('overview.apiInfo.doc', { ns: 'appOverview' })}
</div>
<RiArrowRightUpLine className="size-3.5 shrink-0" />
</Link>
</div>
</div>
)
}
export default React.memo(Card)
@@ -0,0 +1,65 @@
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import Indicator from '@/app/components/header/indicator'
import { cn } from '@/utils/classnames'
import Card from './card'
type ApiAccessProps = {
expand: boolean
apiEnabled: boolean
}
const ApiAccess = ({
expand,
apiEnabled,
}: ApiAccessProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleToggle = () => {
setOpen(!open)
}
return (
<div className="p-3 pt-2">
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="top-start"
offset={{
mainAxis: 4,
crossAxis: -4,
}}
>
<PortalToFollowElemTrigger
className="w-full"
onClick={handleToggle}
>
<div className={cn(
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border border-components-panel-border px-3',
!expand && 'w-8 justify-center',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
>
<ApiAggregate className="size-4 shrink-0 text-text-secondary" />
{expand && <div className="system-sm-medium grow text-text-secondary">{t('appMenus.apiAccess', { ns: 'common' })}</div>}
<Indicator
className={cn('shrink-0', !expand && 'absolute -right-px -top-px')}
color={apiEnabled ? 'green' : 'yellow'}
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[10]">
<Card
apiEnabled={apiEnabled}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
)
}
export default React.memo(ApiAccess)
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,7 @@
import type { RelatedAppResponse } from '@/models/datasets'
import * as React from 'react'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset'
import ServiceApi from './service-api'
import ApiAccess from './api-access'
import Statistics from './statistics'
type IExtraInfoProps = {
@@ -17,7 +16,6 @@ const ExtraInfo = ({
expand,
}: IExtraInfoProps) => {
const apiEnabled = useDatasetDetailContextWithSelector(state => state.dataset?.enable_api)
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
return (
<>
@@ -28,9 +26,8 @@ const ExtraInfo = ({
relatedApps={relatedApps}
/>
)}
<ServiceApi
<ApiAccess
expand={expand}
apiBaseUrl={apiBaseInfo?.api_base_url ?? ''}
apiEnabled={apiEnabled ?? false}
/>
</>
@@ -6,45 +6,22 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import CopyFeedback from '@/app/components/base/copy-feedback'
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
import Switch from '@/app/components/base/switch'
import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal'
import Indicator from '@/app/components/header/indicator'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import { useDisableDatasetServiceApi, useEnableDatasetServiceApi } from '@/service/knowledge/use-dataset'
import { cn } from '@/utils/classnames'
type CardProps = {
apiEnabled: boolean
apiBaseUrl: string
}
const Card = ({
apiEnabled,
apiBaseUrl,
}: CardProps) => {
const { t } = useTranslation()
const datasetId = useDatasetDetailContextWithSelector(state => state.dataset?.id)
const mutateDatasetRes = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
const { mutateAsync: enableDatasetServiceApi } = useEnableDatasetServiceApi()
const { mutateAsync: disableDatasetServiceApi } = useDisableDatasetServiceApi()
const [isSecretKeyModalVisible, setIsSecretKeyModalVisible] = useState(false)
const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager)
const apiReferenceUrl = useDatasetApiAccessUrl()
const onToggle = useCallback(async (state: boolean) => {
let result: 'success' | 'fail'
if (state)
result = (await enableDatasetServiceApi(datasetId ?? '')).result
else
result = (await disableDatasetServiceApi(datasetId ?? '')).result
if (result === 'success')
mutateDatasetRes?.()
}, [datasetId, enableDatasetServiceApi, disableDatasetServiceApi])
const handleOpenSecretKeyModal = useCallback(() => {
setIsSecretKeyModalVisible(true)
}, [])
@@ -68,24 +45,16 @@ const Card = ({
<div className="flex items-center gap-x-1">
<Indicator
className="shrink-0"
color={apiEnabled ? 'green' : 'yellow'}
color={
apiBaseUrl ? 'green' : 'yellow'
}
/>
<div
className={cn(
'system-xs-semibold-uppercase',
apiEnabled ? 'text-text-success' : 'text-text-warning',
)}
className="system-xs-semibold-uppercase text-text-success"
>
{apiEnabled
? t('serviceApi.enabled', { ns: 'dataset' })
: t('serviceApi.disabled', { ns: 'dataset' })}
{t('serviceApi.enabled', { ns: 'dataset' })}
</div>
</div>
<Switch
defaultValue={apiEnabled}
onChange={onToggle}
disabled={!isCurrentWorkspaceManager}
/>
</div>
<div className="flex flex-col">
<div className="system-xs-regular leading-6 text-text-tertiary">
@@ -1,22 +1,17 @@
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import Indicator from '@/app/components/header/indicator'
import { cn } from '@/utils/classnames'
import Card from './card'
type ServiceApiProps = {
expand: boolean
apiBaseUrl: string
apiEnabled: boolean
}
const ServiceApi = ({
expand,
apiBaseUrl,
apiEnabled,
}: ServiceApiProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@@ -26,7 +21,7 @@ const ServiceApi = ({
}
return (
<div className="p-3 pt-2">
<div>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
@@ -41,22 +36,21 @@ const ServiceApi = ({
onClick={handleToggle}
>
<div className={cn(
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border border-components-panel-border px-3',
!expand && 'w-8 justify-center',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border-[0.5px] border-components-button-secondary-border-hover bg-components-button-secondary-bg px-3',
open ? 'bg-components-button-secondary-bg-hover' : 'hover:bg-components-button-secondary-bg-hover',
)}
>
<ApiAggregate className="size-4 shrink-0 text-text-secondary" />
{expand && <div className="system-sm-medium grow text-text-secondary">{t('serviceApi.title', { ns: 'dataset' })}</div>}
<Indicator
className={cn('shrink-0', !expand && 'absolute -right-px -top-px')}
color={apiEnabled ? 'green' : 'yellow'}
className={cn('shrink-0')}
color={
apiBaseUrl ? 'green' : 'yellow'
}
/>
<div className="system-sm-medium grow text-text-secondary">{t('serviceApi.title', { ns: 'dataset' })}</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[10]">
<Card
apiEnabled={apiEnabled}
apiBaseUrl={apiBaseUrl}
/>
</PortalToFollowElemContent>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,125 @@
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import CornerLabels from './corner-labels'
describe('CornerLabels', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
...overrides,
} as DataSet)
describe('Rendering', () => {
it('should render without crashing when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<CornerLabels dataset={dataset} />)
// Should render null when embedding is available and not pipeline
expect(container.firstChild).toBeNull()
})
it('should render unavailable label when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
render(<CornerLabels dataset={dataset} />)
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
})
it('should render pipeline label when runtime_mode is rag_pipeline', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: 'rag_pipeline',
})
render(<CornerLabels dataset={dataset} />)
expect(screen.getByText(/pipeline/i)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should not render when embedding is available and not pipeline', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: 'general',
})
const { container } = render(<CornerLabels dataset={dataset} />)
expect(container.firstChild).toBeNull()
})
it('should prioritize unavailable label over pipeline label', () => {
const dataset = createMockDataset({
embedding_available: false,
runtime_mode: 'rag_pipeline',
})
render(<CornerLabels dataset={dataset} />)
// Should show unavailable since embedding_available is checked first
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
expect(screen.queryByText(/pipeline/i)).not.toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have correct positioning for unavailable label', () => {
const dataset = createMockDataset({ embedding_available: false })
const { container } = render(<CornerLabels dataset={dataset} />)
const labelContainer = container.firstChild as HTMLElement
expect(labelContainer).toHaveClass('absolute', 'right-0', 'top-0', 'z-10')
})
it('should have correct positioning for pipeline label', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: 'rag_pipeline',
})
const { container } = render(<CornerLabels dataset={dataset} />)
const labelContainer = container.firstChild as HTMLElement
expect(labelContainer).toHaveClass('absolute', 'right-0', 'top-0', 'z-10')
})
})
describe('Edge Cases', () => {
it('should handle undefined runtime_mode', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: undefined,
})
const { container } = render(<CornerLabels dataset={dataset} />)
expect(container.firstChild).toBeNull()
})
it('should handle empty string runtime_mode', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: '' as DataSet['runtime_mode'],
})
const { container } = render(<CornerLabels dataset={dataset} />)
expect(container.firstChild).toBeNull()
})
it('should handle all false conditions', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: 'general',
})
const { container } = render(<CornerLabels dataset={dataset} />)
expect(container.firstChild).toBeNull()
})
})
})
@@ -0,0 +1,177 @@
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import DatasetCardFooter from './dataset-card-footer'
// Mock the useFormatTimeFromNow hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: vi.fn((timestamp: number) => {
const date = new Date(timestamp)
return `${date.toLocaleDateString()}`
}),
}),
}))
describe('DatasetCardFooter', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
total_available_documents: 10,
...overrides,
} as DataSet)
describe('Rendering', () => {
it('should render without crashing', () => {
const dataset = createMockDataset()
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('10')).toBeInTheDocument()
})
it('should render document count', () => {
const dataset = createMockDataset({ document_count: 25, total_available_documents: 25 })
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('25')).toBeInTheDocument()
})
it('should render app count for non-external provider', () => {
const dataset = createMockDataset({ app_count: 8, provider: 'vendor' })
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('8')).toBeInTheDocument()
})
it('should not render app count for external provider', () => {
const dataset = createMockDataset({ app_count: 8, provider: 'external' })
render(<DatasetCardFooter dataset={dataset} />)
// App count should not be rendered
const appCounts = screen.queryAllByText('8')
expect(appCounts.length).toBe(0)
})
it('should render update time', () => {
const dataset = createMockDataset()
render(<DatasetCardFooter dataset={dataset} />)
// Check for "updated" text with i18n key
expect(screen.getByText(/updated/)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should show partial document count when total_available_documents < document_count', () => {
const dataset = createMockDataset({
document_count: 20,
total_available_documents: 15,
})
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('15 / 20')).toBeInTheDocument()
})
it('should show full document count when all documents are available', () => {
const dataset = createMockDataset({
document_count: 20,
total_available_documents: 20,
})
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('20')).toBeInTheDocument()
})
it('should handle zero documents', () => {
const dataset = createMockDataset({
document_count: 0,
total_available_documents: 0,
})
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('0')).toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have correct base styling when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<DatasetCardFooter dataset={dataset} />)
const footer = container.firstChild as HTMLElement
expect(footer).toHaveClass('flex', 'items-center', 'gap-x-3', 'px-4')
})
it('should have opacity class when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
const { container } = render(<DatasetCardFooter dataset={dataset} />)
const footer = container.firstChild as HTMLElement
expect(footer).toHaveClass('opacity-30')
})
it('should not have opacity class when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<DatasetCardFooter dataset={dataset} />)
const footer = container.firstChild as HTMLElement
expect(footer).not.toHaveClass('opacity-30')
})
})
describe('Icons', () => {
it('should render document icon', () => {
const dataset = createMockDataset()
const { container } = render(<DatasetCardFooter dataset={dataset} />)
// RiFileTextFill icon
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThanOrEqual(1)
})
it('should render robot icon for non-external provider', () => {
const dataset = createMockDataset({ provider: 'vendor' })
const { container } = render(<DatasetCardFooter dataset={dataset} />)
// Should have both file and robot icons
const icons = container.querySelectorAll('svg')
expect(icons.length).toBe(2)
})
})
describe('Edge Cases', () => {
it('should handle undefined total_available_documents', () => {
const dataset = createMockDataset({
document_count: 10,
total_available_documents: undefined,
})
render(<DatasetCardFooter dataset={dataset} />)
// Should show 0 / 10 since total_available_documents defaults to 0
expect(screen.getByText('0 / 10')).toBeInTheDocument()
})
it('should handle very large numbers', () => {
const dataset = createMockDataset({
document_count: 999999,
total_available_documents: 999999,
app_count: 888888,
})
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('999999')).toBeInTheDocument()
expect(screen.getByText('888888')).toBeInTheDocument()
})
it('should handle zero app count', () => {
const dataset = createMockDataset({ app_count: 0, document_count: 5, total_available_documents: 5 })
render(<DatasetCardFooter dataset={dataset} />)
// Both document count and app count are shown
const zeros = screen.getAllByText('0')
expect(zeros.length).toBeGreaterThanOrEqual(1)
})
})
})
@@ -0,0 +1,254 @@
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import DatasetCardHeader from './dataset-card-header'
// Mock useFormatTimeFromNow hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (timestamp: number) => {
const date = new Date(timestamp)
return date.toLocaleDateString()
},
}),
}))
// Mock useKnowledge hook
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: (technique: string, _method: string) => {
if (technique === 'high_quality')
return 'High Quality'
if (technique === 'economy')
return 'Economy'
return ''
},
}),
}))
describe('DatasetCardHeader', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
indexing_status: 'completed',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
total_document_count: 10,
word_count: 1000,
updated_at: 1609545600,
updated_by: 'user-1',
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
is_published: true,
enable_api: true,
is_multimodal: false,
built_in_field_enabled: false,
icon_info: {
icon: '📙',
icon_type: 'emoji' as const,
icon_background: '#FFF4ED',
icon_url: '',
},
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
} as DataSet['retrieval_model_dict'],
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
} as DataSet['retrieval_model'],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 3,
score_threshold: 0.5,
score_threshold_enabled: false,
},
author_name: 'Test User',
...overrides,
})
describe('Rendering', () => {
it('should render without crashing', () => {
const dataset = createMockDataset()
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should render dataset name', () => {
const dataset = createMockDataset({ name: 'Custom Dataset' })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Custom Dataset')).toBeInTheDocument()
})
it('should render author name', () => {
const dataset = createMockDataset({ author_name: 'John Doe' })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
it('should render edit time', () => {
const dataset = createMockDataset()
render(<DatasetCardHeader dataset={dataset} />)
// Should contain the formatted time
expect(screen.getByText(/segment\.editedAt/)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should show external knowledge base text for external provider', () => {
const dataset = createMockDataset({ provider: 'external' })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText(/externalKnowledgeBase/)).toBeInTheDocument()
})
it('should show chunking mode for text_model doc_form', () => {
const dataset = createMockDataset({ doc_form: ChunkingMode.text })
render(<DatasetCardHeader dataset={dataset} />)
// text_model maps to 'general' in DOC_FORM_TEXT
expect(screen.getByText(/chunkingMode\.general/)).toBeInTheDocument()
})
it('should show multimodal text when is_multimodal is true', () => {
const dataset = createMockDataset({ is_multimodal: true })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText(/multimodal/)).toBeInTheDocument()
})
it('should not show multimodal when is_multimodal is false', () => {
const dataset = createMockDataset({ is_multimodal: false })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.queryByText(/^multimodal$/)).not.toBeInTheDocument()
})
})
describe('Icon', () => {
it('should render AppIcon component', () => {
const dataset = createMockDataset()
const { container } = render(<DatasetCardHeader dataset={dataset} />)
// AppIcon should be rendered
const iconContainer = container.querySelector('.relative.shrink-0')
expect(iconContainer).toBeInTheDocument()
})
it('should use default icon when icon_info is missing', () => {
const dataset = createMockDataset({ icon_info: undefined })
render(<DatasetCardHeader dataset={dataset} />)
// Should still render without crashing
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should render chunking mode icon for published pipeline', () => {
const dataset = createMockDataset({
doc_form: ChunkingMode.text,
runtime_mode: 'rag_pipeline',
is_published: true,
})
const { container } = render(<DatasetCardHeader dataset={dataset} />)
// Should have the icon badge
const iconBadge = container.querySelector('.absolute.-bottom-1.-right-1')
expect(iconBadge).toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have opacity class when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
const { container } = render(<DatasetCardHeader dataset={dataset} />)
const header = container.firstChild as HTMLElement
expect(header).toHaveClass('opacity-30')
})
it('should not have opacity class when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<DatasetCardHeader dataset={dataset} />)
const header = container.firstChild as HTMLElement
expect(header).not.toHaveClass('opacity-30')
})
it('should have correct base styling', () => {
const dataset = createMockDataset()
const { container } = render(<DatasetCardHeader dataset={dataset} />)
const header = container.firstChild as HTMLElement
expect(header).toHaveClass('flex', 'items-center', 'gap-x-3', 'px-4')
})
})
describe('DocModeInfo', () => {
it('should show doc mode info when all conditions are met', () => {
const dataset = createMockDataset({
doc_form: ChunkingMode.text,
indexing_technique: IndexingType.QUALIFIED,
retrieval_model_dict: { search_method: RETRIEVE_METHOD.semantic } as DataSet['retrieval_model_dict'],
runtime_mode: 'general',
})
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText(/chunkingMode/)).toBeInTheDocument()
})
it('should not show doc mode info for unpublished pipeline', () => {
const dataset = createMockDataset({
runtime_mode: 'rag_pipeline',
is_published: false,
})
render(<DatasetCardHeader dataset={dataset} />)
// DocModeInfo should not be rendered since isShowDocModeInfo is false
expect(screen.queryByText(/High Quality/)).not.toBeInTheDocument()
})
it('should show doc mode info for published pipeline', () => {
const dataset = createMockDataset({
doc_form: ChunkingMode.text,
indexing_technique: IndexingType.QUALIFIED,
retrieval_model_dict: { search_method: RETRIEVE_METHOD.semantic } as DataSet['retrieval_model_dict'],
runtime_mode: 'rag_pipeline',
is_published: true,
})
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText(/chunkingMode/)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle missing author_name', () => {
const dataset = createMockDataset({ author_name: undefined })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle empty name', () => {
const dataset = createMockDataset({ name: '' })
render(<DatasetCardHeader dataset={dataset} />)
// Should render without crashing
const { container } = render(<DatasetCardHeader dataset={dataset} />)
expect(container).toBeInTheDocument()
})
it('should handle missing retrieval_model_dict', () => {
const dataset = createMockDataset({ retrieval_model_dict: undefined })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle undefined doc_form', () => {
const dataset = createMockDataset({ doc_form: undefined })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,237 @@
import type { DataSet } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import DatasetCardModals from './dataset-card-modals'
// Mock RenameDatasetModal since it's from a different feature folder
vi.mock('../../../rename-modal', () => ({
default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess?: () => void }) => (
show
? (
<div data-testid="rename-modal">
<button onClick={onClose}>Close Rename</button>
<button onClick={onSuccess}>Success</button>
</div>
)
: null
),
}))
describe('DatasetCardModals', () => {
const mockDataset: DataSet = {
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
indexing_status: 'completed',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
total_document_count: 10,
word_count: 1000,
updated_at: 1609545600,
updated_by: 'user-1',
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
built_in_field_enabled: false,
icon_info: {
icon: '📙',
icon_type: 'emoji' as const,
icon_background: '#FFF4ED',
icon_url: '',
},
retrieval_model_dict: {} as DataSet['retrieval_model_dict'],
retrieval_model: {} as DataSet['retrieval_model'],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 3,
score_threshold: 0.5,
score_threshold_enabled: false,
},
}
const defaultProps = {
dataset: mockDataset,
modalState: {
showRenameModal: false,
showConfirmDelete: false,
confirmMessage: '',
},
onCloseRename: vi.fn(),
onCloseConfirm: vi.fn(),
onConfirmDelete: vi.fn(),
onSuccess: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing when no modals are shown', () => {
const { container } = render(<DatasetCardModals {...defaultProps} />)
// Should render empty fragment
expect(container.innerHTML).toBe('')
})
it('should render rename modal when showRenameModal is true', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
/>,
)
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
})
it('should render confirm modal when showConfirmDelete is true', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: 'Are you sure?',
}}
/>,
)
// Confirm modal should be rendered
expect(screen.getByText('Are you sure?')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass dataset to rename modal', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
/>,
)
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
})
it('should display confirmMessage in confirm modal', () => {
const confirmMessage = 'This is a custom confirm message'
render(
<DatasetCardModals
{...defaultProps}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage,
}}
/>,
)
expect(screen.getByText(confirmMessage)).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onCloseRename when closing rename modal', () => {
const onCloseRename = vi.fn()
render(
<DatasetCardModals
{...defaultProps}
onCloseRename={onCloseRename}
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
/>,
)
fireEvent.click(screen.getByText('Close Rename'))
expect(onCloseRename).toHaveBeenCalledTimes(1)
})
it('should call onConfirmDelete when confirming deletion', () => {
const onConfirmDelete = vi.fn()
render(
<DatasetCardModals
{...defaultProps}
onConfirmDelete={onConfirmDelete}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: 'Delete?',
}}
/>,
)
// Find and click the confirm button
const confirmButton = screen.getByRole('button', { name: /confirm|ok|delete/i })
|| screen.getAllByRole('button').find(btn => btn.textContent?.toLowerCase().includes('confirm'))
if (confirmButton)
fireEvent.click(confirmButton)
expect(onConfirmDelete).toHaveBeenCalledTimes(1)
})
it('should call onCloseConfirm when canceling deletion', () => {
const onCloseConfirm = vi.fn()
render(
<DatasetCardModals
{...defaultProps}
onCloseConfirm={onCloseConfirm}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: 'Delete?',
}}
/>,
)
// Find and click the cancel button
const cancelButton = screen.getByRole('button', { name: /cancel/i })
fireEvent.click(cancelButton)
expect(onCloseConfirm).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle both modals being true (render both)', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{
showRenameModal: true,
showConfirmDelete: true,
confirmMessage: 'Delete this dataset?',
}}
/>,
)
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
expect(screen.getByText('Delete this dataset?')).toBeInTheDocument()
})
it('should handle empty confirmMessage', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: '',
}}
/>,
)
// Should still render confirm modal
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
})
})
@@ -0,0 +1,107 @@
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import Description from './description'
describe('Description', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'This is a test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
...overrides,
} as DataSet)
describe('Rendering', () => {
it('should render without crashing', () => {
const dataset = createMockDataset()
render(<Description dataset={dataset} />)
expect(screen.getByText('This is a test description')).toBeInTheDocument()
})
it('should render the description text', () => {
const dataset = createMockDataset({ description: 'Custom description text' })
render(<Description dataset={dataset} />)
expect(screen.getByText('Custom description text')).toBeInTheDocument()
})
it('should set title attribute for tooltip', () => {
const dataset = createMockDataset({ description: 'Tooltip description' })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle('Tooltip description')
expect(descDiv).toBeInTheDocument()
})
})
describe('Props', () => {
it('should display dataset description', () => {
const description = 'A very detailed description of this dataset'
const dataset = createMockDataset({ description })
render(<Description dataset={dataset} />)
expect(screen.getByText(description)).toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have correct base styling when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle(dataset.description)
expect(descDiv).toHaveClass('system-xs-regular', 'line-clamp-2', 'h-10', 'px-4', 'py-1', 'text-text-tertiary')
})
it('should have opacity class when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle(dataset.description)
expect(descDiv).toHaveClass('opacity-30')
})
it('should not have opacity class when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle(dataset.description)
expect(descDiv).not.toHaveClass('opacity-30')
})
})
describe('Edge Cases', () => {
it('should handle empty description', () => {
const dataset = createMockDataset({ description: '' })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle('')
expect(descDiv).toBeInTheDocument()
expect(descDiv).toHaveTextContent('')
})
it('should handle very long description', () => {
const longDescription = 'A'.repeat(500)
const dataset = createMockDataset({ description: longDescription })
render(<Description dataset={dataset} />)
expect(screen.getByText(longDescription)).toBeInTheDocument()
})
it('should handle description with special characters', () => {
const description = '<script>alert("XSS")</script> & "quotes" \'single\''
const dataset = createMockDataset({ description })
render(<Description dataset={dataset} />)
expect(screen.getByText(description)).toBeInTheDocument()
})
})
})
@@ -0,0 +1,162 @@
import type { DataSet } from '@/models/datasets'
import { fireEvent, render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import OperationsPopover from './operations-popover'
describe('OperationsPopover', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
...overrides,
} as DataSet)
const defaultProps = {
dataset: createMockDataset(),
isCurrentWorkspaceDatasetOperator: false,
openRenameModal: vi.fn(),
handleExportPipeline: vi.fn(),
detectIsUsedByApp: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render the more icon button', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
const moreIcon = container.querySelector('svg')
expect(moreIcon).toBeInTheDocument()
})
it('should render in hidden state initially (group-hover)', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('hidden', 'group-hover:block')
})
})
describe('Props', () => {
it('should show delete option when not workspace dataset operator', () => {
render(<OperationsPopover {...defaultProps} isCurrentWorkspaceDatasetOperator={false} />)
// Click to open popover
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
if (triggerButton)
fireEvent.click(triggerButton)
// showDelete should be true (inverse of isCurrentWorkspaceDatasetOperator)
// This means delete operation will be visible
})
it('should hide delete option when is workspace dataset operator', () => {
render(<OperationsPopover {...defaultProps} isCurrentWorkspaceDatasetOperator={true} />)
// Click to open popover
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
if (triggerButton)
fireEvent.click(triggerButton)
// showDelete should be false
})
it('should show export pipeline when runtime_mode is rag_pipeline', () => {
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline' })
render(<OperationsPopover {...defaultProps} dataset={dataset} />)
// Click to open popover
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
if (triggerButton)
fireEvent.click(triggerButton)
// showExportPipeline should be true
})
it('should hide export pipeline when runtime_mode is not rag_pipeline', () => {
const dataset = createMockDataset({ runtime_mode: 'general' })
render(<OperationsPopover {...defaultProps} dataset={dataset} />)
// Click to open popover
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
if (triggerButton)
fireEvent.click(triggerButton)
// showExportPipeline should be false
})
})
describe('Styles', () => {
it('should have correct positioning styles', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('absolute', 'right-2', 'top-2', 'z-[15]')
})
it('should have icon with correct size classes', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('h-5', 'w-5', 'text-text-tertiary')
})
})
describe('User Interactions', () => {
it('should pass openRenameModal to Operations', () => {
const openRenameModal = vi.fn()
render(<OperationsPopover {...defaultProps} openRenameModal={openRenameModal} />)
// The openRenameModal should be passed to Operations component
expect(openRenameModal).not.toHaveBeenCalled() // Initially not called
})
it('should pass handleExportPipeline to Operations', () => {
const handleExportPipeline = vi.fn()
render(<OperationsPopover {...defaultProps} handleExportPipeline={handleExportPipeline} />)
expect(handleExportPipeline).not.toHaveBeenCalled()
})
it('should pass detectIsUsedByApp to Operations', () => {
const detectIsUsedByApp = vi.fn()
render(<OperationsPopover {...defaultProps} detectIsUsedByApp={detectIsUsedByApp} />)
expect(detectIsUsedByApp).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle dataset with external provider', () => {
const dataset = createMockDataset({ provider: 'external' })
const { container } = render(<OperationsPopover {...defaultProps} dataset={dataset} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle dataset with undefined runtime_mode', () => {
const dataset = createMockDataset({ runtime_mode: undefined })
const { container } = render(<OperationsPopover {...defaultProps} dataset={dataset} />)
expect(container.firstChild).toBeInTheDocument()
})
})
})
@@ -0,0 +1,198 @@
import type { Tag } from '@/app/components/base/tag-management/constant'
import type { DataSet } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { useRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import TagArea from './tag-area'
// Mock TagSelector as it's a complex component from base
vi.mock('@/app/components/base/tag-management/selector', () => ({
default: ({ value, selectedTags, onCacheUpdate, onChange }: {
value: string[]
selectedTags: Tag[]
onCacheUpdate: (tags: Tag[]) => void
onChange?: () => void
}) => (
<div data-testid="tag-selector">
<div data-testid="tag-values">{value.join(',')}</div>
<div data-testid="selected-count">
{selectedTags.length}
{' '}
tags
</div>
<button onClick={() => onCacheUpdate([{ id: 'new-tag', name: 'New Tag', type: 'knowledge', binding_count: 0 }])}>
Update Tags
</button>
<button onClick={onChange}>
Trigger Change
</button>
</div>
),
}))
describe('TagArea', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
...overrides,
} as DataSet)
const mockTags: Tag[] = [
{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: 0 },
{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 },
]
const defaultProps = {
dataset: createMockDataset(),
tags: mockTags,
setTags: vi.fn(),
onSuccess: vi.fn(),
isHoveringTagSelector: false,
onClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<TagArea {...defaultProps} />)
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
})
it('should render TagSelector with correct value', () => {
render(<TagArea {...defaultProps} />)
expect(screen.getByTestId('tag-values')).toHaveTextContent('tag-1,tag-2')
})
it('should display selected tags count', () => {
render(<TagArea {...defaultProps} />)
expect(screen.getByTestId('selected-count')).toHaveTextContent('2 tags')
})
})
describe('Props', () => {
it('should pass dataset id to TagSelector', () => {
const dataset = createMockDataset({ id: 'custom-dataset-id' })
render(<TagArea {...defaultProps} dataset={dataset} />)
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
})
it('should render with empty tags', () => {
render(<TagArea {...defaultProps} tags={[]} />)
expect(screen.getByTestId('selected-count')).toHaveTextContent('0 tags')
})
it('should forward ref correctly', () => {
const TestComponent = () => {
const ref = useRef<HTMLDivElement>(null)
return <TagArea {...defaultProps} ref={ref} />
}
render(<TestComponent />)
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClick when container is clicked', () => {
const onClick = vi.fn()
const { container } = render(<TagArea {...defaultProps} onClick={onClick} />)
const wrapper = container.firstChild as HTMLElement
fireEvent.click(wrapper)
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should call setTags when tags are updated', () => {
const setTags = vi.fn()
render(<TagArea {...defaultProps} setTags={setTags} />)
fireEvent.click(screen.getByText('Update Tags'))
expect(setTags).toHaveBeenCalledWith([{ id: 'new-tag', name: 'New Tag', type: 'knowledge', binding_count: 0 }])
})
it('should call onSuccess when onChange is triggered', () => {
const onSuccess = vi.fn()
render(<TagArea {...defaultProps} onSuccess={onSuccess} />)
fireEvent.click(screen.getByText('Trigger Change'))
expect(onSuccess).toHaveBeenCalledTimes(1)
})
})
describe('Styles', () => {
it('should have opacity class when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
const { container } = render(<TagArea {...defaultProps} dataset={dataset} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('opacity-30')
})
it('should not have opacity class when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<TagArea {...defaultProps} dataset={dataset} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).not.toHaveClass('opacity-30')
})
it('should show mask when not hovering and has tags', () => {
const { container } = render(<TagArea {...defaultProps} isHoveringTagSelector={false} tags={mockTags} />)
const maskDiv = container.querySelector('.bg-tag-selector-mask-bg')
expect(maskDiv).toBeInTheDocument()
expect(maskDiv).not.toHaveClass('hidden')
})
it('should hide mask when hovering', () => {
const { container } = render(<TagArea {...defaultProps} isHoveringTagSelector={true} />)
// When hovering, the mask div should have 'hidden' class
const maskDiv = container.querySelector('.absolute.right-0.top-0')
expect(maskDiv).toHaveClass('hidden')
})
it('should make TagSelector visible when tags exist', () => {
const { container } = render(<TagArea {...defaultProps} tags={mockTags} />)
const tagSelectorWrapper = container.querySelector('.visible')
expect(tagSelectorWrapper).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle undefined onSuccess', () => {
render(<TagArea {...defaultProps} onSuccess={undefined} />)
// Should not throw when clicking Trigger Change
expect(() => fireEvent.click(screen.getByText('Trigger Change'))).not.toThrow()
})
it('should handle many tags', () => {
const manyTags: Tag[] = Array.from({ length: 20 }, (_, i) => ({
id: `tag-${i}`,
name: `Tag ${i}`,
type: 'knowledge' as const,
binding_count: 0,
}))
render(<TagArea {...defaultProps} tags={manyTags} />)
expect(screen.getByTestId('selected-count')).toHaveTextContent('20 tags')
})
})
})
@@ -0,0 +1,427 @@
import type { DataSet } from '@/models/datasets'
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { useDatasetCardState } from './use-dataset-card-state'
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
// Mock service hooks
const mockCheckUsage = vi.fn()
const mockDeleteDataset = vi.fn()
const mockExportPipeline = vi.fn()
vi.mock('@/service/use-dataset-card', () => ({
useCheckDatasetUsage: () => ({ mutateAsync: mockCheckUsage }),
useDeleteDataset: () => ({ mutateAsync: mockDeleteDataset }),
}))
vi.mock('@/service/use-pipeline', () => ({
useExportPipelineDSL: () => ({ mutateAsync: mockExportPipeline }),
}))
describe('useDatasetCardState', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: 0 }],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
pipeline_id: 'pipeline-1',
...overrides,
} as DataSet)
beforeEach(() => {
vi.clearAllMocks()
mockCheckUsage.mockResolvedValue({ is_using: false })
mockDeleteDataset.mockResolvedValue({})
mockExportPipeline.mockResolvedValue({ data: 'yaml content' })
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('Initial State', () => {
it('should return tags from dataset', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
expect(result.current.tags).toEqual(dataset.tags)
})
it('should have initial modal state closed', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
expect(result.current.modalState.showRenameModal).toBe(false)
expect(result.current.modalState.showConfirmDelete).toBe(false)
expect(result.current.modalState.confirmMessage).toBe('')
})
it('should not be exporting initially', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
expect(result.current.exporting).toBe(false)
})
})
describe('Tags State', () => {
it('should update tags when setTags is called', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
act(() => {
result.current.setTags([{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 }])
})
expect(result.current.tags).toEqual([{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 }])
})
it('should sync tags when dataset tags change', () => {
const dataset = createMockDataset()
const { result, rerender } = renderHook(
({ dataset }) => useDatasetCardState({ dataset, onSuccess: vi.fn() }),
{ initialProps: { dataset } },
)
const newTags = [{ id: 'tag-3', name: 'Tag 3', type: 'knowledge', binding_count: 0 }]
const updatedDataset = createMockDataset({ tags: newTags })
rerender({ dataset: updatedDataset })
expect(result.current.tags).toEqual(newTags)
})
})
describe('Modal Handlers', () => {
it('should open rename modal when openRenameModal is called', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
act(() => {
result.current.openRenameModal()
})
expect(result.current.modalState.showRenameModal).toBe(true)
})
it('should close rename modal when closeRenameModal is called', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
act(() => {
result.current.openRenameModal()
})
act(() => {
result.current.closeRenameModal()
})
expect(result.current.modalState.showRenameModal).toBe(false)
})
it('should close confirm delete modal when closeConfirmDelete is called', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
// First trigger show confirm delete
act(() => {
result.current.detectIsUsedByApp()
})
waitFor(() => {
expect(result.current.modalState.showConfirmDelete).toBe(true)
})
act(() => {
result.current.closeConfirmDelete()
})
expect(result.current.modalState.showConfirmDelete).toBe(false)
})
})
describe('detectIsUsedByApp', () => {
it('should check usage and show confirm modal with not-in-use message', async () => {
mockCheckUsage.mockResolvedValue({ is_using: false })
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(mockCheckUsage).toHaveBeenCalledWith('dataset-1')
expect(result.current.modalState.showConfirmDelete).toBe(true)
expect(result.current.modalState.confirmMessage).toContain('deleteDatasetConfirmContent')
})
it('should show in-use message when dataset is used by app', async () => {
mockCheckUsage.mockResolvedValue({ is_using: true })
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(result.current.modalState.confirmMessage).toContain('datasetUsedByApp')
})
})
describe('onConfirmDelete', () => {
it('should delete dataset and call onSuccess', async () => {
const onSuccess = vi.fn()
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess }),
)
await act(async () => {
await result.current.onConfirmDelete()
})
expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1')
expect(onSuccess).toHaveBeenCalled()
})
it('should close confirm modal after delete', async () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
// First open confirm modal
await act(async () => {
await result.current.detectIsUsedByApp()
})
await act(async () => {
await result.current.onConfirmDelete()
})
expect(result.current.modalState.showConfirmDelete).toBe(false)
})
})
describe('handleExportPipeline', () => {
it('should not export if pipeline_id is missing', async () => {
const dataset = createMockDataset({ pipeline_id: undefined })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline()
})
expect(mockExportPipeline).not.toHaveBeenCalled()
})
it('should export pipeline with correct parameters', async () => {
const dataset = createMockDataset({ pipeline_id: 'pipeline-1', name: 'Test Pipeline' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline(true)
})
expect(mockExportPipeline).toHaveBeenCalledWith({
pipelineId: 'pipeline-1',
include: true,
})
})
})
describe('Edge Cases', () => {
it('should handle empty tags array', () => {
const dataset = createMockDataset({ tags: [] })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
expect(result.current.tags).toEqual([])
})
it('should handle undefined onSuccess', async () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset }),
)
// Should not throw when onSuccess is undefined
await act(async () => {
await result.current.onConfirmDelete()
})
expect(mockDeleteDataset).toHaveBeenCalled()
})
})
describe('Error Handling', () => {
it('should show error toast when export pipeline fails', async () => {
const Toast = await import('@/app/components/base/toast')
mockExportPipeline.mockRejectedValue(new Error('Export failed'))
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
it('should handle Response error in detectIsUsedByApp', async () => {
const Toast = await import('@/app/components/base/toast')
const mockResponse = new Response(JSON.stringify({ message: 'API Error' }), {
status: 400,
})
mockCheckUsage.mockRejectedValue(mockResponse)
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.stringContaining('API Error'),
})
})
it('should handle generic Error in detectIsUsedByApp', async () => {
const Toast = await import('@/app/components/base/toast')
mockCheckUsage.mockRejectedValue(new Error('Network error'))
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: 'Network error',
})
})
it('should handle error without message in detectIsUsedByApp', async () => {
const Toast = await import('@/app/components/base/toast')
mockCheckUsage.mockRejectedValue({})
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: 'Unknown error',
})
})
it('should handle exporting state correctly', async () => {
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
// Exporting should initially be false
expect(result.current.exporting).toBe(false)
// Export should work when not exporting
await act(async () => {
await result.current.handleExportPipeline()
})
expect(mockExportPipeline).toHaveBeenCalled()
})
it('should reset exporting state after export completes', async () => {
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline()
})
expect(result.current.exporting).toBe(false)
})
it('should reset exporting state even when export fails', async () => {
mockExportPipeline.mockRejectedValue(new Error('Export failed'))
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline()
})
expect(result.current.exporting).toBe(false)
})
})
})
@@ -0,0 +1,256 @@
import type { DataSet } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import DatasetCard from './index'
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
// Mock ahooks useHover
vi.mock('ahooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('ahooks')>()
return {
...actual,
useHover: () => false,
}
})
// Mock app context
vi.mock('@/context/app-context', () => ({
useSelector: () => false,
}))
// Mock the useDatasetCardState hook
vi.mock('./hooks/use-dataset-card-state', () => ({
useDatasetCardState: () => ({
tags: [],
setTags: vi.fn(),
modalState: {
showRenameModal: false,
showConfirmDelete: false,
confirmMessage: '',
},
openRenameModal: vi.fn(),
closeRenameModal: vi.fn(),
closeConfirmDelete: vi.fn(),
handleExportPipeline: vi.fn(),
detectIsUsedByApp: vi.fn(),
onConfirmDelete: vi.fn(),
}),
}))
// Mock the RenameDatasetModal
vi.mock('../../rename-modal', () => ({
default: () => null,
}))
// Mock useFormatTimeFromNow hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (timestamp: number) => {
const date = new Date(timestamp)
return date.toLocaleDateString()
},
}),
}))
// Mock useKnowledge hook
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: () => 'High Quality',
}),
}))
describe('DatasetCard', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
is_published: true,
total_available_documents: 10,
icon_info: {
icon: '📙',
icon_type: 'emoji' as const,
icon_background: '#FFF4ED',
icon_url: '',
},
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
},
author_name: 'Test User',
...overrides,
} as DataSet)
const defaultProps = {
dataset: createMockDataset(),
onSuccess: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<DatasetCard {...defaultProps} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should render dataset name', () => {
const dataset = createMockDataset({ name: 'Custom Dataset Name' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Custom Dataset Name')).toBeInTheDocument()
})
it('should render dataset description', () => {
const dataset = createMockDataset({ description: 'Custom Description' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Custom Description')).toBeInTheDocument()
})
it('should render document count', () => {
render(<DatasetCard {...defaultProps} />)
expect(screen.getByText('10')).toBeInTheDocument()
})
it('should render app count', () => {
render(<DatasetCard {...defaultProps} />)
expect(screen.getByText('5')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should handle external provider', () => {
const dataset = createMockDataset({ provider: 'external' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle rag_pipeline runtime mode', () => {
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: true })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should navigate to documents page on click for regular dataset', () => {
const dataset = createMockDataset({ provider: 'vendor' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
fireEvent.click(card!)
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents')
})
it('should navigate to hitTesting page on click for external provider', () => {
const dataset = createMockDataset({ provider: 'external' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
fireEvent.click(card!)
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/hitTesting')
})
it('should navigate to pipeline page when pipeline is unpublished', () => {
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: false })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
fireEvent.click(card!)
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/pipeline')
})
})
describe('Styles', () => {
it('should have correct card styling', () => {
render(<DatasetCard {...defaultProps} />)
const card = screen.getByText('Test Dataset').closest('.group')
expect(card).toHaveClass('h-[190px]', 'cursor-pointer', 'flex-col', 'rounded-xl')
})
it('should have data-disable-nprogress attribute', () => {
render(<DatasetCard {...defaultProps} />)
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
expect(card).toHaveAttribute('data-disable-nprogress', 'true')
})
})
describe('Edge Cases', () => {
it('should handle dataset without description', () => {
const dataset = createMockDataset({ description: '' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle embedding not available', () => {
const dataset = createMockDataset({ embedding_available: false })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle undefined onSuccess', () => {
render(<DatasetCard dataset={createMockDataset()} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
})
describe('Tag Area Click', () => {
it('should stop propagation and prevent default when tag area is clicked', () => {
render(<DatasetCard {...defaultProps} />)
// Find tag area element (it's inside the card)
const tagAreaWrapper = document.querySelector('[class*="px-3"]')
if (tagAreaWrapper) {
const stopPropagationSpy = vi.fn()
const preventDefaultSpy = vi.fn()
const clickEvent = new MouseEvent('click', { bubbles: true })
Object.defineProperty(clickEvent, 'stopPropagation', { value: stopPropagationSpy })
Object.defineProperty(clickEvent, 'preventDefault', { value: preventDefaultSpy })
tagAreaWrapper.dispatchEvent(clickEvent)
expect(stopPropagationSpy).toHaveBeenCalled()
expect(preventDefaultSpy).toHaveBeenCalled()
}
})
it('should not navigate when clicking on tag area', () => {
render(<DatasetCard {...defaultProps} />)
// Click on tag area should not trigger card navigation
const tagArea = document.querySelector('[class*="px-3"]')
if (tagArea) {
fireEvent.click(tagArea)
// mockPush should NOT be called when clicking tag area
// (stopPropagation prevents it from reaching the card click handler)
}
})
})
})
@@ -0,0 +1,87 @@
import { RiEditLine } from '@remixicon/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import OperationItem from './operation-item'
describe('OperationItem', () => {
const defaultProps = {
Icon: RiEditLine,
name: 'Edit',
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<OperationItem {...defaultProps} />)
expect(screen.getByText('Edit')).toBeInTheDocument()
})
it('should render the icon', () => {
const { container } = render(<OperationItem {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
expect(icon).toHaveClass('size-4', 'text-text-tertiary')
})
it('should render the name text', () => {
render(<OperationItem {...defaultProps} />)
const nameSpan = screen.getByText('Edit')
expect(nameSpan).toHaveClass('system-md-regular', 'text-text-secondary')
})
})
describe('Props', () => {
it('should render different name', () => {
render(<OperationItem {...defaultProps} name="Delete" />)
expect(screen.getByText('Delete')).toBeInTheDocument()
})
it('should be callable without handleClick', () => {
render(<OperationItem {...defaultProps} />)
const item = screen.getByText('Edit').closest('div')
expect(() => fireEvent.click(item!)).not.toThrow()
})
})
describe('User Interactions', () => {
it('should call handleClick when clicked', () => {
const handleClick = vi.fn()
render(<OperationItem {...defaultProps} handleClick={handleClick} />)
const item = screen.getByText('Edit').closest('div')
fireEvent.click(item!)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should prevent default and stop propagation on click', () => {
const handleClick = vi.fn()
render(<OperationItem {...defaultProps} handleClick={handleClick} />)
const item = screen.getByText('Edit').closest('div')
const clickEvent = new MouseEvent('click', { bubbles: true })
const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault')
const stopPropagationSpy = vi.spyOn(clickEvent, 'stopPropagation')
item!.dispatchEvent(clickEvent)
expect(preventDefaultSpy).toHaveBeenCalled()
expect(stopPropagationSpy).toHaveBeenCalled()
})
})
describe('Styles', () => {
it('should have correct container styling', () => {
render(<OperationItem {...defaultProps} />)
const item = screen.getByText('Edit').closest('div')
expect(item).toHaveClass('flex', 'cursor-pointer', 'items-center', 'gap-x-1', 'rounded-lg')
})
})
describe('Edge Cases', () => {
it('should handle empty name', () => {
render(<OperationItem {...defaultProps} name="" />)
const container = document.querySelector('.cursor-pointer')
expect(container).toBeInTheDocument()
})
})
})
@@ -0,0 +1,119 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Operations from './operations'
describe('Operations', () => {
const defaultProps = {
showDelete: true,
showExportPipeline: true,
openRenameModal: vi.fn(),
handleExportPipeline: vi.fn(),
detectIsUsedByApp: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Operations {...defaultProps} />)
// Edit operation should always be visible
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
})
it('should render edit operation', () => {
render(<Operations {...defaultProps} />)
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
})
it('should render export pipeline operation when showExportPipeline is true', () => {
render(<Operations {...defaultProps} showExportPipeline={true} />)
expect(screen.getByText(/exportPipeline/)).toBeInTheDocument()
})
it('should not render export pipeline operation when showExportPipeline is false', () => {
render(<Operations {...defaultProps} showExportPipeline={false} />)
expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument()
})
it('should render delete operation when showDelete is true', () => {
render(<Operations {...defaultProps} showDelete={true} />)
expect(screen.getByText(/operation\.delete/)).toBeInTheDocument()
})
it('should not render delete operation when showDelete is false', () => {
render(<Operations {...defaultProps} showDelete={false} />)
expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should render divider when showDelete is true', () => {
const { container } = render(<Operations {...defaultProps} showDelete={true} />)
const divider = container.querySelector('.bg-divider-subtle')
expect(divider).toBeInTheDocument()
})
it('should not render divider when showDelete is false', () => {
const { container } = render(<Operations {...defaultProps} showDelete={false} />)
// Should not have the divider-subtle one (the separator before delete)
expect(container.querySelector('.bg-divider-subtle')).toBeNull()
})
})
describe('User Interactions', () => {
it('should call openRenameModal when edit is clicked', () => {
const openRenameModal = vi.fn()
render(<Operations {...defaultProps} openRenameModal={openRenameModal} />)
const editItem = screen.getByText(/operation\.edit/).closest('div')
fireEvent.click(editItem!)
expect(openRenameModal).toHaveBeenCalledTimes(1)
})
it('should call handleExportPipeline when export is clicked', () => {
const handleExportPipeline = vi.fn()
render(<Operations {...defaultProps} handleExportPipeline={handleExportPipeline} />)
const exportItem = screen.getByText(/exportPipeline/).closest('div')
fireEvent.click(exportItem!)
expect(handleExportPipeline).toHaveBeenCalledTimes(1)
})
it('should call detectIsUsedByApp when delete is clicked', () => {
const detectIsUsedByApp = vi.fn()
render(<Operations {...defaultProps} detectIsUsedByApp={detectIsUsedByApp} />)
const deleteItem = screen.getByText(/operation\.delete/).closest('div')
fireEvent.click(deleteItem!)
expect(detectIsUsedByApp).toHaveBeenCalledTimes(1)
})
})
describe('Styles', () => {
it('should have correct container styling', () => {
const { container } = render(<Operations {...defaultProps} />)
const operationsContainer = container.firstChild
expect(operationsContainer).toHaveClass(
'relative',
'flex',
'w-full',
'flex-col',
'rounded-xl',
)
})
})
describe('Edge Cases', () => {
it('should render only edit when both showDelete and showExportPipeline are false', () => {
render(<Operations {...defaultProps} showDelete={false} showExportPipeline={false} />)
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument()
expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument()
})
})
})
@@ -1,30 +1,52 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import DatasetFooter from './index'
describe('DatasetFooter', () => {
it('should render correctly', () => {
render(<DatasetFooter />)
describe('Rendering', () => {
it('should render without crashing', () => {
render(<DatasetFooter />)
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
})
// Check main title (mocked i18n returns ns:key or key)
// The code uses t('didYouKnow', { ns: 'dataset' })
// With default mock it likely returns 'dataset.didYouKnow'
expect(screen.getByText('dataset.didYouKnow')).toBeInTheDocument()
it('should render the main heading', () => {
render(<DatasetFooter />)
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument()
})
// Check paragraph content
expect(screen.getByText(/dataset.intro1/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro2/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro3/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro4/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro5/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro6/)).toBeInTheDocument()
it('should render description paragraph', () => {
render(<DatasetFooter />)
// The paragraph contains multiple text spans
expect(screen.getByText(/intro1/)).toBeInTheDocument()
})
})
it('should have correct styling', () => {
const { container } = render(<DatasetFooter />)
const footer = container.querySelector('footer')
expect(footer).toHaveClass('shrink-0', 'px-12', 'py-6')
describe('Props', () => {
it('should be memoized', () => {
// DatasetFooter is wrapped with React.memo
expect(DatasetFooter).toBeDefined()
})
})
const h3 = container.querySelector('h3')
expect(h3).toHaveClass('text-gradient')
describe('Styles', () => {
it('should have correct footer styling', () => {
render(<DatasetFooter />)
const footer = screen.getByRole('contentinfo')
expect(footer).toHaveClass('shrink-0', 'px-12', 'py-6')
})
it('should have gradient text on heading', () => {
render(<DatasetFooter />)
const heading = screen.getByRole('heading', { level: 3 })
expect(heading).toHaveClass('text-gradient')
})
})
describe('Content Structure', () => {
it('should render accent spans for highlighted text', () => {
render(<DatasetFooter />)
const accentSpans = document.querySelectorAll('.text-text-accent')
expect(accentSpans.length).toBe(2)
})
})
})
@@ -0,0 +1,485 @@
import type { DataSet } from '@/models/datasets'
import { render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import Datasets from './datasets'
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
// Mock ahooks
vi.mock('ahooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('ahooks')>()
return {
...actual,
useHover: () => false,
}
})
// Mock useFormatTimeFromNow hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (timestamp: number) => new Date(timestamp).toLocaleDateString(),
}),
}))
// Mock useKnowledge hook
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: () => 'High Quality',
}),
}))
// Mock service hooks - will be overridden in individual tests
const mockFetchNextPage = vi.fn()
const mockInvalidDatasetList = vi.fn()
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetList: vi.fn(() => ({
data: {
pages: [
{
data: [
createMockDataset({ id: 'dataset-1', name: 'Dataset 1' }),
createMockDataset({ id: 'dataset-2', name: 'Dataset 2' }),
],
},
],
},
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
})),
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
// Mock app context - will be overridden in tests
vi.mock('@/context/app-context', () => ({
useSelector: vi.fn(() => true),
}))
// Mock useDatasetCardState hook
vi.mock('./dataset-card/hooks/use-dataset-card-state', () => ({
useDatasetCardState: () => ({
tags: [],
setTags: vi.fn(),
modalState: {
showRenameModal: false,
showConfirmDelete: false,
confirmMessage: '',
},
openRenameModal: vi.fn(),
closeRenameModal: vi.fn(),
closeConfirmDelete: vi.fn(),
handleExportPipeline: vi.fn(),
detectIsUsedByApp: vi.fn(),
onConfirmDelete: vi.fn(),
}),
}))
// Mock RenameDatasetModal
vi.mock('../rename-modal', () => ({
default: () => null,
}))
function createMockDataset(overrides: Partial<DataSet> = {}): DataSet {
return {
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
is_published: true,
total_available_documents: 10,
icon_info: {
icon: '📙',
icon_type: 'emoji' as const,
icon_background: '#FFF4ED',
icon_url: '',
},
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
},
author_name: 'Test User',
...overrides,
} as DataSet
}
// Store IntersectionObserver callbacks for testing
let intersectionObserverCallback: IntersectionObserverCallback | null = null
const mockObserve = vi.fn()
const mockDisconnect = vi.fn()
const mockUnobserve = vi.fn()
// Custom IntersectionObserver mock
class MockIntersectionObserver {
constructor(callback: IntersectionObserverCallback) {
intersectionObserverCallback = callback
}
observe = mockObserve
disconnect = mockDisconnect
unobserve = mockUnobserve
root = null
rootMargin = ''
thresholds = []
takeRecords = () => []
}
describe('Datasets', () => {
const defaultProps = {
tags: [],
keywords: '',
includeAll: false,
}
beforeEach(() => {
vi.clearAllMocks()
intersectionObserverCallback = null
document.title = ''
// Setup IntersectionObserver mock
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Datasets {...defaultProps} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('should render NewDatasetCard when user is editor', async () => {
const { useSelector } = await import('@/context/app-context')
vi.mocked(useSelector).mockReturnValue(true)
render(<Datasets {...defaultProps} />)
expect(screen.getByText(/createDataset/)).toBeInTheDocument()
})
it('should NOT render NewDatasetCard when user is NOT editor', async () => {
const { useSelector } = await import('@/context/app-context')
vi.mocked(useSelector).mockReturnValue(false)
render(<Datasets {...defaultProps} />)
expect(screen.queryByText(/createDataset/)).not.toBeInTheDocument()
})
it('should render dataset cards from data', () => {
render(<Datasets {...defaultProps} />)
expect(screen.getByText('Dataset 1')).toBeInTheDocument()
expect(screen.getByText('Dataset 2')).toBeInTheDocument()
})
it('should render anchor div for infinite scroll', () => {
render(<Datasets {...defaultProps} />)
const anchor = document.querySelector('.h-0')
expect(anchor).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass tags to useDatasetList', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
render(<Datasets {...defaultProps} tags={['tag-1', 'tag-2']} />)
expect(useDatasetList).toHaveBeenCalledWith(
expect.objectContaining({
tag_ids: ['tag-1', 'tag-2'],
}),
)
})
it('should pass keywords to useDatasetList', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
render(<Datasets {...defaultProps} keywords="search term" />)
expect(useDatasetList).toHaveBeenCalledWith(
expect.objectContaining({
keyword: 'search term',
}),
)
})
it('should pass includeAll to useDatasetList', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
render(<Datasets {...defaultProps} includeAll={true} />)
expect(useDatasetList).toHaveBeenCalledWith(
expect.objectContaining({
include_all: true,
}),
)
})
})
describe('Document Title', () => {
it('should set document title on mount', async () => {
render(<Datasets {...defaultProps} />)
await waitFor(() => {
expect(document.title).toContain('dataset.knowledge')
})
})
})
describe('Loading States', () => {
it('should show Loading component when isFetchingNextPage is true', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: true,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
// Loading component renders a div with loading classes
const nav = screen.getByRole('navigation')
expect(nav).toBeInTheDocument()
})
it('should NOT show Loading component when isFetchingNextPage is false', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
})
describe('DatasetList null handling', () => {
it('should handle null datasetList gracefully', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: null,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('should handle undefined datasetList gracefully', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: undefined,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('should handle empty pages array', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [] },
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
})
describe('IntersectionObserver', () => {
it('should setup IntersectionObserver on mount', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
// Should observe the anchor element
expect(mockObserve).toHaveBeenCalled()
})
it('should call fetchNextPage when isIntersecting, hasNextPage, and not isFetching', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
// Simulate intersection
if (intersectionObserverCallback) {
intersectionObserverCallback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
}
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
})
it('should NOT call fetchNextPage when isIntersecting is false', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
if (intersectionObserverCallback) {
intersectionObserverCallback(
[{ isIntersecting: false } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('should NOT call fetchNextPage when hasNextPage is false', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: false, // No more pages
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
if (intersectionObserverCallback) {
intersectionObserverCallback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('should NOT call fetchNextPage when isFetching is true', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: true, // Already fetching
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
if (intersectionObserverCallback) {
intersectionObserverCallback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('should disconnect observer on unmount', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
const { unmount } = render(<Datasets {...defaultProps} />)
// Unmount the component
unmount()
// disconnect should be called during cleanup
expect(mockDisconnect).toHaveBeenCalled()
})
})
describe('Styles', () => {
it('should have correct grid styling', () => {
render(<Datasets {...defaultProps} />)
const nav = screen.getByRole('navigation')
expect(nav).toHaveClass('grid', 'grow', 'gap-3', 'px-12')
})
})
describe('Edge Cases', () => {
it('should handle empty tags array', () => {
render(<Datasets {...defaultProps} tags={[]} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('should handle empty keywords', () => {
render(<Datasets {...defaultProps} keywords="" />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('should handle multiple pages of data', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: {
pages: [
{ data: [createMockDataset({ id: 'ds-1', name: 'Page 1 Dataset' })] },
{ data: [createMockDataset({ id: 'ds-2', name: 'Page 2 Dataset' })] },
],
},
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.getByText('Page 1 Dataset')).toBeInTheDocument()
expect(screen.getByText('Page 2 Dataset')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,368 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import List from './index'
// Mock next/navigation
const mockPush = vi.fn()
const mockReplace = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
replace: mockReplace,
}),
}))
// Mock ahooks
vi.mock('ahooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('ahooks')>()
return {
...actual,
useBoolean: () => [false, { toggle: vi.fn(), setTrue: vi.fn(), setFalse: vi.fn() }],
useDebounceFn: (fn: () => void) => ({ run: fn }),
useHover: () => false,
}
})
// Mock app context
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: { role: 'admin' },
isCurrentWorkspaceOwner: true,
}),
useSelector: () => true,
}))
// Mock global public context
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({
systemFeatures: {
branding: { enabled: false },
},
}),
}))
// Mock external api panel context
const mockSetShowExternalApiPanel = vi.fn()
vi.mock('@/context/external-api-panel-context', () => ({
useExternalApiPanel: () => ({
showExternalApiPanel: false,
setShowExternalApiPanel: mockSetShowExternalApiPanel,
}),
}))
// Mock tag management store
vi.mock('@/app/components/base/tag-management/store', () => ({
useStore: () => false,
}))
// Mock useDocumentTitle hook
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
// Mock useFormatTimeFromNow hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (timestamp: number) => new Date(timestamp).toLocaleDateString(),
}),
}))
// Mock useKnowledge hook
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: () => 'High Quality',
}),
}))
// Mock service hooks
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetList: vi.fn(() => ({
data: { pages: [{ data: [] }] },
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
})),
useInvalidDatasetList: () => vi.fn(),
useDatasetApiBaseUrl: () => ({
data: { api_base_url: 'https://api.example.com' },
}),
}))
// Mock Datasets component
vi.mock('./datasets', () => ({
default: ({ tags, keywords, includeAll }: { tags: string[], keywords: string, includeAll: boolean }) => (
<div data-testid="datasets-component">
<span data-testid="tags">{tags.join(',')}</span>
<span data-testid="keywords">{keywords}</span>
<span data-testid="include-all">{includeAll ? 'true' : 'false'}</span>
</div>
),
}))
// Mock DatasetFooter component
vi.mock('./dataset-footer', () => ({
default: () => <footer data-testid="dataset-footer">Footer</footer>,
}))
// Mock ExternalAPIPanel component
vi.mock('../external-api/external-api-panel', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="external-api-panel">
<button onClick={onClose}>Close Panel</button>
</div>
),
}))
// Mock TagManagementModal
vi.mock('@/app/components/base/tag-management', () => ({
default: () => <div data-testid="tag-management-modal" />,
}))
// Mock TagFilter
vi.mock('@/app/components/base/tag-management/filter', () => ({
default: ({ onChange }: { value: string[], onChange: (val: string[]) => void }) => (
<div data-testid="tag-filter">
<button onClick={() => onChange(['tag-1', 'tag-2'])}>Select Tags</button>
</div>
),
}))
// Mock CheckboxWithLabel
vi.mock('@/app/components/datasets/create/website/base/checkbox-with-label', () => ({
default: ({ isChecked, onChange, label }: { isChecked: boolean, onChange: () => void, label: string }) => (
<label>
<input
type="checkbox"
checked={isChecked}
onChange={onChange}
data-testid="include-all-checkbox"
/>
{label}
</label>
),
}))
describe('List', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<List />)
expect(screen.getByTestId('datasets-component')).toBeInTheDocument()
})
it('should render the search input', () => {
render(<List />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render tag filter', () => {
render(<List />)
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
})
it('should render external API panel button', () => {
render(<List />)
expect(screen.getByText(/externalAPIPanelTitle/)).toBeInTheDocument()
})
it('should render dataset footer when branding is disabled', () => {
render(<List />)
expect(screen.getByTestId('dataset-footer')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass includeAll prop to Datasets', () => {
render(<List />)
expect(screen.getByTestId('include-all')).toHaveTextContent('false')
})
it('should pass empty keywords initially', () => {
render(<List />)
expect(screen.getByTestId('keywords')).toHaveTextContent('')
})
it('should pass empty tags initially', () => {
render(<List />)
expect(screen.getByTestId('tags')).toHaveTextContent('')
})
})
describe('User Interactions', () => {
it('should open external API panel when button is clicked', () => {
render(<List />)
const button = screen.getByText(/externalAPIPanelTitle/)
fireEvent.click(button)
expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(true)
})
it('should update search input value', () => {
render(<List />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test search' } })
expect(input).toHaveValue('test search')
})
it('should trigger tag filter change', () => {
render(<List />)
// Tag filter is rendered and interactive
const selectTagsBtn = screen.getByText('Select Tags')
expect(selectTagsBtn).toBeInTheDocument()
fireEvent.click(selectTagsBtn)
// The onChange callback was triggered (debounced)
})
})
describe('Conditional Rendering', () => {
it('should show include all checkbox for workspace owner', () => {
render(<List />)
expect(screen.getByTestId('include-all-checkbox')).toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have correct container styling', () => {
const { container } = render(<List />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('scroll-container', 'relative', 'flex', 'grow', 'flex-col')
})
})
describe('Edge Cases', () => {
it('should handle empty state gracefully', () => {
render(<List />)
// Should render without errors even with empty data
expect(screen.getByTestId('datasets-component')).toBeInTheDocument()
})
})
describe('Branch Coverage', () => {
it('should redirect normal role users to /apps', async () => {
// Re-mock useAppContext with normal role
vi.doMock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: { role: 'normal' },
isCurrentWorkspaceOwner: false,
}),
useSelector: () => true,
}))
// Clear module cache and re-import
vi.resetModules()
const { default: ListComponent } = await import('./index')
render(<ListComponent />)
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/apps')
})
})
it('should clear search input when onClear is called', () => {
render(<List />)
const input = screen.getByRole('textbox')
// First set a value
fireEvent.change(input, { target: { value: 'test search' } })
expect(input).toHaveValue('test search')
// Find and click the clear button
const clearButton = document.querySelector('[class*="clear"], button[aria-label*="clear"]')
if (clearButton) {
fireEvent.click(clearButton)
expect(input).toHaveValue('')
}
})
it('should show ExternalAPIPanel when showExternalApiPanel is true', async () => {
// Re-mock to show external API panel
vi.doMock('@/context/external-api-panel-context', () => ({
useExternalApiPanel: () => ({
showExternalApiPanel: true,
setShowExternalApiPanel: mockSetShowExternalApiPanel,
}),
}))
vi.resetModules()
const { default: ListComponent } = await import('./index')
render(<ListComponent />)
expect(screen.getByTestId('external-api-panel')).toBeInTheDocument()
})
it('should close ExternalAPIPanel when onClose is called', async () => {
vi.doMock('@/context/external-api-panel-context', () => ({
useExternalApiPanel: () => ({
showExternalApiPanel: true,
setShowExternalApiPanel: mockSetShowExternalApiPanel,
}),
}))
vi.resetModules()
const { default: ListComponent } = await import('./index')
render(<ListComponent />)
const closeButton = screen.getByText('Close Panel')
fireEvent.click(closeButton)
expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(false)
})
it('should show TagManagementModal when showTagManagementModal is true', async () => {
vi.doMock('@/app/components/base/tag-management/store', () => ({
useStore: () => true, // showTagManagementModal is true
}))
vi.resetModules()
const { default: ListComponent } = await import('./index')
render(<ListComponent />)
expect(screen.getByTestId('tag-management-modal')).toBeInTheDocument()
})
it('should not show DatasetFooter when branding is enabled', async () => {
vi.doMock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({
systemFeatures: {
branding: { enabled: true },
},
}),
}))
vi.resetModules()
const { default: ListComponent } = await import('./index')
render(<ListComponent />)
expect(screen.queryByTestId('dataset-footer')).not.toBeInTheDocument()
})
it('should not show include all checkbox when not workspace owner', async () => {
vi.doMock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: { role: 'editor' },
isCurrentWorkspaceOwner: false,
}),
useSelector: () => true,
}))
vi.resetModules()
const { default: ListComponent } = await import('./index')
render(<ListComponent />)
expect(screen.queryByTestId('include-all-checkbox')).not.toBeInTheDocument()
})
})
})
+11 -3
View File
@@ -14,13 +14,14 @@ import TagFilter from '@/app/components/base/tag-management/filter'
// Hooks
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
import { useAppContext } from '@/context/app-context'
import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context'
import { useExternalApiPanel } from '@/context/external-api-panel-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset'
// Components
import ExternalAPIPanel from '../external-api/external-api-panel'
import ServiceApi from '../extra-info/service-api'
import DatasetFooter from './dataset-footer'
import Datasets from './datasets'
@@ -58,6 +59,9 @@ const List = () => {
return router.replace('/apps')
}, [currentWorkspace, router])
const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager)
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
return (
<div className="scroll-container relative flex grow flex-col overflow-y-auto bg-background-body">
<div className="sticky top-0 z-10 flex items-center justify-end gap-x-1 bg-background-body px-12 pb-2 pt-4">
@@ -81,6 +85,11 @@ const List = () => {
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
{
isCurrentWorkspaceManager && (
<ServiceApi apiBaseUrl={apiBaseInfo?.api_base_url ?? ''} />
)
}
<div className="h-4 w-[1px] bg-divider-regular" />
<Button
className="shadows-shadow-xs gap-0.5"
@@ -96,7 +105,6 @@ const List = () => {
{showTagManagementModal && (
<TagManagementModal type="knowledge" show={showTagManagementModal} />
)}
{showExternalApiPanel && <ExternalAPIPanel onClose={() => setShowExternalApiPanel(false)} />}
</div>
)
@@ -1,49 +1,76 @@
import { render, screen } from '@testing-library/react'
import NewDatasetCard from './index'
import { describe, expect, it } from 'vitest'
import CreateAppCard from './index'
type MockOptionProps = {
text: string
href: string
}
describe('CreateAppCard', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<CreateAppCard />)
expect(screen.getAllByRole('link')).toHaveLength(3)
})
// Mock dependencies
vi.mock('./option', () => ({
default: ({ text, href }: MockOptionProps) => (
<a data-testid="option-link" href={href}>
{text}
</a>
),
}))
it('should render create dataset option', () => {
render(<CreateAppCard />)
expect(screen.getByText(/createDataset/)).toBeInTheDocument()
})
vi.mock('@remixicon/react', () => ({
RiAddLine: () => <svg data-testid="icon-add" />,
RiFunctionAddLine: () => <svg data-testid="icon-function" />,
}))
it('should render create from pipeline option', () => {
render(<CreateAppCard />)
expect(screen.getByText(/createFromPipeline/)).toBeInTheDocument()
})
vi.mock('@/app/components/base/icons/src/vender/solid/development', () => ({
ApiConnectionMod: () => <svg data-testid="icon-api" />,
}))
it('should render connect dataset option', () => {
render(<CreateAppCard />)
expect(screen.getByText(/connectDataset/)).toBeInTheDocument()
})
})
describe('NewDatasetCard', () => {
it('should render all options', () => {
render(<NewDatasetCard />)
describe('Props', () => {
it('should have correct displayName', () => {
expect(CreateAppCard.displayName).toBe('CreateAppCard')
})
})
const options = screen.getAllByTestId('option-link')
expect(options).toHaveLength(3)
describe('Links', () => {
it('should have correct href for create dataset', () => {
render(<CreateAppCard />)
const links = screen.getAllByRole('link')
expect(links[0]).toHaveAttribute('href', '/datasets/create')
})
// Check first option (Create Dataset)
const createDataset = options[0]
expect(createDataset).toHaveAttribute('href', '/datasets/create')
expect(createDataset).toHaveTextContent('dataset.createDataset')
it('should have correct href for create from pipeline', () => {
render(<CreateAppCard />)
const links = screen.getAllByRole('link')
expect(links[1]).toHaveAttribute('href', '/datasets/create-from-pipeline')
})
// Check second option (Create from Pipeline)
const createFromPipeline = options[1]
expect(createFromPipeline).toHaveAttribute('href', '/datasets/create-from-pipeline')
expect(createFromPipeline).toHaveTextContent('dataset.createFromPipeline')
it('should have correct href for connect dataset', () => {
render(<CreateAppCard />)
const links = screen.getAllByRole('link')
expect(links[2]).toHaveAttribute('href', '/datasets/connect')
})
})
// Check third option (Connect Dataset)
const connectDataset = options[2]
expect(connectDataset).toHaveAttribute('href', '/datasets/connect')
expect(connectDataset).toHaveTextContent('dataset.connectDataset')
describe('Styles', () => {
it('should have correct card styling', () => {
const { container } = render(<CreateAppCard />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('h-[190px]', 'flex', 'flex-col', 'rounded-xl')
})
it('should have border separator for connect option', () => {
const { container } = render(<CreateAppCard />)
const borderDiv = container.querySelector('.border-t-\\[0\\.5px\\]')
expect(borderDiv).toBeInTheDocument()
})
})
describe('Icons', () => {
it('should render three icons for three options', () => {
const { container } = render(<CreateAppCard />)
// Each option has an icon
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThanOrEqual(3)
})
})
})
@@ -0,0 +1,78 @@
import { RiAddLine } from '@remixicon/react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Option from './option'
describe('Option', () => {
const defaultProps = {
Icon: RiAddLine,
text: 'Test Option',
href: '/test-path',
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Option {...defaultProps} />)
expect(screen.getByRole('link')).toBeInTheDocument()
})
it('should render the text content', () => {
render(<Option {...defaultProps} />)
expect(screen.getByText('Test Option')).toBeInTheDocument()
})
it('should render the icon', () => {
render(<Option {...defaultProps} />)
// Icon should be rendered with correct size class
const icon = document.querySelector('.h-4.w-4')
expect(icon).toBeInTheDocument()
})
})
describe('Props', () => {
it('should have correct href attribute', () => {
render(<Option {...defaultProps} />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', '/test-path')
})
it('should render different text based on props', () => {
render(<Option {...defaultProps} text="Different Text" />)
expect(screen.getByText('Different Text')).toBeInTheDocument()
})
it('should render different href based on props', () => {
render(<Option {...defaultProps} href="/different-path" />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', '/different-path')
})
})
describe('Styles', () => {
it('should have correct base styling', () => {
render(<Option {...defaultProps} />)
const link = screen.getByRole('link')
expect(link).toHaveClass('flex', 'w-full', 'items-center', 'gap-x-2', 'rounded-lg')
})
it('should have text span with correct styling', () => {
render(<Option {...defaultProps} />)
const textSpan = screen.getByText('Test Option')
expect(textSpan).toHaveClass('system-sm-medium', 'grow', 'text-left')
})
})
describe('Edge Cases', () => {
it('should handle empty text', () => {
render(<Option {...defaultProps} text="" />)
const link = screen.getByRole('link')
expect(link).toBeInTheDocument()
})
it('should handle long text', () => {
const longText = 'A'.repeat(100)
render(<Option {...defaultProps} text={longText} />)
expect(screen.getByText(longText)).toBeInTheDocument()
})
})
})
@@ -0,0 +1,92 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import AddedMetadataButton from './add-metadata-button'
describe('AddedMetadataButton', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<AddedMetadataButton />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render with translated text', () => {
render(<AddedMetadataButton />)
// The button should contain text from i18n
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render add icon', () => {
const { container } = render(<AddedMetadataButton />)
// Check if there's an SVG element (the RiAddLine icon)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
render(<AddedMetadataButton className="custom-class" />)
const button = screen.getByRole('button')
expect(button).toHaveClass('custom-class')
})
it('should apply default classes', () => {
render(<AddedMetadataButton />)
const button = screen.getByRole('button')
expect(button).toHaveClass('flex', 'w-full', 'items-center')
})
it('should merge custom className with default classes', () => {
render(<AddedMetadataButton className="my-custom-class" />)
const button = screen.getByRole('button')
expect(button).toHaveClass('flex', 'w-full', 'items-center', 'my-custom-class')
})
})
describe('User Interactions', () => {
it('should call onClick when button is clicked', () => {
const handleClick = vi.fn()
render(<AddedMetadataButton onClick={handleClick} />)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should not throw when onClick is not provided and button is clicked', () => {
render(<AddedMetadataButton />)
expect(() => {
fireEvent.click(screen.getByRole('button'))
}).not.toThrow()
})
it('should call onClick multiple times on multiple clicks', () => {
const handleClick = vi.fn()
render(<AddedMetadataButton onClick={handleClick} />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(3)
})
})
describe('Edge Cases', () => {
it('should render with undefined className', () => {
render(<AddedMetadataButton className={undefined} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render with empty className', () => {
render(<AddedMetadataButton className="" />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render with undefined onClick', () => {
render(<AddedMetadataButton onClick={undefined} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,287 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import WrappedDatePicker from './date-picker'
type TriggerArgs = {
handleClickTrigger: () => void
}
type DatePickerProps = {
onChange: (value: Date | null) => void
onClear: () => void
renderTrigger: (args: TriggerArgs) => React.ReactNode
value?: Date
}
// Mock the base date picker component
vi.mock('@/app/components/base/date-and-time-picker/date-picker', () => ({
default: ({ onChange, onClear, renderTrigger, value }: DatePickerProps) => {
const trigger = renderTrigger({
handleClickTrigger: () => {},
})
return (
<div data-testid="date-picker-wrapper">
{trigger}
<button data-testid="select-date" onClick={() => onChange(value || null)}>
Select Date
</button>
<button data-testid="clear-date" onClick={() => onClear()}>
Clear
</button>
</div>
)
},
}))
// Mock useTimestamp hook
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: (timestamp: number) => {
if (!timestamp)
return ''
return new Date(timestamp * 1000).toLocaleDateString()
},
}),
}))
describe('WrappedDatePicker', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should render placeholder text when no value', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker onChange={handleChange} />)
// When no value, should show placeholder from i18n
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should render formatted date when value is provided', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should render calendar icon', () => {
const handleChange = vi.fn()
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should render select date button', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker onChange={handleChange} />)
expect(screen.getByTestId('select-date')).toBeInTheDocument()
})
it('should render clear date button', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker onChange={handleChange} />)
expect(screen.getByTestId('clear-date')).toBeInTheDocument()
})
it('should render close icon for clearing', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
const { container } = render(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
// RiCloseCircleFill should be rendered
const closeIcon = container.querySelectorAll('svg')
expect(closeIcon.length).toBeGreaterThan(0)
})
})
describe('Props', () => {
it('should apply custom className', () => {
const handleChange = vi.fn()
const { container } = render(
<WrappedDatePicker className="custom-class" onChange={handleChange} />,
)
const triggerElement = container.querySelector('.custom-class')
expect(triggerElement).toBeInTheDocument()
})
it('should accept undefined value', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker value={undefined} onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should accept number value', () => {
const handleChange = vi.fn()
const timestamp = 1609459200 // 2021-01-01
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onChange with timestamp when date is selected', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
fireEvent.click(screen.getByTestId('select-date'))
expect(handleChange).toHaveBeenCalled()
})
it('should call onChange with null when date is cleared via onClear', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
fireEvent.click(screen.getByTestId('clear-date'))
expect(handleChange).toHaveBeenCalledWith(null)
})
it('should call onChange with null when close icon is clicked directly', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
const { container } = render(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
// Find the RiCloseCircleFill icon (it has specific classes)
const closeIcon = container.querySelector('.cursor-pointer.hover\\:text-components-input-text-filled')
if (closeIcon) {
fireEvent.click(closeIcon)
expect(handleChange).toHaveBeenCalledWith(null)
}
})
it('should show close button on hover when value exists', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
const { container } = render(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
// The close icon should be present but hidden initially
const triggerGroup = container.querySelector('.group')
expect(triggerGroup).toBeInTheDocument()
})
it('should handle clicking on trigger element', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
const { container } = render(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
const trigger = container.querySelector('.group.flex')
if (trigger)
fireEvent.click(trigger)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have tertiary text color when no value', () => {
const handleChange = vi.fn()
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
const textElement = container.querySelector('.text-text-tertiary')
expect(textElement).toBeInTheDocument()
})
it('should have secondary text color when value exists', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
const { container } = render(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
const textElement = container.querySelector('.text-text-secondary')
expect(textElement).toBeInTheDocument()
})
it('should have input background styling', () => {
const handleChange = vi.fn()
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
const bgElement = container.querySelector('.bg-components-input-bg-normal')
expect(bgElement).toBeInTheDocument()
})
it('should have quaternary text color for close icon when value exists', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
const { container } = render(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
const closeIcon = container.querySelector('.text-text-quaternary')
expect(closeIcon).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle timestamp of 0', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker value={0} onChange={handleChange} />)
// 0 is falsy but is a valid timestamp (epoch)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should handle very large timestamp', () => {
const handleChange = vi.fn()
const farFuture = 4102444800 // 2100-01-01
render(<WrappedDatePicker value={farFuture} onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should handle switching between no value and value', () => {
const handleChange = vi.fn()
const { rerender } = render(
<WrappedDatePicker onChange={handleChange} />,
)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
const timestamp = Math.floor(Date.now() / 1000)
rerender(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should handle clearing date multiple times', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
fireEvent.click(screen.getByTestId('clear-date'))
fireEvent.click(screen.getByTestId('clear-date'))
fireEvent.click(screen.getByTestId('clear-date'))
expect(handleChange).toHaveBeenCalledTimes(3)
})
it('should handle rapid date selections', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
fireEvent.click(screen.getByTestId('select-date'))
fireEvent.click(screen.getByTestId('select-date'))
fireEvent.click(screen.getByTestId('select-date'))
expect(handleChange).toHaveBeenCalledTimes(3)
})
it('should handle onChange with date object that has unix method', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker onChange={handleChange} />)
// The mock triggers onChange with the value prop
fireEvent.click(screen.getByTestId('select-date'))
// onChange should have been called
expect(handleChange).toHaveBeenCalled()
})
})
})
@@ -0,0 +1,257 @@
import type { MetadataItemWithEdit } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import AddRow from './add-row'
type InputCombinedProps = {
type: DataType
value: string | number | null
onChange: (value: string | number) => void
}
type LabelProps = {
text: string
}
// Mock InputCombined component
vi.mock('./input-combined', () => ({
default: ({ type, value, onChange }: InputCombinedProps) => (
<input
data-testid="input-combined"
data-type={type}
value={value || ''}
onChange={e => onChange(e.target.value)}
/>
),
}))
// Mock Label component
vi.mock('./label', () => ({
default: ({ text }: LabelProps) => <div data-testid="label">{text}</div>,
}))
describe('AddRow', () => {
const mockPayload: MetadataItemWithEdit = {
id: 'test-id',
name: 'test_field',
type: DataType.string,
value: 'test value',
}
describe('Rendering', () => {
it('should render without crashing', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render label with payload name', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('label')).toHaveTextContent('test_field')
})
it('should render input combined component', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
})
it('should render remove button icon', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should pass correct type to input combined', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.string)
})
it('should pass correct value to input combined', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toHaveValue('test value')
})
})
describe('Props', () => {
it('should apply custom className', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow
payload={mockPayload}
onChange={handleChange}
onRemove={handleRemove}
className="custom-class"
/>,
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should have default flex styling', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(container.firstChild).toHaveClass('flex', 'h-6', 'items-center', 'space-x-0.5')
})
it('should handle different data types', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const numberPayload: MetadataItemWithEdit = {
...mockPayload,
type: DataType.number,
value: 42,
}
render(
<AddRow payload={numberPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.number)
})
})
describe('User Interactions', () => {
it('should call onChange with updated payload when input changes', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'new value' } })
expect(handleChange).toHaveBeenCalledWith({
...mockPayload,
value: 'new value',
})
})
it('should call onRemove when remove button is clicked', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
const removeButton = container.querySelector('.cursor-pointer')
if (removeButton)
fireEvent.click(removeButton)
expect(handleRemove).toHaveBeenCalledTimes(1)
})
it('should preserve other payload properties on change', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'updated' } })
expect(handleChange).toHaveBeenCalledWith(
expect.objectContaining({
id: 'test-id',
name: 'test_field',
type: DataType.string,
}),
)
})
})
describe('Remove Button Styling', () => {
it('should have hover styling on remove button', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
const removeButton = container.querySelector('.cursor-pointer')
expect(removeButton).toHaveClass('hover:bg-state-destructive-hover', 'hover:text-text-destructive')
})
})
describe('Edge Cases', () => {
it('should handle null value', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const nullPayload: MetadataItemWithEdit = {
...mockPayload,
value: null,
}
render(
<AddRow payload={nullPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
})
it('should handle empty string value', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const emptyPayload: MetadataItemWithEdit = {
...mockPayload,
value: '',
}
render(
<AddRow payload={emptyPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toHaveValue('')
})
it('should handle time type payload', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const timePayload: MetadataItemWithEdit = {
...mockPayload,
type: DataType.time,
value: 1609459200,
}
render(
<AddRow payload={timePayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.time)
})
it('should handle multiple onRemove calls', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
const removeButton = container.querySelector('.cursor-pointer')
if (removeButton) {
fireEvent.click(removeButton)
fireEvent.click(removeButton)
fireEvent.click(removeButton)
}
expect(handleRemove).toHaveBeenCalledTimes(3)
})
})
})
@@ -0,0 +1,395 @@
import type { MetadataItemWithEdit } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType, UpdateType } from '../types'
import EditMetadatabatchItem from './edit-row'
type InputCombinedProps = {
type: DataType
value: string | number | null
onChange: (value: string | number) => void
readOnly?: boolean
}
type MultipleValueInputProps = {
onClear: () => void
readOnly?: boolean
}
type LabelProps = {
text: string
isDeleted?: boolean
}
type EditedBeaconProps = {
onReset: () => void
}
// Mock InputCombined component
vi.mock('./input-combined', () => ({
default: ({ type, value, onChange, readOnly }: InputCombinedProps) => (
<input
data-testid="input-combined"
data-type={type}
value={value || ''}
onChange={e => onChange(e.target.value)}
readOnly={readOnly}
/>
),
}))
// Mock InputHasSetMultipleValue component
vi.mock('./input-has-set-multiple-value', () => ({
default: ({ onClear, readOnly }: MultipleValueInputProps) => (
<div data-testid="multiple-value-input" data-readonly={readOnly}>
<button data-testid="clear-multiple" onClick={onClear}>Clear Multiple</button>
</div>
),
}))
// Mock Label component
vi.mock('./label', () => ({
default: ({ text, isDeleted }: LabelProps) => (
<div data-testid="label" data-deleted={isDeleted}>{text}</div>
),
}))
// Mock EditedBeacon component
vi.mock('./edited-beacon', () => ({
default: ({ onReset }: EditedBeaconProps) => (
<button data-testid="edited-beacon" onClick={onReset}>Reset</button>
),
}))
describe('EditMetadatabatchItem', () => {
const mockPayload: MetadataItemWithEdit = {
id: 'test-id',
name: 'test_field',
type: DataType.string,
value: 'test value',
isMultipleValue: false,
isUpdated: false,
}
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render label with payload name', () => {
render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('label')).toHaveTextContent('test_field')
})
it('should render input combined for single value', () => {
render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
})
it('should render multiple value input when isMultipleValue is true', () => {
const multiplePayload: MetadataItemWithEdit = {
...mockPayload,
isMultipleValue: true,
}
render(
<EditMetadatabatchItem
payload={multiplePayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('multiple-value-input')).toBeInTheDocument()
})
it('should render delete button icon', () => {
const { container } = render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})
describe('Updated State', () => {
it('should show edited beacon when isUpdated is true', () => {
const updatedPayload: MetadataItemWithEdit = {
...mockPayload,
isUpdated: true,
}
render(
<EditMetadatabatchItem
payload={updatedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('edited-beacon')).toBeInTheDocument()
})
it('should not show edited beacon when isUpdated is false', () => {
render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.queryByTestId('edited-beacon')).not.toBeInTheDocument()
})
})
describe('Deleted State', () => {
it('should pass isDeleted to label when updateType is delete', () => {
const deletedPayload: MetadataItemWithEdit = {
...mockPayload,
updateType: UpdateType.delete,
}
render(
<EditMetadatabatchItem
payload={deletedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('label')).toHaveAttribute('data-deleted', 'true')
})
it('should set readOnly on input when deleted', () => {
const deletedPayload: MetadataItemWithEdit = {
...mockPayload,
updateType: UpdateType.delete,
}
render(
<EditMetadatabatchItem
payload={deletedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('input-combined')).toHaveAttribute('readonly')
})
it('should have destructive styling on delete button when deleted', () => {
const deletedPayload: MetadataItemWithEdit = {
...mockPayload,
updateType: UpdateType.delete,
}
const { container } = render(
<EditMetadatabatchItem
payload={deletedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
const deleteButton = container.querySelector('.bg-state-destructive-hover')
expect(deleteButton).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onChange with updated payload when input changes', () => {
const handleChange = vi.fn()
render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={handleChange}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'new value' } })
expect(handleChange).toHaveBeenCalledWith(
expect.objectContaining({
...mockPayload,
value: 'new value',
}),
)
})
it('should call onRemove with id when delete button is clicked', () => {
const handleRemove = vi.fn()
const { container } = render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={handleRemove}
onReset={vi.fn()}
/>,
)
const deleteButton = container.querySelector('.cursor-pointer')
if (deleteButton)
fireEvent.click(deleteButton)
expect(handleRemove).toHaveBeenCalledWith('test-id')
})
it('should call onReset with id when reset beacon is clicked', () => {
const handleReset = vi.fn()
const updatedPayload: MetadataItemWithEdit = {
...mockPayload,
isUpdated: true,
}
render(
<EditMetadatabatchItem
payload={updatedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={handleReset}
/>,
)
fireEvent.click(screen.getByTestId('edited-beacon'))
expect(handleReset).toHaveBeenCalledWith('test-id')
})
it('should call onChange to clear multiple value', () => {
const handleChange = vi.fn()
const multiplePayload: MetadataItemWithEdit = {
...mockPayload,
isMultipleValue: true,
}
render(
<EditMetadatabatchItem
payload={multiplePayload}
onChange={handleChange}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('clear-multiple'))
expect(handleChange).toHaveBeenCalledWith(
expect.objectContaining({
value: null,
isMultipleValue: false,
}),
)
})
})
describe('Multiple Value State', () => {
it('should render multiple value input when isMultipleValue is true', () => {
const multiplePayload: MetadataItemWithEdit = {
...mockPayload,
isMultipleValue: true,
}
render(
<EditMetadatabatchItem
payload={multiplePayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('multiple-value-input')).toBeInTheDocument()
expect(screen.queryByTestId('input-combined')).not.toBeInTheDocument()
})
it('should pass readOnly to multiple value input when deleted', () => {
const multipleDeletedPayload: MetadataItemWithEdit = {
...mockPayload,
isMultipleValue: true,
updateType: UpdateType.delete,
}
render(
<EditMetadatabatchItem
payload={multipleDeletedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('multiple-value-input')).toHaveAttribute('data-readonly', 'true')
})
})
describe('Edge Cases', () => {
it('should handle payload with number type', () => {
const numberPayload: MetadataItemWithEdit = {
...mockPayload,
type: DataType.number,
value: 42,
}
render(
<EditMetadatabatchItem
payload={numberPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.number)
})
it('should handle payload with time type', () => {
const timePayload: MetadataItemWithEdit = {
...mockPayload,
type: DataType.time,
value: 1609459200,
}
render(
<EditMetadatabatchItem
payload={timePayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.time)
})
it('should handle null value', () => {
const nullPayload: MetadataItemWithEdit = {
...mockPayload,
value: null,
}
render(
<EditMetadatabatchItem
payload={nullPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,179 @@
import { fireEvent, render, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import EditedBeacon from './edited-beacon'
describe('EditedBeacon', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with correct size', () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
expect(container.firstChild).toHaveClass('size-4', 'cursor-pointer')
})
it('should render beacon dot by default (not hovering)', () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
// When not hovering, should show the small beacon dot
const beaconDot = container.querySelector('.size-1')
expect(beaconDot).toBeInTheDocument()
})
})
describe('Hover State', () => {
it('should show reset icon on hover', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
fireEvent.mouseEnter(wrapper)
await waitFor(() => {
// On hover, should show the reset icon (RiResetLeftLine)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})
it('should show beacon dot when not hovering', () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
// By default (not hovering), should show beacon dot
const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
expect(beaconDot).toBeInTheDocument()
})
it('should hide beacon dot on hover', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
fireEvent.mouseEnter(wrapper)
await waitFor(() => {
// On hover, the small beacon dot should be hidden
const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
expect(beaconDot).not.toBeInTheDocument()
})
})
it('should show beacon dot again on mouse leave', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
// Hover
fireEvent.mouseEnter(wrapper)
await waitFor(() => {
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
// Leave
fireEvent.mouseLeave(wrapper)
await waitFor(() => {
const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
expect(beaconDot).toBeInTheDocument()
})
})
})
describe('User Interactions', () => {
it('should call onReset when reset button is clicked', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
// Hover to show reset button
fireEvent.mouseEnter(wrapper)
await waitFor(() => {
const resetButton = container.querySelector('.bg-text-accent-secondary')
expect(resetButton).toBeInTheDocument()
})
// Find and click the reset button (the clickable element with onClick)
const clickableElement = container.querySelector('.flex.size-4.items-center.justify-center.rounded-full.bg-text-accent-secondary')
if (clickableElement) {
fireEvent.click(clickableElement)
}
expect(handleReset).toHaveBeenCalledTimes(1)
})
it('should not call onReset when clicking beacon dot (not hovering)', () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
// Click on the wrapper when not hovering
const wrapper = container.firstChild as HTMLElement
fireEvent.click(wrapper)
// onReset should not be called because we're not hovering
expect(handleReset).not.toHaveBeenCalled()
})
})
describe('Tooltip', () => {
it('should render tooltip on hover', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
fireEvent.mouseEnter(wrapper)
// Tooltip should be rendered (it wraps the reset button)
await waitFor(() => {
const resetIcon = container.querySelector('svg')
expect(resetIcon).toBeInTheDocument()
})
})
})
describe('Edge Cases', () => {
it('should handle multiple hover/leave cycles', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
for (let i = 0; i < 3; i++) {
fireEvent.mouseEnter(wrapper)
await waitFor(() => {
expect(container.querySelector('svg')).toBeInTheDocument()
})
fireEvent.mouseLeave(wrapper)
await waitFor(() => {
expect(container.querySelector('.size-1.rounded-full')).toBeInTheDocument()
})
}
})
it('should handle rapid hover/leave', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
// Rapid hover/leave
fireEvent.mouseEnter(wrapper)
fireEvent.mouseLeave(wrapper)
fireEvent.mouseEnter(wrapper)
await waitFor(() => {
expect(container.querySelector('svg')).toBeInTheDocument()
})
})
})
})
@@ -0,0 +1,269 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import InputCombined from './input-combined'
type DatePickerProps = {
value: number | null
onChange: (value: number) => void
className?: string
}
// Mock the base date-picker component
vi.mock('../base/date-picker', () => ({
default: ({ value, onChange, className }: DatePickerProps) => (
<div data-testid="date-picker" className={className} onClick={() => onChange(Date.now())}>
{value || 'Pick date'}
</div>
),
}))
describe('InputCombined', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const handleChange = vi.fn()
const { container } = render(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render text input for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="test" onChange={handleChange} />,
)
const input = screen.getByDisplayValue('test')
expect(input).toBeInTheDocument()
expect(input.tagName.toLowerCase()).toBe('input')
})
it('should render number input for number type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
)
const input = screen.getByDisplayValue('42')
expect(input).toBeInTheDocument()
})
it('should render date picker for time type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.time} value={Date.now()} onChange={handleChange} />,
)
expect(screen.getByTestId('date-picker')).toBeInTheDocument()
})
})
describe('String Input', () => {
it('should call onChange with input value for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new value' } })
expect(handleChange).toHaveBeenCalledWith('new value')
})
it('should display current value for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="existing value" onChange={handleChange} />,
)
expect(screen.getByDisplayValue('existing value')).toBeInTheDocument()
})
it('should apply readOnly prop to string input', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="test" onChange={handleChange} readOnly />,
)
const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('readonly')
})
})
describe('Number Input', () => {
it('should call onChange with number value for number type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '123' } })
expect(handleChange).toHaveBeenCalled()
})
it('should display current value for number type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={999} onChange={handleChange} />,
)
expect(screen.getByDisplayValue('999')).toBeInTheDocument()
})
it('should apply readOnly prop to number input', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={42} onChange={handleChange} readOnly />,
)
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('readonly')
})
})
describe('Time/Date Input', () => {
it('should render date picker for time type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.time} value={1234567890} onChange={handleChange} />,
)
expect(screen.getByTestId('date-picker')).toBeInTheDocument()
})
it('should call onChange when date is selected', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.time} value={null} onChange={handleChange} />,
)
fireEvent.click(screen.getByTestId('date-picker'))
expect(handleChange).toHaveBeenCalled()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const handleChange = vi.fn()
const { container } = render(
<InputCombined
type={DataType.string}
value=""
onChange={handleChange}
className="custom-class"
/>,
)
// Check that custom class is applied to wrapper
const wrapper = container.querySelector('.custom-class')
expect(wrapper).toBeInTheDocument()
})
it('should handle null value for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value={null} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('should handle undefined value for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value={undefined as unknown as string} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('should handle null value for number type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={null} onChange={handleChange} />,
)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have correct base styling for string input', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
expect(input).toHaveClass('h-6', 'grow', 'p-0.5', 'text-xs', 'rounded-md')
})
it('should have correct styling for number input', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
)
const input = screen.getByRole('spinbutton')
expect(input).toHaveClass('rounded-l-md')
})
})
describe('Edge Cases', () => {
it('should handle empty string value', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('')
})
it('should handle zero value for number', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
)
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
})
it('should handle negative number', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={-100} onChange={handleChange} />,
)
expect(screen.getByDisplayValue('-100')).toBeInTheDocument()
})
it('should handle special characters in string', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value={'<script>alert("xss")</script>'} onChange={handleChange} />,
)
expect(screen.getByDisplayValue('<script>alert("xss")</script>')).toBeInTheDocument()
})
it('should handle switching between types', () => {
const handleChange = vi.fn()
const { rerender } = render(
<InputCombined type={DataType.string} value="test" onChange={handleChange} />,
)
expect(screen.getByRole('textbox')).toBeInTheDocument()
rerender(
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
)
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,147 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import InputHasSetMultipleValue from './input-has-set-multiple-value'
describe('InputHasSetMultipleValue', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with correct wrapper styling', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
expect(container.firstChild).toHaveClass('h-6', 'grow', 'rounded-md', 'bg-components-input-bg-normal', 'p-0.5')
})
it('should render multiple value text', () => {
const handleClear = vi.fn()
render(<InputHasSetMultipleValue onClear={handleClear} />)
// The text should come from i18n
expect(screen.getByText(/multipleValue|Multiple/i)).toBeInTheDocument()
})
it('should render close icon when not readOnly', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
// Should have close icon (RiCloseLine)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})
describe('Props', () => {
it('should not show close icon when readOnly is true', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
// Should not have close icon
const svg = container.querySelector('svg')
expect(svg).not.toBeInTheDocument()
})
it('should show close icon when readOnly is false', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly={false} />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should show close icon when readOnly is undefined', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly={undefined} />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should apply pr-1.5 padding when readOnly', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
const badge = container.querySelector('.inline-flex')
expect(badge).toHaveClass('pr-1.5')
})
it('should apply pr-0.5 padding when not readOnly', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
const badge = container.querySelector('.inline-flex')
expect(badge).toHaveClass('pr-0.5')
})
})
describe('User Interactions', () => {
it('should call onClear when close icon is clicked', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
const closeIcon = container.querySelector('svg')
expect(closeIcon).toBeInTheDocument()
if (closeIcon) {
fireEvent.click(closeIcon)
}
expect(handleClear).toHaveBeenCalledTimes(1)
})
it('should not call onClear when readOnly and clicking on component', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
// Click on the wrapper
fireEvent.click(container.firstChild as HTMLElement)
expect(handleClear).not.toHaveBeenCalled()
})
it('should call onClear multiple times on multiple clicks', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
const closeIcon = container.querySelector('svg')
if (closeIcon) {
fireEvent.click(closeIcon)
fireEvent.click(closeIcon)
fireEvent.click(closeIcon)
}
expect(handleClear).toHaveBeenCalledTimes(3)
})
})
describe('Styling', () => {
it('should have badge styling', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
const badge = container.querySelector('.inline-flex')
expect(badge).toHaveClass('h-5', 'items-center', 'rounded-[5px]', 'border-[0.5px]')
})
it('should have hover styles on close button wrapper', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
const closeWrapper = container.querySelector('.cursor-pointer')
expect(closeWrapper).toHaveClass('hover:bg-state-base-hover', 'hover:text-text-secondary')
})
})
describe('Edge Cases', () => {
it('should render correctly when switching readOnly state', () => {
const handleClear = vi.fn()
const { container, rerender } = render(<InputHasSetMultipleValue onClear={handleClear} />)
// Initially not readOnly
expect(container.querySelector('svg')).toBeInTheDocument()
// Switch to readOnly
rerender(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
expect(container.querySelector('svg')).not.toBeInTheDocument()
// Switch back to not readOnly
rerender(<InputHasSetMultipleValue onClear={handleClear} readOnly={false} />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,113 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Label from './label'
describe('Label', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Label text="Test Label" />)
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
it('should render text with correct styling', () => {
render(<Label text="My Label" />)
const labelElement = screen.getByText('My Label')
expect(labelElement).toHaveClass('system-xs-medium', 'w-[136px]', 'shrink-0', 'truncate', 'text-text-tertiary')
})
it('should not have deleted styling by default', () => {
render(<Label text="Label" />)
const labelElement = screen.getByText('Label')
expect(labelElement).not.toHaveClass('text-text-quaternary', 'line-through')
})
})
describe('Props', () => {
it('should apply custom className', () => {
render(<Label text="Label" className="custom-class" />)
const labelElement = screen.getByText('Label')
expect(labelElement).toHaveClass('custom-class')
})
it('should merge custom className with default classes', () => {
render(<Label text="Label" className="my-custom-class" />)
const labelElement = screen.getByText('Label')
expect(labelElement).toHaveClass('system-xs-medium', 'my-custom-class')
})
it('should apply deleted styling when isDeleted is true', () => {
render(<Label text="Label" isDeleted />)
const labelElement = screen.getByText('Label')
expect(labelElement).toHaveClass('text-text-quaternary', 'line-through')
})
it('should not apply deleted styling when isDeleted is false', () => {
render(<Label text="Label" isDeleted={false} />)
const labelElement = screen.getByText('Label')
expect(labelElement).not.toHaveClass('text-text-quaternary', 'line-through')
})
it('should render different text values', () => {
const { rerender } = render(<Label text="First" />)
expect(screen.getByText('First')).toBeInTheDocument()
rerender(<Label text="Second" />)
expect(screen.getByText('Second')).toBeInTheDocument()
})
})
describe('Deleted State', () => {
it('should have strikethrough when deleted', () => {
render(<Label text="Deleted Label" isDeleted />)
const labelElement = screen.getByText('Deleted Label')
expect(labelElement).toHaveClass('line-through')
})
it('should have quaternary text color when deleted', () => {
render(<Label text="Deleted Label" isDeleted />)
const labelElement = screen.getByText('Deleted Label')
expect(labelElement).toHaveClass('text-text-quaternary')
})
it('should combine deleted styling with custom className', () => {
render(<Label text="Label" isDeleted className="custom" />)
const labelElement = screen.getByText('Label')
expect(labelElement).toHaveClass('line-through', 'custom')
})
})
describe('Edge Cases', () => {
it('should render with empty text', () => {
const { container } = render(<Label text="" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with long text (truncation)', () => {
const longText = 'This is a very long label text that should be truncated'
render(<Label text={longText} />)
const labelElement = screen.getByText(longText)
expect(labelElement).toHaveClass('truncate')
})
it('should render with undefined className', () => {
render(<Label text="Label" className={undefined} />)
expect(screen.getByText('Label')).toBeInTheDocument()
})
it('should render with undefined isDeleted', () => {
render(<Label text="Label" isDeleted={undefined} />)
const labelElement = screen.getByText('Label')
expect(labelElement).not.toHaveClass('line-through')
})
it('should handle special characters in text', () => {
render(<Label text={'Label & "chars"'} />)
expect(screen.getByText('Label & "chars"')).toBeInTheDocument()
})
it('should handle numbers in text', () => {
render(<Label text="Label 123" />)
expect(screen.getByText('Label 123')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,548 @@
import type { MetadataItemInBatchEdit, MetadataItemWithEdit } from '../types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType, UpdateType } from '../types'
import EditMetadataBatchModal from './modal'
// Mock service/API calls
const mockDoAddMetaData = vi.fn().mockResolvedValue({})
vi.mock('@/service/knowledge/use-metadata', () => ({
useCreateMetaData: () => ({
mutate: mockDoAddMetaData,
}),
useDatasetMetaData: () => ({
data: {
doc_metadata: [
{ id: 'existing-1', name: 'existing_field', type: DataType.string },
{ id: 'existing-2', name: 'another_field', type: DataType.number },
],
},
}),
}))
// Mock check name hook to control validation
let mockCheckNameResult = { errorMsg: '' }
vi.mock('../hooks/use-check-metadata-name', () => ({
default: () => ({
checkName: () => mockCheckNameResult,
}),
}))
// Mock Toast to verify notifications
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (args: unknown) => mockToastNotify(args),
},
}))
// Type definitions for mock props
type EditRowProps = {
payload: MetadataItemWithEdit
onChange: (item: MetadataItemWithEdit) => void
onRemove: (id: string) => void
onReset: (id: string) => void
}
type AddRowProps = {
payload: MetadataItemWithEdit
onChange: (item: MetadataItemWithEdit) => void
onRemove: () => void
}
type SelectModalProps = {
trigger: React.ReactNode
onSelect: (item: MetadataItemInBatchEdit) => void
onSave: (data: { name: string, type: DataType }) => Promise<void>
onManage: () => void
}
// Mock child components with callback exposure
vi.mock('./edit-row', () => ({
default: ({ payload, onChange, onRemove, onReset }: EditRowProps) => (
<div data-testid="edit-row" data-id={payload.id}>
<span data-testid="edit-row-name">{payload.name}</span>
<button data-testid={`change-${payload.id}`} onClick={() => onChange({ ...payload, value: 'changed', isUpdated: true, updateType: UpdateType.changeValue })}>Change</button>
<button data-testid={`remove-${payload.id}`} onClick={() => onRemove(payload.id)}>Remove</button>
<button data-testid={`reset-${payload.id}`} onClick={() => onReset(payload.id)}>Reset</button>
</div>
),
}))
vi.mock('./add-row', () => ({
default: ({ payload, onChange, onRemove }: AddRowProps) => (
<div data-testid="add-row" data-id={payload.id}>
<span data-testid="add-row-name">{payload.name}</span>
<button data-testid={`add-change-${payload.id}`} onClick={() => onChange({ ...payload, value: 'new_value' })}>Change</button>
<button data-testid="add-remove" onClick={onRemove}>Remove</button>
</div>
),
}))
vi.mock('../metadata-dataset/select-metadata-modal', () => ({
default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => (
<div data-testid="select-modal">
{trigger}
<button data-testid="select-metadata" onClick={() => onSelect({ id: 'new-1', name: 'new_field', type: DataType.string, value: null, isMultipleValue: false })}>Select</button>
<button data-testid="save-metadata" onClick={() => onSave({ name: 'created_field', type: DataType.string }).catch(() => {})}>Save</button>
<button data-testid="manage-metadata" onClick={onManage}>Manage</button>
</div>
),
}))
describe('EditMetadataBatchModal', () => {
const mockList: MetadataItemInBatchEdit[] = [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1', isMultipleValue: false },
{ id: '2', name: 'field_two', type: DataType.number, value: 42, isMultipleValue: false },
]
const defaultProps = {
datasetId: 'ds-1',
documentNum: 5,
list: mockList,
onSave: vi.fn(),
onHide: vi.fn(),
onShowManage: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockCheckNameResult = { errorMsg: '' }
})
describe('Rendering', () => {
it('should render without crashing', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
it('should render document count', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByText(/5/)).toBeInTheDocument()
})
})
it('should render all edit rows for existing items', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
const editRows = screen.getAllByTestId('edit-row')
expect(editRows).toHaveLength(2)
})
})
it('should render field names for existing items', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('field_two')).toBeInTheDocument()
})
})
it('should render checkbox for apply to all', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
const checkboxes = document.querySelectorAll('[data-testid*="checkbox"]')
expect(checkboxes.length).toBeGreaterThan(0)
})
})
it('should render select metadata modal', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByTestId('select-modal')).toBeInTheDocument()
})
})
})
describe('User Interactions', () => {
it('should call onHide when cancel button is clicked', async () => {
const onHide = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onHide={onHide} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
const cancelButton = screen.getByText(/cancel/i)
fireEvent.click(cancelButton)
expect(onHide).toHaveBeenCalled()
})
it('should call onSave when save button is clicked', async () => {
const onSave = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find the primary save button (not the one in SelectMetadataModal)
const saveButtons = screen.getAllByText(/save/i)
const modalSaveButton = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
if (modalSaveButton)
fireEvent.click(modalSaveButton)
expect(onSave).toHaveBeenCalled()
})
it('should toggle apply to all checkbox', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
const checkboxContainer = document.querySelector('[data-testid*="checkbox"]')
expect(checkboxContainer).toBeInTheDocument()
if (checkboxContainer) {
fireEvent.click(checkboxContainer)
await waitFor(() => {
const checkIcon = checkboxContainer.querySelector('svg')
expect(checkIcon).toBeInTheDocument()
})
}
})
it('should call onHide when modal close button is clicked', async () => {
const onHide = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onHide={onHide} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
})
describe('Edit Row Operations', () => {
it('should update item value when change is triggered', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('change-1'))
// The component should update internally
expect(screen.getAllByTestId('edit-row').length).toBe(2)
})
it('should mark item as deleted when remove is clicked', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('remove-1'))
// The component should update internally - item marked as deleted
expect(screen.getAllByTestId('edit-row').length).toBe(2)
})
it('should reset item when reset is clicked', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// First change the item
fireEvent.click(screen.getByTestId('change-1'))
// Then reset it
fireEvent.click(screen.getByTestId('reset-1'))
expect(screen.getAllByTestId('edit-row').length).toBe(2)
})
})
describe('Add Metadata Operations', () => {
it('should add new item when metadata is selected', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('select-metadata'))
// Should now have add-row for the new item
await waitFor(() => {
expect(screen.getByTestId('add-row')).toBeInTheDocument()
})
})
it('should remove added item when remove is clicked', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// First add an item
fireEvent.click(screen.getByTestId('select-metadata'))
await waitFor(() => {
expect(screen.getByTestId('add-row')).toBeInTheDocument()
})
// Then remove it
fireEvent.click(screen.getByTestId('add-remove'))
await waitFor(() => {
expect(screen.queryByTestId('add-row')).not.toBeInTheDocument()
})
})
it('should update added item when change is triggered', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// First add an item
fireEvent.click(screen.getByTestId('select-metadata'))
await waitFor(() => {
expect(screen.getByTestId('add-row')).toBeInTheDocument()
})
// Then change it
fireEvent.click(screen.getByTestId('add-change-new-1'))
expect(screen.getByTestId('add-row')).toBeInTheDocument()
})
it('should call doAddMetaData when saving new metadata with valid name', async () => {
mockCheckNameResult = { errorMsg: '' }
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('save-metadata'))
await waitFor(() => {
expect(mockDoAddMetaData).toHaveBeenCalled()
})
})
it('should show success toast when saving with valid name', async () => {
mockCheckNameResult = { errorMsg: '' }
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('save-metadata'))
await waitFor(() => {
expect(mockDoAddMetaData).toHaveBeenCalled()
})
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
}),
)
})
})
it('should show error toast when saving with invalid name', async () => {
mockCheckNameResult = { errorMsg: 'Name already exists' }
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('save-metadata'))
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
message: 'Name already exists',
}),
)
})
})
it('should call onShowManage when manage is clicked', async () => {
const onShowManage = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onShowManage={onShowManage} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('manage-metadata'))
expect(onShowManage).toHaveBeenCalled()
})
})
describe('Props', () => {
it('should pass correct datasetId', async () => {
render(<EditMetadataBatchModal {...defaultProps} datasetId="custom-ds" />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
it('should display correct document number', async () => {
render(<EditMetadataBatchModal {...defaultProps} documentNum={10} />)
await waitFor(() => {
expect(screen.getByText(/10/)).toBeInTheDocument()
})
})
it('should handle empty list', async () => {
render(<EditMetadataBatchModal {...defaultProps} list={[]} />)
await waitFor(() => {
expect(screen.queryByTestId('edit-row')).not.toBeInTheDocument()
})
})
})
describe('Edge Cases', () => {
it('should handle list with multiple value items', async () => {
const multipleValueList: MetadataItemInBatchEdit[] = [
{ id: '1', name: 'field', type: DataType.string, value: null, isMultipleValue: true },
]
render(<EditMetadataBatchModal {...defaultProps} list={multipleValueList} />)
await waitFor(() => {
expect(screen.getByTestId('edit-row')).toBeInTheDocument()
})
})
it('should handle rapid save clicks', async () => {
const onSave = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find the primary save button
const saveButtons = screen.getAllByText(/save/i)
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
if (saveBtn) {
fireEvent.click(saveBtn)
fireEvent.click(saveBtn)
fireEvent.click(saveBtn)
}
expect(onSave).toHaveBeenCalledTimes(3)
})
it('should pass correct arguments to onSave', async () => {
const onSave = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
const saveButtons = screen.getAllByText(/save/i)
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
if (saveBtn)
fireEvent.click(saveBtn)
expect(onSave).toHaveBeenCalledWith(
expect.any(Array),
expect.any(Array),
expect.any(Boolean),
)
})
it('should pass isApplyToAllSelectDocument as true when checked', async () => {
const onSave = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
const checkboxContainer = document.querySelector('[data-testid*="checkbox"]')
if (checkboxContainer)
fireEvent.click(checkboxContainer)
const saveButtons = screen.getAllByText(/save/i)
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
if (saveBtn)
fireEvent.click(saveBtn)
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
expect.any(Array),
expect.any(Array),
true,
)
})
})
it('should filter out deleted items when saving', async () => {
const onSave = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Remove an item
fireEvent.click(screen.getByTestId('remove-1'))
// Save
const saveButtons = screen.getAllByText(/save/i)
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
if (saveBtn)
fireEvent.click(saveBtn)
expect(onSave).toHaveBeenCalled()
// The first argument should not contain the deleted item (id '1')
const savedList = onSave.mock.calls[0][0] as MetadataItemInBatchEdit[]
const hasDeletedItem = savedList.some(item => item.id === '1')
expect(hasDeletedItem).toBe(false)
})
it('should handle multiple add and remove operations', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Add first item
fireEvent.click(screen.getByTestId('select-metadata'))
await waitFor(() => {
expect(screen.getByTestId('add-row')).toBeInTheDocument()
})
// Remove it
fireEvent.click(screen.getByTestId('add-remove'))
await waitFor(() => {
expect(screen.queryByTestId('add-row')).not.toBeInTheDocument()
})
// Add again
fireEvent.click(screen.getByTestId('select-metadata'))
await waitFor(() => {
expect(screen.getByTestId('add-row')).toBeInTheDocument()
})
})
})
})
@@ -0,0 +1,647 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType, UpdateType } from '../types'
import useBatchEditDocumentMetadata from './use-batch-edit-document-metadata'
type DocMetadataItem = {
id: string
name: string
type: DataType
value: string | number | null
}
type DocListItem = {
id: string
name?: string
doc_metadata?: DocMetadataItem[] | null
}
type MetadataItemWithEdit = {
id: string
name: string
type: DataType
value: string | number | null
isMultipleValue?: boolean
updateType?: UpdateType
}
// Mock useBatchUpdateDocMetadata
const mockMutateAsync = vi.fn().mockResolvedValue({})
vi.mock('@/service/knowledge/use-metadata', () => ({
useBatchUpdateDocMetadata: () => ({
mutateAsync: mockMutateAsync,
}),
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
describe('useBatchEditDocumentMetadata', () => {
const mockDocList: DocListItem[] = [
{
id: 'doc-1',
name: 'Document 1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
],
},
{
id: 'doc-2',
name: 'Document 2',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 2' },
],
},
]
const defaultProps = {
datasetId: 'ds-1',
docList: mockDocList as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
onUpdate: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Hook Initialization', () => {
it('should initialize with isShowEditModal as false', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
expect(result.current.isShowEditModal).toBe(false)
})
it('should return showEditModal function', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
expect(typeof result.current.showEditModal).toBe('function')
})
it('should return hideEditModal function', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
expect(typeof result.current.hideEditModal).toBe('function')
})
it('should return originalList', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
expect(Array.isArray(result.current.originalList)).toBe(true)
})
it('should return handleSave function', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
expect(typeof result.current.handleSave).toBe('function')
})
})
describe('Modal Control', () => {
it('should show modal when showEditModal is called', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
act(() => {
result.current.showEditModal()
})
expect(result.current.isShowEditModal).toBe(true)
})
it('should hide modal when hideEditModal is called', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
act(() => {
result.current.showEditModal()
})
act(() => {
result.current.hideEditModal()
})
expect(result.current.isShowEditModal).toBe(false)
})
})
describe('Original List Processing', () => {
it('should compute originalList from docList metadata', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
expect(result.current.originalList.length).toBeGreaterThan(0)
})
it('should filter out built-in metadata', () => {
const docListWithBuiltIn: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: 'built-in', name: 'created_at', type: DataType.time, value: 123 },
{ id: '1', name: 'custom', type: DataType.string, value: 'test' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListWithBuiltIn as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
const hasBuiltIn = result.current.originalList.some(item => item.id === 'built-in')
expect(hasBuiltIn).toBe(false)
})
it('should mark items with multiple values', () => {
const docListWithDifferentValues: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Value A' },
],
},
{
id: 'doc-2',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Value B' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListWithDifferentValues as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
const fieldItem = result.current.originalList.find(item => item.id === '1')
expect(fieldItem?.isMultipleValue).toBe(true)
})
it('should not mark items with same values as multiple', () => {
const docListWithSameValues: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Same Value' },
],
},
{
id: 'doc-2',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Same Value' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListWithSameValues as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
const fieldItem = result.current.originalList.find(item => item.id === '1')
expect(fieldItem?.isMultipleValue).toBe(false)
})
it('should skip already marked multiple value items', () => {
// Three docs with same field but different values
const docListThreeDocs: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Value A' },
],
},
{
id: 'doc-2',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Value B' },
],
},
{
id: 'doc-3',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Value C' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListThreeDocs as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
// Should only have one item for field '1', marked as multiple
const fieldItems = result.current.originalList.filter(item => item.id === '1')
expect(fieldItems.length).toBe(1)
expect(fieldItems[0].isMultipleValue).toBe(true)
})
})
describe('handleSave', () => {
it('should call mutateAsync with correct data', async () => {
const onUpdate = vi.fn()
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({ ...defaultProps, onUpdate }),
)
await act(async () => {
await result.current.handleSave([], [], false)
})
expect(mockMutateAsync).toHaveBeenCalled()
})
it('should call onUpdate after successful save', async () => {
const onUpdate = vi.fn()
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({ ...defaultProps, onUpdate }),
)
await act(async () => {
await result.current.handleSave([], [], false)
})
await waitFor(() => {
expect(onUpdate).toHaveBeenCalled()
})
})
it('should hide modal after successful save', async () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
act(() => {
result.current.showEditModal()
})
expect(result.current.isShowEditModal).toBe(true)
await act(async () => {
await result.current.handleSave([], [], false)
})
await waitFor(() => {
expect(result.current.isShowEditModal).toBe(false)
})
})
it('should handle edited items with changeValue updateType', async () => {
const docListSingleDoc: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Old Value' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
const editedList: MetadataItemWithEdit[] = [
{
id: '1',
name: 'field_one',
type: DataType.string,
value: 'New Value',
updateType: UpdateType.changeValue,
},
]
await act(async () => {
await result.current.handleSave(editedList, [], false)
})
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
metadata_list: expect.arrayContaining([
expect.objectContaining({
document_id: 'doc-1',
metadata_list: expect.arrayContaining([
expect.objectContaining({
id: '1',
value: 'New Value',
}),
]),
}),
]),
}),
)
})
it('should handle removed items', async () => {
const docListSingleDoc: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
// Only pass field_one in editedList, field_two should be removed
const editedList: MetadataItemWithEdit[] = [
{
id: '1',
name: 'field_one',
type: DataType.string,
value: 'Value 1',
},
]
await act(async () => {
await result.current.handleSave(editedList, [], false)
})
expect(mockMutateAsync).toHaveBeenCalled()
})
it('should handle added items', async () => {
const docListSingleDoc: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
const addedList = [
{
id: 'new-1',
name: 'new_field',
type: DataType.string,
value: 'New Value',
isMultipleValue: false,
},
]
await act(async () => {
await result.current.handleSave([], addedList, false)
})
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
metadata_list: expect.arrayContaining([
expect.objectContaining({
metadata_list: expect.arrayContaining([
expect.objectContaining({
name: 'new_field',
}),
]),
}),
]),
}),
)
})
it('should add missing metadata when isApplyToAllSelectDocument is true', async () => {
// Doc 1 has field, Doc 2 doesn't have it
const docListMissingField: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
],
},
{
id: 'doc-2',
doc_metadata: [],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListMissingField as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
const editedList: MetadataItemWithEdit[] = [
{
id: '1',
name: 'field_one',
type: DataType.string,
value: 'Updated Value',
isMultipleValue: false,
updateType: UpdateType.changeValue,
},
]
await act(async () => {
await result.current.handleSave(editedList, [], true)
})
// Both documents should have the field after applying to all
expect(mockMutateAsync).toHaveBeenCalled()
const callArgs = mockMutateAsync.mock.calls[0][0]
expect(callArgs.metadata_list.length).toBe(2)
})
it('should not add missing metadata for multiple value items when isApplyToAllSelectDocument is true', async () => {
// Two docs with different values for same field
const docListDifferentValues: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value A' },
],
},
{
id: 'doc-2',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value B' },
],
},
{
id: 'doc-3',
doc_metadata: [],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListDifferentValues as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
// Mark it as multiple value item - should not be added to doc-3
const editedList: MetadataItemWithEdit[] = [
{
id: '1',
name: 'field_one',
type: DataType.string,
value: null,
isMultipleValue: true,
updateType: UpdateType.changeValue,
},
]
await act(async () => {
await result.current.handleSave(editedList, [], true)
})
expect(mockMutateAsync).toHaveBeenCalled()
})
it('should update existing items in the list', async () => {
const docListSingleDoc: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Old Value' },
{ id: '2', name: 'field_two', type: DataType.number, value: 100 },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
// Edit both items
const editedList: MetadataItemWithEdit[] = [
{
id: '1',
name: 'field_one',
type: DataType.string,
value: 'New Value 1',
updateType: UpdateType.changeValue,
},
{
id: '2',
name: 'field_two',
type: DataType.number,
value: 200,
updateType: UpdateType.changeValue,
},
]
await act(async () => {
await result.current.handleSave(editedList, [], false)
})
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
metadata_list: expect.arrayContaining([
expect.objectContaining({
metadata_list: expect.arrayContaining([
expect.objectContaining({ id: '1', value: 'New Value 1' }),
expect.objectContaining({ id: '2', value: 200 }),
]),
}),
]),
}),
)
})
})
describe('Selected Document IDs', () => {
it('should use selectedDocumentIds when provided', async () => {
const selectedIds = ['doc-1']
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
selectedDocumentIds: selectedIds,
}),
)
await act(async () => {
await result.current.handleSave([], [], false)
})
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
dataset_id: 'ds-1',
metadata_list: expect.arrayContaining([
expect.objectContaining({
document_id: 'doc-1',
}),
]),
}),
)
})
it('should handle selectedDocumentIds not in docList', async () => {
// Select a document that's not in docList
const selectedIds = ['doc-1', 'doc-not-in-list']
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
selectedDocumentIds: selectedIds,
}),
)
await act(async () => {
await result.current.handleSave([], [], false)
})
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
metadata_list: expect.arrayContaining([
expect.objectContaining({
document_id: 'doc-not-in-list',
partial_update: true,
}),
]),
}),
)
})
})
describe('Edge Cases', () => {
it('should handle empty docList', () => {
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: [] as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
expect(result.current.originalList).toEqual([])
})
it('should handle documents without metadata', () => {
const docListNoMetadata: DocListItem[] = [
{ id: 'doc-1', name: 'Doc 1' },
{ id: 'doc-2', name: 'Doc 2', doc_metadata: null },
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListNoMetadata as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
expect(result.current.originalList).toEqual([])
})
})
})
@@ -0,0 +1,166 @@
import { renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import useCheckMetadataName from './use-check-metadata-name'
describe('useCheckMetadataName', () => {
describe('Hook Initialization', () => {
it('should return an object with checkName function', () => {
const { result } = renderHook(() => useCheckMetadataName())
expect(result.current).toHaveProperty('checkName')
expect(typeof result.current.checkName).toBe('function')
})
})
describe('checkName - Empty Name Validation', () => {
it('should return error for empty string', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('')
expect(errorMsg).toBeTruthy()
})
it('should return error for whitespace-only string', () => {
const { result } = renderHook(() => useCheckMetadataName())
// Whitespace is not valid since it doesn't match the pattern
const { errorMsg } = result.current.checkName(' ')
expect(errorMsg).toBeTruthy()
})
})
describe('checkName - Pattern Validation', () => {
it('should return error for name starting with number', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('1name')
expect(errorMsg).toBeTruthy()
})
it('should return error for name starting with uppercase', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('Name')
expect(errorMsg).toBeTruthy()
})
it('should return error for name starting with underscore', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('_name')
expect(errorMsg).toBeTruthy()
})
it('should return error for name with spaces', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('my name')
expect(errorMsg).toBeTruthy()
})
it('should return error for name with special characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('name-with-dash')
expect(errorMsg).toBeTruthy()
})
it('should return error for name with dots', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('name.with.dot')
expect(errorMsg).toBeTruthy()
})
it('should accept valid name starting with lowercase letter', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('validname')
expect(errorMsg).toBe('')
})
it('should accept valid name with numbers after first character', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('name123')
expect(errorMsg).toBe('')
})
it('should accept valid name with underscores after first character', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('name_with_underscore')
expect(errorMsg).toBe('')
})
it('should accept single lowercase letter', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('a')
expect(errorMsg).toBe('')
})
})
describe('checkName - Length Validation', () => {
it('should return error for name longer than 255 characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const longName = 'a'.repeat(256)
const { errorMsg } = result.current.checkName(longName)
expect(errorMsg).toBeTruthy()
})
it('should accept name with exactly 255 characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const maxLengthName = 'a'.repeat(255)
const { errorMsg } = result.current.checkName(maxLengthName)
expect(errorMsg).toBe('')
})
it('should accept name with less than 255 characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const shortName = 'a'.repeat(100)
const { errorMsg } = result.current.checkName(shortName)
expect(errorMsg).toBe('')
})
})
describe('checkName - Edge Cases', () => {
it('should validate all lowercase letters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('abcdefghijklmnopqrstuvwxyz')
expect(errorMsg).toBe('')
})
it('should validate name with mixed numbers and underscores', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('a1_2_3_test')
expect(errorMsg).toBe('')
})
it('should reject uppercase letters anywhere in name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('nameWithUppercase')
expect(errorMsg).toBeTruthy()
})
it('should reject unicode characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('名字')
expect(errorMsg).toBeTruthy()
})
it('should reject emoji characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('name😀')
expect(errorMsg).toBeTruthy()
})
})
describe('Return Value Structure', () => {
it('should return object with errorMsg property', () => {
const { result } = renderHook(() => useCheckMetadataName())
const returnValue = result.current.checkName('test')
expect(returnValue).toHaveProperty('errorMsg')
})
it('should return empty string for valid name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('valid_name')
expect(errorMsg).toBe('')
})
it('should return non-empty string for invalid name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('')
expect(typeof errorMsg).toBe('string')
expect(errorMsg.length).toBeGreaterThan(0)
})
})
})
@@ -0,0 +1,308 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import useEditDatasetMetadata from './use-edit-dataset-metadata'
// Mock service hooks
const mockDoAddMetaData = vi.fn().mockResolvedValue({})
const mockDoRenameMetaData = vi.fn().mockResolvedValue({})
const mockDoDeleteMetaData = vi.fn().mockResolvedValue({})
const mockToggleBuiltInStatus = vi.fn().mockResolvedValue({})
vi.mock('@/service/knowledge/use-metadata', () => ({
useDatasetMetaData: () => ({
data: {
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, count: 5 },
{ id: '2', name: 'field_two', type: DataType.number, count: 3 },
],
built_in_field_enabled: false,
},
}),
useCreateMetaData: () => ({
mutate: mockDoAddMetaData,
}),
useRenameMeta: () => ({
mutate: mockDoRenameMetaData,
}),
useDeleteMetaData: () => ({
mutateAsync: mockDoDeleteMetaData,
}),
useUpdateBuiltInStatus: () => ({
mutateAsync: mockToggleBuiltInStatus,
}),
useBuiltInMetaDataFields: () => ({
data: {
fields: [
{ name: 'created_at', type: DataType.time },
{ name: 'modified_at', type: DataType.time },
],
},
}),
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
// Mock useCheckMetadataName
vi.mock('./use-check-metadata-name', () => ({
default: () => ({
checkName: (name: string) => ({
errorMsg: name && /^[a-z][a-z0-9_]*$/.test(name) ? '' : 'Invalid name',
}),
}),
}))
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
}
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
describe('useEditDatasetMetadata', () => {
const defaultProps = {
datasetId: 'ds-1',
onUpdateDocList: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
localStorageMock.getItem.mockReturnValue(null)
})
describe('Hook Initialization', () => {
it('should initialize with isShowEditModal as false', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(result.current.isShowEditModal).toBe(false)
})
it('should return showEditModal function', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.showEditModal).toBe('function')
})
it('should return hideEditModal function', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.hideEditModal).toBe('function')
})
it('should return datasetMetaData', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(result.current.datasetMetaData).toBeDefined()
})
it('should return handleAddMetaData function', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.handleAddMetaData).toBe('function')
})
it('should return handleRename function', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.handleRename).toBe('function')
})
it('should return handleDeleteMetaData function', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.handleDeleteMetaData).toBe('function')
})
it('should return builtInMetaData', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(result.current.builtInMetaData).toBeDefined()
})
it('should return builtInEnabled', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.builtInEnabled).toBe('boolean')
})
it('should return setBuiltInEnabled function', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.setBuiltInEnabled).toBe('function')
})
})
describe('Modal Control', () => {
it('should show modal when showEditModal is called', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
act(() => {
result.current.showEditModal()
})
expect(result.current.isShowEditModal).toBe(true)
})
it('should hide modal when hideEditModal is called', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
act(() => {
result.current.showEditModal()
})
act(() => {
result.current.hideEditModal()
})
expect(result.current.isShowEditModal).toBe(false)
})
it('should handle toggle of modal state', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
// Initially closed
expect(result.current.isShowEditModal).toBe(false)
// Show, hide, show
act(() => result.current.showEditModal())
expect(result.current.isShowEditModal).toBe(true)
act(() => result.current.hideEditModal())
expect(result.current.isShowEditModal).toBe(false)
act(() => result.current.showEditModal())
expect(result.current.isShowEditModal).toBe(true)
})
})
describe('handleAddMetaData', () => {
it('should call doAddMetaData with valid name', async () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
await act(async () => {
await result.current.handleAddMetaData({
name: 'valid_name',
type: DataType.string,
})
})
expect(mockDoAddMetaData).toHaveBeenCalled()
})
it('should reject invalid name', async () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
await expect(
act(async () => {
await result.current.handleAddMetaData({
name: '',
type: DataType.string,
})
}),
).rejects.toThrow()
})
})
describe('handleRename', () => {
it('should call doRenameMetaData with valid name', async () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
await act(async () => {
await result.current.handleRename({
id: '1',
name: 'new_valid_name',
type: DataType.string,
count: 5,
})
})
expect(mockDoRenameMetaData).toHaveBeenCalled()
})
it('should call onUpdateDocList after rename', async () => {
const onUpdateDocList = vi.fn()
const { result } = renderHook(() =>
useEditDatasetMetadata({ ...defaultProps, onUpdateDocList }),
)
await act(async () => {
await result.current.handleRename({
id: '1',
name: 'renamed',
type: DataType.string,
count: 5,
})
})
await waitFor(() => {
expect(onUpdateDocList).toHaveBeenCalled()
})
})
it('should reject invalid name for rename', async () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
await expect(
act(async () => {
await result.current.handleRename({
id: '1',
name: 'Invalid Name',
type: DataType.string,
count: 5,
})
}),
).rejects.toThrow()
})
})
describe('handleDeleteMetaData', () => {
it('should call doDeleteMetaData', async () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
await act(async () => {
await result.current.handleDeleteMetaData('1')
})
expect(mockDoDeleteMetaData).toHaveBeenCalledWith('1')
})
it('should call onUpdateDocList after delete', async () => {
const onUpdateDocList = vi.fn()
const { result } = renderHook(() =>
useEditDatasetMetadata({ ...defaultProps, onUpdateDocList }),
)
await act(async () => {
await result.current.handleDeleteMetaData('1')
})
await waitFor(() => {
expect(onUpdateDocList).toHaveBeenCalled()
})
})
})
describe('Built-in Status', () => {
it('should toggle built-in status', async () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
await act(async () => {
await result.current.setBuiltInEnabled(true)
})
expect(mockToggleBuiltInStatus).toHaveBeenCalledWith(true)
})
})
describe('Edge Cases', () => {
it('should handle different datasetIds', () => {
const { result, rerender } = renderHook(
props => useEditDatasetMetadata(props),
{ initialProps: defaultProps },
)
expect(result.current).toBeDefined()
rerender({ ...defaultProps, datasetId: 'ds-2' })
expect(result.current).toBeDefined()
})
})
})
@@ -0,0 +1,587 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import useMetadataDocument from './use-metadata-document'
type DocDetail = {
id: string
name: string
data_source_type: string
word_count: number
language?: string
hit_count?: number
segment_count?: number
}
// Mock service hooks
const mockMutateAsync = vi.fn().mockResolvedValue({})
const mockDoAddMetaData = vi.fn().mockResolvedValue({})
vi.mock('@/service/knowledge/use-metadata', () => ({
useBatchUpdateDocMetadata: () => ({
mutateAsync: mockMutateAsync,
}),
useCreateMetaData: () => ({
mutateAsync: mockDoAddMetaData,
}),
useDocumentMetaData: () => ({
data: {
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
{ id: 'built-in', name: 'created_at', type: DataType.time, value: 1609459200 },
],
},
}),
useDatasetMetaData: () => ({
data: {
built_in_field_enabled: true,
},
}),
}))
// Mock useDatasetDetailContext
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContext: () => ({
dataset: {
embedding_available: true,
},
}),
}))
// Mock useMetadataMap and useLanguages with comprehensive field definitions
vi.mock('@/hooks/use-metadata', () => ({
useMetadataMap: () => ({
originInfo: {
subFieldsMap: {
data_source_type: { label: 'Source Type', inputType: 'text' },
language: { label: 'Language', inputType: 'select' },
empty_field: { label: 'Empty Field', inputType: 'text' },
},
},
technicalParameters: {
subFieldsMap: {
word_count: { label: 'Word Count', inputType: 'text' },
hit_count: {
label: 'Hit Count',
inputType: 'text',
render: (val: number, segmentCount?: number) => `${val}/${segmentCount || 0}`,
},
custom_render: {
label: 'Custom Render',
inputType: 'text',
render: (val: string) => `Rendered: ${val}`,
},
},
},
}),
useLanguages: () => ({
en: 'English',
zh: 'Chinese',
ja: 'Japanese',
}),
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
// Mock useCheckMetadataName
vi.mock('./use-check-metadata-name', () => ({
default: () => ({
checkName: (name: string) => ({
errorMsg: name && /^[a-z][a-z0-9_]*$/.test(name) ? '' : 'Invalid name',
}),
}),
}))
describe('useMetadataDocument', () => {
const mockDocDetail: DocDetail = {
id: 'doc-1',
name: 'Test Document',
data_source_type: 'upload_file',
word_count: 100,
language: 'en',
hit_count: 50,
segment_count: 10,
}
const defaultProps = {
datasetId: 'ds-1',
documentId: 'doc-1',
docDetail: mockDocDetail as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Hook Initialization', () => {
it('should return embeddingAvailable', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(result.current.embeddingAvailable).toBe(true)
})
it('should return isEdit as false initially', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(result.current.isEdit).toBe(false)
})
it('should return setIsEdit function', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(typeof result.current.setIsEdit).toBe('function')
})
it('should return list without built-in items', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
const hasBuiltIn = result.current.list.some(item => item.id === 'built-in')
expect(hasBuiltIn).toBe(false)
})
it('should return builtList with only built-in items', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
const allBuiltIn = result.current.builtList.every(item => item.id === 'built-in')
expect(allBuiltIn).toBe(true)
})
it('should return tempList', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(Array.isArray(result.current.tempList)).toBe(true)
})
it('should return setTempList function', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(typeof result.current.setTempList).toBe('function')
})
it('should return hasData based on list length', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(result.current.hasData).toBe(result.current.list.length > 0)
})
it('should return builtInEnabled', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(typeof result.current.builtInEnabled).toBe('boolean')
})
it('should return originInfo', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(Array.isArray(result.current.originInfo)).toBe(true)
})
it('should return technicalParameters', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(Array.isArray(result.current.technicalParameters)).toBe(true)
})
})
describe('Edit Mode', () => {
it('should enter edit mode when startToEdit is called', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
expect(result.current.isEdit).toBe(true)
})
it('should exit edit mode when handleCancel is called', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
act(() => {
result.current.handleCancel()
})
expect(result.current.isEdit).toBe(false)
})
it('should reset tempList when handleCancel is called', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
const originalLength = result.current.list.length
act(() => {
result.current.setTempList([])
})
act(() => {
result.current.handleCancel()
})
expect(result.current.tempList.length).toBe(originalLength)
})
})
describe('handleSelectMetaData', () => {
it('should add metadata to tempList if not exists', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
const initialLength = result.current.tempList.length
act(() => {
result.current.handleSelectMetaData({
id: 'new-id',
name: 'new_field',
type: DataType.string,
value: null,
})
})
expect(result.current.tempList.length).toBe(initialLength + 1)
})
it('should not add duplicate metadata', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
const initialLength = result.current.tempList.length
// Try to add existing item
if (result.current.tempList.length > 0) {
act(() => {
result.current.handleSelectMetaData(result.current.tempList[0])
})
expect(result.current.tempList.length).toBe(initialLength)
}
})
})
describe('handleAddMetaData', () => {
it('should call doAddMetaData with valid name', async () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
await act(async () => {
await result.current.handleAddMetaData({
name: 'valid_field',
type: DataType.string,
})
})
expect(mockDoAddMetaData).toHaveBeenCalled()
})
it('should reject invalid name', async () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
await expect(
act(async () => {
await result.current.handleAddMetaData({
name: '',
type: DataType.string,
})
}),
).rejects.toThrow()
})
})
describe('handleSave', () => {
it('should call mutateAsync to save metadata', async () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
await act(async () => {
await result.current.handleSave()
})
expect(mockMutateAsync).toHaveBeenCalled()
})
it('should exit edit mode after save', async () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
await act(async () => {
await result.current.handleSave()
})
await waitFor(() => {
expect(result.current.isEdit).toBe(false)
})
})
})
describe('getReadOnlyMetaData - originInfo', () => {
it('should return origin info with correct structure', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(result.current.originInfo).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: DataType.string,
}),
]),
)
})
it('should use languageMap for language field (select type)', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
// Find language field in originInfo
const languageField = result.current.originInfo.find(
item => item.name === 'Language',
)
// If language field exists and docDetail has language 'en', value should be 'English'
if (languageField)
expect(languageField.value).toBe('English')
})
it('should return dash for empty field values', () => {
const docDetailWithEmpty: DocDetail = {
id: 'doc-1',
name: 'Test Document',
data_source_type: 'upload_file',
word_count: 100,
}
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: docDetailWithEmpty as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
// Check if there's any field with '-' value (meaning empty)
const hasEmptyField = result.current.originInfo.some(
item => item.value === '-',
)
// language field should return '-' since it's not set
expect(hasEmptyField).toBe(true)
})
it('should return empty object for non-language select fields', () => {
// This tests the else branch of getTargetMap where field !== 'language'
const { result } = renderHook(() => useMetadataDocument(defaultProps))
// The data_source_type field is a text field, not select
const sourceTypeField = result.current.originInfo.find(
item => item.name === 'Source Type',
)
// It should return the raw value since it's not a select type
if (sourceTypeField)
expect(sourceTypeField.value).toBe('upload_file')
})
})
describe('getReadOnlyMetaData - technicalParameters', () => {
it('should return technical parameters with correct structure', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(result.current.technicalParameters).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: DataType.string,
}),
]),
)
})
it('should use render function when available', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
// Find hit_count field which has a render function
const hitCountField = result.current.technicalParameters.find(
item => item.name === 'Hit Count',
)
// The render function should format as "val/segmentCount"
if (hitCountField)
expect(hitCountField.value).toBe('50/10')
})
it('should return raw value when no render function', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
// Find word_count field which has no render function
const wordCountField = result.current.technicalParameters.find(
item => item.name === 'Word Count',
)
if (wordCountField)
expect(wordCountField.value).toBe(100)
})
it('should handle fields with render function and undefined segment_count', () => {
const docDetailNoSegment: DocDetail = {
id: 'doc-1',
name: 'Test Document',
data_source_type: 'upload_file',
word_count: 100,
hit_count: 25,
}
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: docDetailNoSegment as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
const hitCountField = result.current.technicalParameters.find(
item => item.name === 'Hit Count',
)
// Should use 0 as default for segment_count
if (hitCountField)
expect(hitCountField.value).toBe('25/0')
})
it('should return dash for null/undefined values', () => {
const docDetailWithNull: DocDetail = {
id: 'doc-1',
name: 'Test Document',
data_source_type: '',
word_count: 0,
}
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: docDetailWithNull as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
// 0 should still be shown, but empty string should show '-'
const sourceTypeField = result.current.originInfo.find(
item => item.name === 'Source Type',
)
if (sourceTypeField)
expect(sourceTypeField.value).toBe('-')
})
it('should handle 0 value correctly (not treated as empty)', () => {
const docDetailWithZero: DocDetail = {
id: 'doc-1',
name: 'Test Document',
data_source_type: 'upload_file',
word_count: 0,
}
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: docDetailWithZero as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
// word_count of 0 should still show 0, not '-'
const wordCountField = result.current.technicalParameters.find(
item => item.name === 'Word Count',
)
if (wordCountField)
expect(wordCountField.value).toBe(0)
})
})
describe('Edge Cases', () => {
it('should handle empty docDetail', () => {
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: {} as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
expect(result.current).toBeDefined()
})
it('should handle different datasetIds', () => {
const { result, rerender } = renderHook(
props => useMetadataDocument(props),
{ initialProps: defaultProps },
)
expect(result.current).toBeDefined()
rerender({ ...defaultProps, datasetId: 'ds-2' })
expect(result.current).toBeDefined()
})
it('should handle docDetail with all fields', () => {
const fullDocDetail: DocDetail = {
id: 'doc-1',
name: 'Full Document',
data_source_type: 'website',
word_count: 500,
language: 'zh',
hit_count: 100,
segment_count: 20,
}
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: fullDocDetail as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
// Language should be mapped
const languageField = result.current.originInfo.find(
item => item.name === 'Language',
)
if (languageField)
expect(languageField.value).toBe('Chinese')
// Hit count should be rendered
const hitCountField = result.current.technicalParameters.find(
item => item.name === 'Hit Count',
)
if (hitCountField)
expect(hitCountField.value).toBe('100/20')
})
it('should handle unknown language', () => {
const unknownLangDetail: DocDetail = {
id: 'doc-1',
name: 'Unknown Lang Document',
data_source_type: 'upload_file',
word_count: 100,
language: 'unknown_lang',
}
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: unknownLangDetail as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
// Unknown language should return undefined from the map
const languageField = result.current.originInfo.find(
item => item.name === 'Language',
)
// When language is not in map, it returns undefined
expect(languageField?.value).toBeUndefined()
})
})
})
@@ -0,0 +1,268 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import CreateContent from './create-content'
type ModalLikeWrapProps = {
children: React.ReactNode
title: string
onClose?: () => void
onConfirm: () => void
beforeHeader?: React.ReactNode
}
type OptionCardProps = {
title: string
selected: boolean
onSelect: () => void
}
type FieldProps = {
label: string
children: React.ReactNode
}
// Mock ModalLikeWrap
vi.mock('../../../base/modal-like-wrap', () => ({
default: ({ children, title, onClose, onConfirm, beforeHeader }: ModalLikeWrapProps) => (
<div data-testid="modal-wrap">
<div data-testid="modal-title">{title}</div>
{beforeHeader && <div data-testid="before-header">{beforeHeader}</div>}
<div data-testid="modal-content">{children}</div>
<button data-testid="close-btn" onClick={onClose}>Close</button>
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
</div>
),
}))
// Mock OptionCard
vi.mock('../../../workflow/nodes/_base/components/option-card', () => ({
default: ({ title, selected, onSelect }: OptionCardProps) => (
<button
data-testid={`option-${title.toLowerCase()}`}
data-selected={selected}
onClick={onSelect}
>
{title}
</button>
),
}))
// Mock Field
vi.mock('./field', () => ({
default: ({ label, children }: FieldProps) => (
<div data-testid="field">
<label data-testid="field-label">{label}</label>
{children}
</div>
),
}))
describe('CreateContent', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('modal-wrap')).toBeInTheDocument()
})
it('should render modal title', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('modal-title')).toBeInTheDocument()
})
it('should render type selection options', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('option-string')).toBeInTheDocument()
expect(screen.getByTestId('option-number')).toBeInTheDocument()
expect(screen.getByTestId('option-time')).toBeInTheDocument()
})
it('should render name input field', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render confirm button', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('confirm-btn')).toBeInTheDocument()
})
it('should render close button', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('close-btn')).toBeInTheDocument()
})
})
describe('Type Selection', () => {
it('should default to string type', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true')
})
it('should select number type when clicked', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
fireEvent.click(screen.getByTestId('option-number'))
expect(screen.getByTestId('option-number')).toHaveAttribute('data-selected', 'true')
})
it('should select time type when clicked', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
fireEvent.click(screen.getByTestId('option-time'))
expect(screen.getByTestId('option-time')).toHaveAttribute('data-selected', 'true')
})
it('should deselect previous type when new type is selected', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
// Initially string is selected
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true')
// Select number
fireEvent.click(screen.getByTestId('option-number'))
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'false')
expect(screen.getByTestId('option-number')).toHaveAttribute('data-selected', 'true')
})
})
describe('Name Input', () => {
it('should update name when typing', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new_field' } })
expect(input).toHaveValue('new_field')
})
it('should start with empty name', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByRole('textbox')).toHaveValue('')
})
})
describe('User Interactions', () => {
it('should call onSave with type and name when confirmed', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test_field' } })
fireEvent.click(screen.getByTestId('confirm-btn'))
expect(handleSave).toHaveBeenCalledWith({
type: DataType.string,
name: 'test_field',
})
})
it('should call onSave with correct type after changing type', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
fireEvent.click(screen.getByTestId('option-number'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'num_field' } })
fireEvent.click(screen.getByTestId('confirm-btn'))
expect(handleSave).toHaveBeenCalledWith({
type: DataType.number,
name: 'num_field',
})
})
it('should call onClose when close button is clicked', () => {
const handleSave = vi.fn()
const handleClose = vi.fn()
render(<CreateContent onSave={handleSave} onClose={handleClose} />)
fireEvent.click(screen.getByTestId('close-btn'))
expect(handleClose).toHaveBeenCalled()
})
})
describe('Back Button', () => {
it('should show back button when hasBack is true', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} hasBack />)
expect(screen.getByTestId('before-header')).toBeInTheDocument()
})
it('should not show back button when hasBack is false', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} hasBack={false} />)
expect(screen.queryByTestId('before-header')).not.toBeInTheDocument()
})
it('should call onBack when back button is clicked', () => {
const handleSave = vi.fn()
const handleBack = vi.fn()
render(<CreateContent onSave={handleSave} hasBack onBack={handleBack} />)
const backButton = screen.getByTestId('before-header')
// Find the clickable element inside
const clickable = backButton.querySelector('.cursor-pointer') || backButton.firstChild
if (clickable)
fireEvent.click(clickable)
// The back functionality is tested through the actual implementation
expect(screen.getByTestId('before-header')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty name submission', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
fireEvent.click(screen.getByTestId('confirm-btn'))
expect(handleSave).toHaveBeenCalledWith({
type: DataType.string,
name: '',
})
})
it('should handle type cycling', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
// Cycle through all types
fireEvent.click(screen.getByTestId('option-number'))
fireEvent.click(screen.getByTestId('option-time'))
fireEvent.click(screen.getByTestId('option-string'))
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true')
})
it('should handle special characters in name', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test_field_123' } })
expect(input).toHaveValue('test_field_123')
})
})
})
@@ -0,0 +1,246 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import CreateMetadataModal from './create-metadata-modal'
type PortalProps = {
children: React.ReactNode
open: boolean
}
type TriggerProps = {
children: React.ReactNode
onClick: () => void
}
type ContentProps = {
children: React.ReactNode
className?: string
}
type CreateContentProps = {
onSave: (data: { type: DataType, name: string }) => void
onClose?: () => void
onBack?: () => void
hasBack?: boolean
}
// Mock PortalToFollowElem components
vi.mock('../../../base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: PortalProps) => (
<div data-testid="portal-wrapper" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children, className }: ContentProps) => (
<div data-testid="portal-content" className={className}>{children}</div>
),
}))
// Mock CreateContent component
vi.mock('./create-content', () => ({
default: ({ onSave, onClose, onBack, hasBack }: CreateContentProps) => (
<div data-testid="create-content">
<span data-testid="has-back">{String(hasBack)}</span>
<button data-testid="save-btn" onClick={() => onSave({ type: DataType.string, name: 'test' })}>Save</button>
<button data-testid="close-btn" onClick={onClose}>Close</button>
{hasBack && <button data-testid="back-btn" onClick={onBack}>Back</button>}
</div>
),
}))
describe('CreateMetadataModal', () => {
const mockTrigger = <button data-testid="trigger-button">Open Modal</button>
describe('Rendering', () => {
it('should render trigger when closed', () => {
render(
<CreateMetadataModal
open={false}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
// Portal wrapper should exist but closed
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'false')
})
it('should render content when open', () => {
render(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
expect(screen.getByTestId('create-content')).toBeInTheDocument()
})
it('should render trigger element', () => {
render(
<CreateMetadataModal
open={false}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('trigger-button')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass hasBack to CreateContent', () => {
render(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
hasBack
/>,
)
expect(screen.getByTestId('has-back')).toHaveTextContent('true')
})
it('should pass hasBack=undefined when not provided', () => {
render(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('has-back')).toHaveTextContent('undefined')
})
it('should accept custom popupLeft', () => {
render(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
popupLeft={50}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should toggle open state when trigger is clicked', () => {
const setOpen = vi.fn()
render(
<CreateMetadataModal
open={false}
setOpen={setOpen}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(setOpen).toHaveBeenCalledWith(true)
})
it('should call onSave when save button is clicked', () => {
const handleSave = vi.fn()
render(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={handleSave}
/>,
)
fireEvent.click(screen.getByTestId('save-btn'))
expect(handleSave).toHaveBeenCalledWith({
type: DataType.string,
name: 'test',
})
})
it('should close modal when close button is clicked', () => {
const setOpen = vi.fn()
render(
<CreateMetadataModal
open={true}
setOpen={setOpen}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('close-btn'))
expect(setOpen).toHaveBeenCalledWith(false)
})
it('should close modal when back button is clicked', () => {
const setOpen = vi.fn()
render(
<CreateMetadataModal
open={true}
setOpen={setOpen}
trigger={mockTrigger}
onSave={vi.fn()}
hasBack
/>,
)
fireEvent.click(screen.getByTestId('back-btn'))
expect(setOpen).toHaveBeenCalledWith(false)
})
})
describe('Edge Cases', () => {
it('should handle switching open state', () => {
const { rerender } = render(
<CreateMetadataModal
open={false}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'false')
rerender(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'true')
})
it('should handle different trigger elements', () => {
const customTrigger = <div data-testid="custom-trigger">Custom</div>
render(
<CreateMetadataModal
open={false}
setOpen={vi.fn()}
trigger={customTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
})
})
})

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