mirror of
https://github.com/langgenius/dify.git
synced 2026-04-02 03:47:10 +08:00
Compare commits
24 Commits
3-6-type-c
...
copilot/an
| Author | SHA1 | Date | |
|---|---|---|---|
| 85396a2af2 | |||
| 6c19e75969 | |||
| 9970f4449a | |||
| cbb19cce39 | |||
| 0aef09d630 | |||
| d2208ad43e | |||
| 4a2ba058bb | |||
| 654e41d47f | |||
| ec5409756e | |||
| 8b1ea3a8f5 | |||
| f2d3feca66 | |||
| 0590b09958 | |||
| 66f9fde2fe | |||
| 1811a855ab | |||
| 322cd37de1 | |||
| 2cc0de9c1b | |||
| 46098b2be6 | |||
| 7dcf94f48f | |||
| 7869551afd | |||
| c925d17e8f | |||
| dc2a53d834 | |||
| 05ab107e73 | |||
| c016793efb | |||
| a5bcbaebb7 |
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@ -25,6 +25,10 @@ updates:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 2
|
||||
groups:
|
||||
lexical:
|
||||
patterns:
|
||||
- "lexical"
|
||||
- "@lexical/*"
|
||||
storybook:
|
||||
patterns:
|
||||
- "storybook"
|
||||
@ -33,5 +37,7 @@ updates:
|
||||
patterns:
|
||||
- "*"
|
||||
exclude-patterns:
|
||||
- "lexical"
|
||||
- "@lexical/*"
|
||||
- "storybook"
|
||||
- "@storybook/*"
|
||||
|
||||
@ -133,7 +133,7 @@ Star Dify on GitHub and be instantly notified of new releases.
|
||||
|
||||
### Custom configurations
|
||||
|
||||
If you need to customize the configuration, please refer to the comments in our [.env.example](docker/.env.example) file and update the corresponding values in your `.env` file. Additionally, you might need to make adjustments to the `docker-compose.yaml` file itself, such as changing image versions, port mappings, or volume mounts, based on your specific deployment environment and requirements. After making any changes, please re-run `docker-compose up -d`. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
|
||||
If you need to customize the configuration, please refer to the comments in our [.env.example](docker/.env.example) file and update the corresponding values in your `.env` file. Additionally, you might need to make adjustments to the `docker-compose.yaml` file itself, such as changing image versions, port mappings, or volume mounts, based on your specific deployment environment and requirements. After making any changes, please re-run `docker compose up -d`. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
|
||||
|
||||
#### Customizing Suggested Questions
|
||||
|
||||
|
||||
@ -62,6 +62,22 @@ This is the default standard for backend code in this repo. Follow it for new co
|
||||
|
||||
- Code should usually include type annotations that match the repo’s current Python version (avoid untyped public APIs and “mystery” values).
|
||||
- Prefer modern typing forms (e.g. `list[str]`, `dict[str, int]`) and avoid `Any` unless there’s a strong reason.
|
||||
- For dictionary-like data with known keys and value types, prefer `TypedDict` over `dict[...]` or `Mapping[...]`.
|
||||
- For optional keys in typed payloads, use `NotRequired[...]` (or `total=False` when most fields are optional).
|
||||
- Keep `dict[...]` / `Mapping[...]` for truly dynamic key spaces where the key set is unknown.
|
||||
|
||||
```python
|
||||
from datetime import datetime
|
||||
from typing import NotRequired, TypedDict
|
||||
|
||||
|
||||
class UserProfile(TypedDict):
|
||||
user_id: str
|
||||
email: str
|
||||
created_at: datetime
|
||||
nickname: NotRequired[str]
|
||||
```
|
||||
|
||||
- For classes, declare member variables at the top of the class body (before `__init__`) so the class shape is obvious at a glance:
|
||||
|
||||
```python
|
||||
|
||||
165
api/commands.py
165
api/commands.py
@ -30,6 +30,7 @@ from extensions.ext_redis import redis_client
|
||||
from extensions.ext_storage import storage
|
||||
from extensions.storage.opendal_storage import OpenDALStorage
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.db_migration_lock import DbMigrationAutoRenewLock
|
||||
from libs.helper import email as email_validate
|
||||
from libs.password import hash_password, password_pattern, valid_password
|
||||
@ -2598,15 +2599,29 @@ def migrate_oss(
|
||||
@click.option(
|
||||
"--start-from",
|
||||
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
|
||||
required=True,
|
||||
required=False,
|
||||
default=None,
|
||||
help="Lower bound (inclusive) for created_at.",
|
||||
)
|
||||
@click.option(
|
||||
"--end-before",
|
||||
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
|
||||
required=True,
|
||||
required=False,
|
||||
default=None,
|
||||
help="Upper bound (exclusive) for created_at.",
|
||||
)
|
||||
@click.option(
|
||||
"--from-days-ago",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Relative lower bound in days ago (inclusive). Must be used with --before-days.",
|
||||
)
|
||||
@click.option(
|
||||
"--before-days",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Relative upper bound in days ago (exclusive). Required for relative mode.",
|
||||
)
|
||||
@click.option("--batch-size", default=1000, show_default=True, help="Batch size for selecting messages.")
|
||||
@click.option(
|
||||
"--graceful-period",
|
||||
@ -2618,8 +2633,10 @@ def migrate_oss(
|
||||
def clean_expired_messages(
|
||||
batch_size: int,
|
||||
graceful_period: int,
|
||||
start_from: datetime.datetime,
|
||||
end_before: datetime.datetime,
|
||||
start_from: datetime.datetime | None,
|
||||
end_before: datetime.datetime | None,
|
||||
from_days_ago: int | None,
|
||||
before_days: int | None,
|
||||
dry_run: bool,
|
||||
):
|
||||
"""
|
||||
@ -2630,18 +2647,70 @@ def clean_expired_messages(
|
||||
start_at = time.perf_counter()
|
||||
|
||||
try:
|
||||
abs_mode = start_from is not None and end_before is not None
|
||||
rel_mode = before_days is not None
|
||||
|
||||
if abs_mode and rel_mode:
|
||||
raise click.UsageError(
|
||||
"Options are mutually exclusive: use either (--start-from,--end-before) "
|
||||
"or (--from-days-ago,--before-days)."
|
||||
)
|
||||
|
||||
if from_days_ago is not None and before_days is None:
|
||||
raise click.UsageError("--from-days-ago must be used together with --before-days.")
|
||||
|
||||
if (start_from is None) ^ (end_before is None):
|
||||
raise click.UsageError("Both --start-from and --end-before are required when using absolute time range.")
|
||||
|
||||
if not abs_mode and not rel_mode:
|
||||
raise click.UsageError(
|
||||
"You must provide either (--start-from,--end-before) or (--before-days [--from-days-ago])."
|
||||
)
|
||||
|
||||
if rel_mode:
|
||||
assert before_days is not None
|
||||
if before_days < 0:
|
||||
raise click.UsageError("--before-days must be >= 0.")
|
||||
if from_days_ago is not None:
|
||||
if from_days_ago < 0:
|
||||
raise click.UsageError("--from-days-ago must be >= 0.")
|
||||
if from_days_ago <= before_days:
|
||||
raise click.UsageError("--from-days-ago must be greater than --before-days.")
|
||||
|
||||
# Create policy based on billing configuration
|
||||
# NOTE: graceful_period will be ignored when billing is disabled.
|
||||
policy = create_message_clean_policy(graceful_period_days=graceful_period)
|
||||
|
||||
# Create and run the cleanup service
|
||||
service = MessagesCleanService.from_time_range(
|
||||
policy=policy,
|
||||
start_from=start_from,
|
||||
end_before=end_before,
|
||||
batch_size=batch_size,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
if abs_mode:
|
||||
assert start_from is not None
|
||||
assert end_before is not None
|
||||
service = MessagesCleanService.from_time_range(
|
||||
policy=policy,
|
||||
start_from=start_from,
|
||||
end_before=end_before,
|
||||
batch_size=batch_size,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
elif from_days_ago is None:
|
||||
assert before_days is not None
|
||||
service = MessagesCleanService.from_days(
|
||||
policy=policy,
|
||||
days=before_days,
|
||||
batch_size=batch_size,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
else:
|
||||
assert before_days is not None
|
||||
assert from_days_ago is not None
|
||||
now = naive_utc_now()
|
||||
service = MessagesCleanService.from_time_range(
|
||||
policy=policy,
|
||||
start_from=now - datetime.timedelta(days=from_days_ago),
|
||||
end_before=now - datetime.timedelta(days=before_days),
|
||||
batch_size=batch_size,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
stats = service.run()
|
||||
|
||||
end_at = time.perf_counter()
|
||||
@ -2668,3 +2737,77 @@ def clean_expired_messages(
|
||||
raise
|
||||
|
||||
click.echo(click.style("messages cleanup completed.", fg="green"))
|
||||
|
||||
|
||||
@click.command("export-app-messages", help="Export messages for an app to JSONL.GZ.")
|
||||
@click.option("--app-id", required=True, help="Application ID to export messages for.")
|
||||
@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.",
|
||||
)
|
||||
@click.option(
|
||||
"--end-before",
|
||||
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
|
||||
required=True,
|
||||
help="Upper bound (exclusive) for created_at.",
|
||||
)
|
||||
@click.option(
|
||||
"--filename",
|
||||
required=True,
|
||||
help="Base filename (relative path). Do not include suffix like .jsonl.gz.",
|
||||
)
|
||||
@click.option("--use-cloud-storage", is_flag=True, default=False, help="Upload to cloud storage instead of local file.")
|
||||
@click.option("--batch-size", default=1000, show_default=True, help="Batch size for cursor pagination.")
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Scan only, print stats without writing any file.")
|
||||
def export_app_messages(
|
||||
app_id: str,
|
||||
start_from: datetime.datetime | None,
|
||||
end_before: datetime.datetime,
|
||||
filename: str,
|
||||
use_cloud_storage: bool,
|
||||
batch_size: int,
|
||||
dry_run: bool,
|
||||
):
|
||||
if start_from and start_from >= end_before:
|
||||
raise click.UsageError("--start-from must be before --end-before.")
|
||||
|
||||
from services.retention.conversation.message_export_service import AppMessageExportService
|
||||
|
||||
try:
|
||||
validated_filename = AppMessageExportService.validate_export_filename(filename)
|
||||
except ValueError as e:
|
||||
raise click.BadParameter(str(e), param_hint="--filename") from e
|
||||
|
||||
click.echo(click.style(f"export_app_messages: starting export for app {app_id}.", fg="green"))
|
||||
start_at = time.perf_counter()
|
||||
|
||||
try:
|
||||
service = AppMessageExportService(
|
||||
app_id=app_id,
|
||||
end_before=end_before,
|
||||
filename=validated_filename,
|
||||
start_from=start_from,
|
||||
batch_size=batch_size,
|
||||
use_cloud_storage=use_cloud_storage,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
stats = service.run()
|
||||
|
||||
elapsed = time.perf_counter() - start_at
|
||||
click.echo(
|
||||
click.style(
|
||||
f"export_app_messages: completed in {elapsed:.2f}s\n"
|
||||
f" - Batches: {stats.batches}\n"
|
||||
f" - Total messages: {stats.total_messages}\n"
|
||||
f" - Messages with feedback: {stats.messages_with_feedback}\n"
|
||||
f" - Total feedbacks: {stats.total_feedbacks}",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
elapsed = time.perf_counter() - start_at
|
||||
logger.exception("export_app_messages failed")
|
||||
click.echo(click.style(f"export_app_messages: failed after {elapsed:.2f}s - {e}", fg="red"))
|
||||
raise
|
||||
|
||||
@ -239,7 +239,7 @@ class MessageSuggestedQuestionApi(WebApiResource):
|
||||
def get(self, app_model, end_user, message_id):
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
raise NotCompletionAppError()
|
||||
raise NotChatAppError()
|
||||
|
||||
message_id = str(message_id)
|
||||
|
||||
|
||||
@ -44,14 +44,13 @@ from core.app.entities.task_entities import (
|
||||
)
|
||||
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
|
||||
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
|
||||
from core.app.task_pipeline.message_file_utils import prepare_file_dict
|
||||
from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk
|
||||
from core.model_manager import ModelInstance
|
||||
from core.ops.entities.trace_entity import TraceTaskName
|
||||
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
|
||||
from core.prompt.utils.prompt_message_util import PromptMessageUtil
|
||||
from core.prompt.utils.prompt_template_parser import PromptTemplateParser
|
||||
from core.tools.signature import sign_tool_file
|
||||
from dify_graph.file import helpers as file_helpers
|
||||
from dify_graph.file.enums import FileTransferMethod
|
||||
from dify_graph.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage
|
||||
from dify_graph.model_runtime.entities.message_entities import (
|
||||
@ -460,91 +459,40 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline):
|
||||
"""
|
||||
self._task_state.metadata.usage = self._task_state.llm_result.usage
|
||||
metadata_dict = self._task_state.metadata.model_dump()
|
||||
|
||||
# Fetch files associated with this message
|
||||
files = None
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
message_files = session.scalars(select(MessageFile).where(MessageFile.message_id == self._message_id)).all()
|
||||
|
||||
if message_files:
|
||||
# Fetch all required UploadFile objects in a single query to avoid N+1 problem
|
||||
upload_file_ids = list(
|
||||
dict.fromkeys(
|
||||
mf.upload_file_id
|
||||
for mf in message_files
|
||||
if mf.transfer_method == FileTransferMethod.LOCAL_FILE and mf.upload_file_id
|
||||
)
|
||||
)
|
||||
upload_files_map = {}
|
||||
if upload_file_ids:
|
||||
upload_files = session.scalars(select(UploadFile).where(UploadFile.id.in_(upload_file_ids))).all()
|
||||
upload_files_map = {uf.id: uf for uf in upload_files}
|
||||
|
||||
files_list = []
|
||||
for message_file in message_files:
|
||||
file_dict = prepare_file_dict(message_file, upload_files_map)
|
||||
files_list.append(file_dict)
|
||||
|
||||
files = files_list or None
|
||||
|
||||
return MessageEndStreamResponse(
|
||||
task_id=self._application_generate_entity.task_id,
|
||||
id=self._message_id,
|
||||
metadata=metadata_dict,
|
||||
files=files,
|
||||
)
|
||||
|
||||
def _record_files(self):
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
message_files = session.scalars(select(MessageFile).where(MessageFile.message_id == self._message_id)).all()
|
||||
if not message_files:
|
||||
return None
|
||||
|
||||
files_list = []
|
||||
upload_file_ids = [
|
||||
mf.upload_file_id
|
||||
for mf in message_files
|
||||
if mf.transfer_method == FileTransferMethod.LOCAL_FILE and mf.upload_file_id
|
||||
]
|
||||
upload_files_map = {}
|
||||
if upload_file_ids:
|
||||
upload_files = session.scalars(select(UploadFile).where(UploadFile.id.in_(upload_file_ids))).all()
|
||||
upload_files_map = {uf.id: uf for uf in upload_files}
|
||||
|
||||
for message_file in message_files:
|
||||
upload_file = None
|
||||
if message_file.transfer_method == FileTransferMethod.LOCAL_FILE and message_file.upload_file_id:
|
||||
upload_file = upload_files_map.get(message_file.upload_file_id)
|
||||
|
||||
url = None
|
||||
filename = "file"
|
||||
mime_type = "application/octet-stream"
|
||||
size = 0
|
||||
extension = ""
|
||||
|
||||
if message_file.transfer_method == FileTransferMethod.REMOTE_URL:
|
||||
url = message_file.url
|
||||
if message_file.url:
|
||||
filename = message_file.url.split("/")[-1].split("?")[0] # Remove query params
|
||||
elif message_file.transfer_method == FileTransferMethod.LOCAL_FILE:
|
||||
if upload_file:
|
||||
url = file_helpers.get_signed_file_url(upload_file_id=str(upload_file.id))
|
||||
filename = upload_file.name
|
||||
mime_type = upload_file.mime_type or "application/octet-stream"
|
||||
size = upload_file.size or 0
|
||||
extension = f".{upload_file.extension}" if upload_file.extension else ""
|
||||
elif message_file.upload_file_id:
|
||||
# Fallback: generate URL even if upload_file not found
|
||||
url = file_helpers.get_signed_file_url(upload_file_id=str(message_file.upload_file_id))
|
||||
elif message_file.transfer_method == FileTransferMethod.TOOL_FILE and message_file.url:
|
||||
# For tool files, use URL directly if it's HTTP, otherwise sign it
|
||||
if message_file.url.startswith("http"):
|
||||
url = message_file.url
|
||||
filename = message_file.url.split("/")[-1].split("?")[0]
|
||||
else:
|
||||
# Extract tool file id and extension from URL
|
||||
url_parts = message_file.url.split("/")
|
||||
if url_parts:
|
||||
file_part = url_parts[-1].split("?")[0] # Remove query params first
|
||||
# Use rsplit to correctly handle filenames with multiple dots
|
||||
if "." in file_part:
|
||||
tool_file_id, ext = file_part.rsplit(".", 1)
|
||||
extension = f".{ext}"
|
||||
else:
|
||||
tool_file_id = file_part
|
||||
extension = ".bin"
|
||||
url = sign_tool_file(tool_file_id=tool_file_id, extension=extension)
|
||||
filename = file_part
|
||||
|
||||
transfer_method_value = message_file.transfer_method
|
||||
remote_url = message_file.url if message_file.transfer_method == FileTransferMethod.REMOTE_URL else ""
|
||||
file_dict = {
|
||||
"related_id": message_file.id,
|
||||
"extension": extension,
|
||||
"filename": filename,
|
||||
"size": size,
|
||||
"mime_type": mime_type,
|
||||
"transfer_method": transfer_method_value,
|
||||
"type": message_file.type,
|
||||
"url": url or "",
|
||||
"upload_file_id": message_file.upload_file_id or message_file.id,
|
||||
"remote_url": remote_url,
|
||||
}
|
||||
files_list.append(file_dict)
|
||||
return files_list or None
|
||||
|
||||
def _agent_message_to_stream_response(self, answer: str, message_id: str) -> AgentMessageStreamResponse:
|
||||
"""
|
||||
Agent message to stream response.
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from threading import Thread
|
||||
from threading import Thread, Timer
|
||||
from typing import Union
|
||||
|
||||
from flask import Flask, current_app
|
||||
@ -96,9 +95,9 @@ class MessageCycleManager:
|
||||
if auto_generate_conversation_name and is_first_message:
|
||||
# start generate thread
|
||||
# time.sleep not block other logic
|
||||
time.sleep(1)
|
||||
thread = Thread(
|
||||
target=self._generate_conversation_name_worker,
|
||||
thread = Timer(
|
||||
1,
|
||||
self._generate_conversation_name_worker,
|
||||
kwargs={
|
||||
"flask_app": current_app._get_current_object(), # type: ignore
|
||||
"conversation_id": conversation_id,
|
||||
|
||||
76
api/core/app/task_pipeline/message_file_utils.py
Normal file
76
api/core/app/task_pipeline/message_file_utils.py
Normal file
@ -0,0 +1,76 @@
|
||||
from core.tools.signature import sign_tool_file
|
||||
from dify_graph.file import helpers as file_helpers
|
||||
from dify_graph.file.enums import FileTransferMethod
|
||||
from models.model import MessageFile, UploadFile
|
||||
|
||||
MAX_TOOL_FILE_EXTENSION_LENGTH = 10
|
||||
|
||||
|
||||
def prepare_file_dict(message_file: MessageFile, upload_files_map: dict[str, UploadFile]) -> dict:
|
||||
"""
|
||||
Prepare file dictionary for message end stream response.
|
||||
|
||||
:param message_file: MessageFile instance
|
||||
:param upload_files_map: Dictionary mapping upload_file_id to UploadFile
|
||||
:return: Dictionary containing file information
|
||||
"""
|
||||
upload_file = None
|
||||
if message_file.transfer_method == FileTransferMethod.LOCAL_FILE and message_file.upload_file_id:
|
||||
upload_file = upload_files_map.get(message_file.upload_file_id)
|
||||
|
||||
url = None
|
||||
filename = "file"
|
||||
mime_type = "application/octet-stream"
|
||||
size = 0
|
||||
extension = ""
|
||||
|
||||
if message_file.transfer_method == FileTransferMethod.REMOTE_URL:
|
||||
url = message_file.url
|
||||
if message_file.url:
|
||||
filename = message_file.url.split("/")[-1].split("?")[0]
|
||||
if "." in filename:
|
||||
extension = "." + filename.rsplit(".", 1)[1]
|
||||
elif message_file.transfer_method == FileTransferMethod.LOCAL_FILE:
|
||||
if upload_file:
|
||||
url = file_helpers.get_signed_file_url(upload_file_id=str(upload_file.id))
|
||||
filename = upload_file.name
|
||||
mime_type = upload_file.mime_type or "application/octet-stream"
|
||||
size = upload_file.size or 0
|
||||
extension = f".{upload_file.extension}" if upload_file.extension else ""
|
||||
elif message_file.upload_file_id:
|
||||
url = file_helpers.get_signed_file_url(upload_file_id=str(message_file.upload_file_id))
|
||||
elif message_file.transfer_method == FileTransferMethod.TOOL_FILE and message_file.url:
|
||||
if message_file.url.startswith(("http://", "https://")):
|
||||
url = message_file.url
|
||||
filename = message_file.url.split("/")[-1].split("?")[0]
|
||||
if "." in filename:
|
||||
extension = "." + filename.rsplit(".", 1)[1]
|
||||
else:
|
||||
url_parts = message_file.url.split("/")
|
||||
if url_parts:
|
||||
file_part = url_parts[-1].split("?")[0]
|
||||
if "." in file_part:
|
||||
tool_file_id, ext = file_part.rsplit(".", 1)
|
||||
extension = f".{ext}"
|
||||
if len(extension) > MAX_TOOL_FILE_EXTENSION_LENGTH:
|
||||
extension = ".bin"
|
||||
else:
|
||||
tool_file_id = file_part
|
||||
extension = ".bin"
|
||||
url = sign_tool_file(tool_file_id=tool_file_id, extension=extension)
|
||||
filename = file_part
|
||||
|
||||
transfer_method_value = message_file.transfer_method.value
|
||||
remote_url = message_file.url if message_file.transfer_method == FileTransferMethod.REMOTE_URL else ""
|
||||
return {
|
||||
"related_id": message_file.id,
|
||||
"extension": extension,
|
||||
"filename": filename,
|
||||
"size": size,
|
||||
"mime_type": mime_type,
|
||||
"transfer_method": transfer_method_value,
|
||||
"type": message_file.type,
|
||||
"url": url or "",
|
||||
"upload_file_id": message_file.upload_file_id or message_file.id,
|
||||
"remote_url": remote_url,
|
||||
}
|
||||
@ -37,6 +37,7 @@ VARIABLE_TO_PARAMETER_TYPE_MAPPING = {
|
||||
VariableEntityType.CHECKBOX: ToolParameter.ToolParameterType.BOOLEAN,
|
||||
VariableEntityType.FILE: ToolParameter.ToolParameterType.FILE,
|
||||
VariableEntityType.FILE_LIST: ToolParameter.ToolParameterType.FILES,
|
||||
VariableEntityType.JSON_OBJECT: ToolParameter.ToolParameterType.OBJECT,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@ -82,8 +83,18 @@ class DocumentExtractorNode(Node[DocumentExtractorNodeData]):
|
||||
|
||||
value = variable.value
|
||||
inputs = {"variable_selector": variable_selector}
|
||||
if isinstance(value, list):
|
||||
value = list(filter(lambda x: x, value))
|
||||
process_data = {"documents": value if isinstance(value, list) else [value]}
|
||||
|
||||
if not value:
|
||||
return NodeRunResult(
|
||||
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
outputs={"text": ArrayStringSegment(value=[])},
|
||||
)
|
||||
|
||||
try:
|
||||
if isinstance(value, list):
|
||||
extracted_text_list = [
|
||||
@ -111,6 +122,7 @@ class DocumentExtractorNode(Node[DocumentExtractorNodeData]):
|
||||
else:
|
||||
raise DocumentExtractorError(f"Unsupported variable type: {type(value)}")
|
||||
except DocumentExtractorError as e:
|
||||
logger.warning(e, exc_info=True)
|
||||
return NodeRunResult(
|
||||
status=WorkflowNodeExecutionStatus.FAILED,
|
||||
error=str(e),
|
||||
@ -385,6 +397,32 @@ def parser_docx_part(block, doc: Document, content_items, i):
|
||||
content_items.append((i, "table", Table(block, doc)))
|
||||
|
||||
|
||||
def _normalize_docx_zip(file_content: bytes) -> bytes:
|
||||
"""
|
||||
Some DOCX files (e.g. exported by Evernote on Windows) are malformed:
|
||||
ZIP entry names use backslash (\\) as path separator instead of the forward
|
||||
slash (/) required by both the ZIP spec and OOXML. On Linux/Mac the entry
|
||||
"word\\document.xml" is never found when python-docx looks for
|
||||
"word/document.xml", which triggers a KeyError about a missing relationship.
|
||||
|
||||
This function rewrites the ZIP in-memory, normalizing all entry names to
|
||||
use forward slashes without touching any actual document content.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(file_content), "r") as zin:
|
||||
out_buf = io.BytesIO()
|
||||
with zipfile.ZipFile(out_buf, "w", compression=zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in zin.infolist():
|
||||
data = zin.read(item.filename)
|
||||
# Normalize backslash path separators to forward slash
|
||||
item.filename = item.filename.replace("\\", "/")
|
||||
zout.writestr(item, data)
|
||||
return out_buf.getvalue()
|
||||
except zipfile.BadZipFile:
|
||||
# Not a valid zip — return as-is and let python-docx report the real error
|
||||
return file_content
|
||||
|
||||
|
||||
def _extract_text_from_docx(file_content: bytes) -> str:
|
||||
"""
|
||||
Extract text from a DOCX file.
|
||||
@ -392,7 +430,15 @@ def _extract_text_from_docx(file_content: bytes) -> str:
|
||||
"""
|
||||
try:
|
||||
doc_file = io.BytesIO(file_content)
|
||||
doc = docx.Document(doc_file)
|
||||
try:
|
||||
doc = docx.Document(doc_file)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse DOCX, attempting to normalize ZIP entry paths: %s", e)
|
||||
# Some DOCX files exported by tools like Evernote on Windows use
|
||||
# backslash path separators in ZIP entries and/or single-quoted XML
|
||||
# attributes, both of which break python-docx on Linux. Normalize and retry.
|
||||
file_content = _normalize_docx_zip(file_content)
|
||||
doc = docx.Document(io.BytesIO(file_content))
|
||||
text = []
|
||||
|
||||
# Keep track of paragraph and table positions
|
||||
|
||||
@ -23,7 +23,11 @@ from dify_graph.variables import (
|
||||
)
|
||||
from dify_graph.variables.segments import ArrayObjectSegment
|
||||
|
||||
from .entities import KnowledgeRetrievalNodeData
|
||||
from .entities import (
|
||||
Condition,
|
||||
KnowledgeRetrievalNodeData,
|
||||
MetadataFilteringCondition,
|
||||
)
|
||||
from .exc import (
|
||||
KnowledgeRetrievalNodeError,
|
||||
RateLimitExceededError,
|
||||
@ -171,6 +175,12 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
|
||||
if node_data.metadata_filtering_mode is not None:
|
||||
metadata_filtering_mode = node_data.metadata_filtering_mode
|
||||
|
||||
resolved_metadata_conditions = (
|
||||
self._resolve_metadata_filtering_conditions(node_data.metadata_filtering_conditions)
|
||||
if node_data.metadata_filtering_conditions
|
||||
else None
|
||||
)
|
||||
|
||||
if str(node_data.retrieval_mode) == DatasetRetrieveConfigEntity.RetrieveStrategy.SINGLE and query:
|
||||
# fetch model config
|
||||
if node_data.single_retrieval_config is None:
|
||||
@ -189,7 +199,7 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
|
||||
model_mode=model.mode,
|
||||
model_name=model.name,
|
||||
metadata_model_config=node_data.metadata_model_config,
|
||||
metadata_filtering_conditions=node_data.metadata_filtering_conditions,
|
||||
metadata_filtering_conditions=resolved_metadata_conditions,
|
||||
metadata_filtering_mode=metadata_filtering_mode,
|
||||
query=query,
|
||||
)
|
||||
@ -247,7 +257,7 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
|
||||
weights=weights,
|
||||
reranking_enable=node_data.multiple_retrieval_config.reranking_enable,
|
||||
metadata_model_config=node_data.metadata_model_config,
|
||||
metadata_filtering_conditions=node_data.metadata_filtering_conditions,
|
||||
metadata_filtering_conditions=resolved_metadata_conditions,
|
||||
metadata_filtering_mode=metadata_filtering_mode,
|
||||
attachment_ids=[attachment.related_id for attachment in attachments] if attachments else None,
|
||||
)
|
||||
@ -256,6 +266,48 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
|
||||
usage = self._rag_retrieval.llm_usage
|
||||
return retrieval_resource_list, usage
|
||||
|
||||
def _resolve_metadata_filtering_conditions(
|
||||
self, conditions: MetadataFilteringCondition
|
||||
) -> MetadataFilteringCondition:
|
||||
if conditions.conditions is None:
|
||||
return MetadataFilteringCondition(
|
||||
logical_operator=conditions.logical_operator,
|
||||
conditions=None,
|
||||
)
|
||||
|
||||
variable_pool = self.graph_runtime_state.variable_pool
|
||||
resolved_conditions: list[Condition] = []
|
||||
for cond in conditions.conditions or []:
|
||||
value = cond.value
|
||||
if isinstance(value, str):
|
||||
segment_group = variable_pool.convert_template(value)
|
||||
if len(segment_group.value) == 1:
|
||||
resolved_value = segment_group.value[0].to_object()
|
||||
else:
|
||||
resolved_value = segment_group.text
|
||||
elif isinstance(value, Sequence) and all(isinstance(v, str) for v in value):
|
||||
resolved_values = []
|
||||
for v in value: # type: ignore
|
||||
segment_group = variable_pool.convert_template(v)
|
||||
if len(segment_group.value) == 1:
|
||||
resolved_values.append(segment_group.value[0].to_object())
|
||||
else:
|
||||
resolved_values.append(segment_group.text)
|
||||
resolved_value = resolved_values
|
||||
else:
|
||||
resolved_value = value
|
||||
resolved_conditions.append(
|
||||
Condition(
|
||||
name=cond.name,
|
||||
comparison_operator=cond.comparison_operator,
|
||||
value=resolved_value,
|
||||
)
|
||||
)
|
||||
return MetadataFilteringCondition(
|
||||
logical_operator=conditions.logical_operator or "and",
|
||||
conditions=resolved_conditions,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _extract_variable_selector_to_variable_mapping(
|
||||
cls,
|
||||
|
||||
@ -13,6 +13,7 @@ def init_app(app: DifyApp):
|
||||
convert_to_agent_apps,
|
||||
create_tenant,
|
||||
delete_archived_workflow_runs,
|
||||
export_app_messages,
|
||||
extract_plugins,
|
||||
extract_unique_plugins,
|
||||
file_usage,
|
||||
@ -66,6 +67,7 @@ def init_app(app: DifyApp):
|
||||
restore_workflow_runs,
|
||||
clean_workflow_runs,
|
||||
clean_expired_messages,
|
||||
export_app_messages,
|
||||
]
|
||||
for cmd in cmds_to_register:
|
||||
app.cli.add_command(cmd)
|
||||
|
||||
@ -21,6 +21,10 @@ celery_redis = Redis(
|
||||
ssl_cert_reqs=getattr(dify_config, "REDIS_SSL_CERT_REQS", None) if dify_config.BROKER_USE_SSL else None,
|
||||
ssl_certfile=getattr(dify_config, "REDIS_SSL_CERTFILE", None) if dify_config.BROKER_USE_SSL else None,
|
||||
ssl_keyfile=getattr(dify_config, "REDIS_SSL_KEYFILE", None) if dify_config.BROKER_USE_SSL else None,
|
||||
# Add conservative socket timeouts and health checks to avoid long-lived half-open sockets
|
||||
socket_timeout=5,
|
||||
socket_connect_timeout=5,
|
||||
health_check_interval=30,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -3,6 +3,7 @@ import math
|
||||
import time
|
||||
from collections.abc import Iterable, Sequence
|
||||
|
||||
from celery import group
|
||||
from sqlalchemy import ColumnElement, and_, func, or_, select
|
||||
from sqlalchemy.engine.row import Row
|
||||
from sqlalchemy.orm import Session
|
||||
@ -85,20 +86,25 @@ def trigger_provider_refresh() -> None:
|
||||
lock_keys: list[str] = build_trigger_refresh_lock_keys(subscriptions)
|
||||
acquired: list[bool] = _acquire_locks(keys=lock_keys, ttl_seconds=lock_ttl)
|
||||
|
||||
enqueued: int = 0
|
||||
for (tenant_id, subscription_id), is_locked in zip(subscriptions, acquired):
|
||||
if not is_locked:
|
||||
continue
|
||||
trigger_subscription_refresh.delay(tenant_id=tenant_id, subscription_id=subscription_id)
|
||||
enqueued += 1
|
||||
if not any(acquired):
|
||||
continue
|
||||
|
||||
jobs = [
|
||||
trigger_subscription_refresh.s(tenant_id=tenant_id, subscription_id=subscription_id)
|
||||
for (tenant_id, subscription_id), is_locked in zip(subscriptions, acquired)
|
||||
if is_locked
|
||||
]
|
||||
result = group(jobs).apply_async()
|
||||
enqueued = len(jobs)
|
||||
|
||||
logger.info(
|
||||
"Trigger refresh page %d/%d: scanned=%d locks_acquired=%d enqueued=%d",
|
||||
"Trigger refresh page %d/%d: scanned=%d locks_acquired=%d enqueued=%d result=%s",
|
||||
page + 1,
|
||||
pages,
|
||||
len(subscriptions),
|
||||
sum(1 for x in acquired if x),
|
||||
enqueued,
|
||||
result,
|
||||
)
|
||||
|
||||
logger.info("Trigger refresh scan done: due=%d", total_due)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import logging
|
||||
|
||||
from celery import group, shared_task
|
||||
from celery import current_app, group, shared_task
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
@ -29,31 +29,27 @@ def poll_workflow_schedules() -> None:
|
||||
with session_factory() as session:
|
||||
total_dispatched = 0
|
||||
|
||||
# Process in batches until we've handled all due schedules or hit the limit
|
||||
while True:
|
||||
due_schedules = _fetch_due_schedules(session)
|
||||
|
||||
if not due_schedules:
|
||||
break
|
||||
|
||||
dispatched_count = _process_schedules(session, due_schedules)
|
||||
total_dispatched += dispatched_count
|
||||
with current_app.producer_or_acquire() as producer: # type: ignore
|
||||
dispatched_count = _process_schedules(session, due_schedules, producer)
|
||||
total_dispatched += dispatched_count
|
||||
|
||||
logger.debug("Batch processed: %d dispatched", dispatched_count)
|
||||
|
||||
# Circuit breaker: check if we've hit the per-tick limit (if enabled)
|
||||
if (
|
||||
dify_config.WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK > 0
|
||||
and total_dispatched >= dify_config.WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK
|
||||
):
|
||||
logger.warning(
|
||||
"Circuit breaker activated: reached dispatch limit (%d), will continue next tick",
|
||||
dify_config.WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK,
|
||||
)
|
||||
break
|
||||
logger.debug("Batch processed: %d dispatched", dispatched_count)
|
||||
|
||||
# Circuit breaker: check if we've hit the per-tick limit (if enabled)
|
||||
if 0 < dify_config.WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK <= total_dispatched:
|
||||
logger.warning(
|
||||
"Circuit breaker activated: reached dispatch limit (%d), will continue next tick",
|
||||
dify_config.WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK,
|
||||
)
|
||||
break
|
||||
if total_dispatched > 0:
|
||||
logger.info("Total processed: %d dispatched", total_dispatched)
|
||||
logger.info("Total processed: %d workflow schedule(s) dispatched", total_dispatched)
|
||||
|
||||
|
||||
def _fetch_due_schedules(session: Session) -> list[WorkflowSchedulePlan]:
|
||||
@ -90,7 +86,7 @@ def _fetch_due_schedules(session: Session) -> list[WorkflowSchedulePlan]:
|
||||
return list(due_schedules)
|
||||
|
||||
|
||||
def _process_schedules(session: Session, schedules: list[WorkflowSchedulePlan]) -> int:
|
||||
def _process_schedules(session: Session, schedules: list[WorkflowSchedulePlan], producer=None) -> int:
|
||||
"""Process schedules: check quota, update next run time and dispatch to Celery in parallel."""
|
||||
if not schedules:
|
||||
return 0
|
||||
@ -107,7 +103,7 @@ def _process_schedules(session: Session, schedules: list[WorkflowSchedulePlan])
|
||||
|
||||
if tasks_to_dispatch:
|
||||
job = group(run_schedule_trigger.s(schedule_id) for schedule_id in tasks_to_dispatch)
|
||||
job.apply_async()
|
||||
job.apply_async(producer=producer)
|
||||
|
||||
logger.debug("Dispatched %d tasks in parallel", len(tasks_to_dispatch))
|
||||
|
||||
|
||||
@ -63,7 +63,12 @@ class RagPipelineTransformService:
|
||||
):
|
||||
node = self._deal_file_extensions(node)
|
||||
if node.get("data", {}).get("type") == "knowledge-index":
|
||||
node = self._deal_knowledge_index(dataset, doc_form, indexing_technique, retrieval_model, node)
|
||||
knowledge_configuration = KnowledgeConfiguration.model_validate(node.get("data", {}))
|
||||
if dataset.tenant_id != current_user.current_tenant_id:
|
||||
raise ValueError("Unauthorized")
|
||||
node = self._deal_knowledge_index(
|
||||
knowledge_configuration, dataset, indexing_technique, retrieval_model, node
|
||||
)
|
||||
new_nodes.append(node)
|
||||
if new_nodes:
|
||||
graph["nodes"] = new_nodes
|
||||
@ -155,14 +160,13 @@ class RagPipelineTransformService:
|
||||
|
||||
def _deal_knowledge_index(
|
||||
self,
|
||||
knowledge_configuration: KnowledgeConfiguration,
|
||||
dataset: Dataset,
|
||||
doc_form: str,
|
||||
indexing_technique: str | None,
|
||||
retrieval_model: RetrievalSetting | None,
|
||||
node: dict,
|
||||
):
|
||||
knowledge_configuration_dict = node.get("data", {})
|
||||
knowledge_configuration = KnowledgeConfiguration.model_validate(knowledge_configuration_dict)
|
||||
|
||||
if indexing_technique == "high_quality":
|
||||
knowledge_configuration.embedding_model = dataset.embedding_model
|
||||
|
||||
304
api/services/retention/conversation/message_export_service.py
Normal file
304
api/services/retention/conversation/message_export_service.py
Normal file
@ -0,0 +1,304 @@
|
||||
"""
|
||||
Export app messages to JSONL.GZ format.
|
||||
|
||||
Outputs: conversation_id, message_id, query, answer, inputs (raw JSON),
|
||||
retriever_resources (from message_metadata), feedback (user feedbacks array).
|
||||
|
||||
Uses (created_at, id) cursor pagination and batch-loads feedbacks to avoid N+1.
|
||||
Does NOT touch Message.inputs / Message.user_feedback properties.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import gzip
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from collections.abc import Generator, Iterable
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, BinaryIO, cast
|
||||
|
||||
import orjson
|
||||
import sqlalchemy as sa
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select, tuple_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_storage import storage
|
||||
from models.model import Message, MessageFeedback
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_FILENAME_BASE_LENGTH = 1024
|
||||
FORBIDDEN_FILENAME_SUFFIXES = (".jsonl.gz", ".jsonl", ".gz")
|
||||
|
||||
|
||||
class AppMessageExportFeedback(BaseModel):
|
||||
id: str
|
||||
app_id: str
|
||||
conversation_id: str
|
||||
message_id: str
|
||||
rating: str
|
||||
content: str | None = None
|
||||
from_source: str
|
||||
from_end_user_id: str | None = None
|
||||
from_account_id: str | None = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AppMessageExportRecord(BaseModel):
|
||||
conversation_id: str
|
||||
message_id: str
|
||||
query: str
|
||||
answer: str
|
||||
inputs: dict[str, Any]
|
||||
retriever_resources: list[Any] = Field(default_factory=list)
|
||||
feedback: list[AppMessageExportFeedback] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AppMessageExportStats(BaseModel):
|
||||
batches: int = 0
|
||||
total_messages: int = 0
|
||||
messages_with_feedback: int = 0
|
||||
total_feedbacks: int = 0
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AppMessageExportService:
|
||||
@staticmethod
|
||||
def validate_export_filename(filename: str) -> str:
|
||||
normalized = filename.strip()
|
||||
if not normalized:
|
||||
raise ValueError("--filename must not be empty.")
|
||||
|
||||
normalized_lower = normalized.lower()
|
||||
if normalized_lower.endswith(FORBIDDEN_FILENAME_SUFFIXES):
|
||||
raise ValueError("--filename must not include .jsonl.gz/.jsonl/.gz suffix; pass base filename only.")
|
||||
|
||||
if normalized.startswith("/"):
|
||||
raise ValueError("--filename must be a relative path; absolute paths are not allowed.")
|
||||
|
||||
if "\\" in normalized:
|
||||
raise ValueError("--filename must use '/' as path separator; '\\' is not allowed.")
|
||||
|
||||
if "//" in normalized:
|
||||
raise ValueError("--filename must not contain empty path segments ('//').")
|
||||
|
||||
if len(normalized) > MAX_FILENAME_BASE_LENGTH:
|
||||
raise ValueError(f"--filename is too long; max length is {MAX_FILENAME_BASE_LENGTH}.")
|
||||
|
||||
for ch in normalized:
|
||||
if ch == "\x00" or ord(ch) < 32 or ord(ch) == 127:
|
||||
raise ValueError("--filename must not contain control characters or NUL.")
|
||||
|
||||
parts = PurePosixPath(normalized).parts
|
||||
if not parts:
|
||||
raise ValueError("--filename must include a file name.")
|
||||
|
||||
if any(part in (".", "..") for part in parts):
|
||||
raise ValueError("--filename must not contain '.' or '..' path segments.")
|
||||
|
||||
return normalized
|
||||
|
||||
@property
|
||||
def output_gz_name(self) -> str:
|
||||
return f"{self._filename_base}.jsonl.gz"
|
||||
|
||||
@property
|
||||
def output_jsonl_name(self) -> str:
|
||||
return f"{self._filename_base}.jsonl"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_id: str,
|
||||
end_before: datetime.datetime,
|
||||
filename: str,
|
||||
*,
|
||||
start_from: datetime.datetime | None = None,
|
||||
batch_size: int = 1000,
|
||||
use_cloud_storage: bool = False,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
if start_from and start_from >= end_before:
|
||||
raise ValueError(f"start_from ({start_from}) must be before end_before ({end_before})")
|
||||
|
||||
self._app_id = app_id
|
||||
self._end_before = end_before
|
||||
self._start_from = start_from
|
||||
self._filename_base = self.validate_export_filename(filename)
|
||||
self._batch_size = batch_size
|
||||
self._use_cloud_storage = use_cloud_storage
|
||||
self._dry_run = dry_run
|
||||
|
||||
def run(self) -> AppMessageExportStats:
|
||||
stats = AppMessageExportStats()
|
||||
|
||||
logger.info(
|
||||
"export_app_messages: app_id=%s, start_from=%s, end_before=%s, dry_run=%s, cloud=%s, output_gz=%s",
|
||||
self._app_id,
|
||||
self._start_from,
|
||||
self._end_before,
|
||||
self._dry_run,
|
||||
self._use_cloud_storage,
|
||||
self.output_gz_name,
|
||||
)
|
||||
|
||||
if self._dry_run:
|
||||
for _ in self._iter_records_with_stats(stats):
|
||||
pass
|
||||
self._finalize_stats(stats)
|
||||
return stats
|
||||
|
||||
if self._use_cloud_storage:
|
||||
self._export_to_cloud(stats)
|
||||
else:
|
||||
self._export_to_local(stats)
|
||||
|
||||
self._finalize_stats(stats)
|
||||
return stats
|
||||
|
||||
def iter_records(self) -> Generator[AppMessageExportRecord, None, None]:
|
||||
for batch in self._iter_record_batches():
|
||||
yield from batch
|
||||
|
||||
@staticmethod
|
||||
def write_jsonl_gz(records: Iterable[AppMessageExportRecord], fileobj: BinaryIO) -> None:
|
||||
with gzip.GzipFile(fileobj=fileobj, mode="wb") as gz:
|
||||
for record in records:
|
||||
gz.write(orjson.dumps(record.model_dump(mode="json")) + b"\n")
|
||||
|
||||
def _export_to_local(self, stats: AppMessageExportStats) -> None:
|
||||
output_path = Path.cwd() / self.output_gz_name
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with output_path.open("wb") as output_file:
|
||||
self.write_jsonl_gz(self._iter_records_with_stats(stats), output_file)
|
||||
|
||||
def _export_to_cloud(self, stats: AppMessageExportStats) -> None:
|
||||
with tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024) as tmp:
|
||||
self.write_jsonl_gz(self._iter_records_with_stats(stats), cast(BinaryIO, tmp))
|
||||
tmp.seek(0)
|
||||
data = tmp.read()
|
||||
|
||||
storage.save(self.output_gz_name, data)
|
||||
logger.info("export_app_messages: uploaded %d bytes to cloud key=%s", len(data), self.output_gz_name)
|
||||
|
||||
def _iter_records_with_stats(self, stats: AppMessageExportStats) -> Generator[AppMessageExportRecord, None, None]:
|
||||
for record in self.iter_records():
|
||||
self._update_stats(stats, record)
|
||||
yield record
|
||||
|
||||
@staticmethod
|
||||
def _update_stats(stats: AppMessageExportStats, record: AppMessageExportRecord) -> None:
|
||||
stats.total_messages += 1
|
||||
if record.feedback:
|
||||
stats.messages_with_feedback += 1
|
||||
stats.total_feedbacks += len(record.feedback)
|
||||
|
||||
def _finalize_stats(self, stats: AppMessageExportStats) -> None:
|
||||
if stats.total_messages == 0:
|
||||
stats.batches = 0
|
||||
return
|
||||
stats.batches = (stats.total_messages + self._batch_size - 1) // self._batch_size
|
||||
|
||||
def _iter_record_batches(self) -> Generator[list[AppMessageExportRecord], None, None]:
|
||||
cursor: tuple[datetime.datetime, str] | None = None
|
||||
while True:
|
||||
rows, cursor = self._fetch_batch(cursor)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
message_ids = [str(row.id) for row in rows]
|
||||
feedbacks_map = self._fetch_feedbacks(message_ids)
|
||||
yield [self._build_record(row, feedbacks_map) for row in rows]
|
||||
|
||||
def _fetch_batch(
|
||||
self, cursor: tuple[datetime.datetime, str] | None
|
||||
) -> tuple[list[Any], tuple[datetime.datetime, str] | None]:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
stmt = (
|
||||
select(
|
||||
Message.id,
|
||||
Message.conversation_id,
|
||||
Message.query,
|
||||
Message.answer,
|
||||
Message._inputs, # pyright: ignore[reportPrivateUsage]
|
||||
Message.message_metadata,
|
||||
Message.created_at,
|
||||
)
|
||||
.where(
|
||||
Message.app_id == self._app_id,
|
||||
Message.created_at < self._end_before,
|
||||
)
|
||||
.order_by(Message.created_at, Message.id)
|
||||
.limit(self._batch_size)
|
||||
)
|
||||
|
||||
if self._start_from:
|
||||
stmt = stmt.where(Message.created_at >= self._start_from)
|
||||
|
||||
if cursor:
|
||||
stmt = stmt.where(
|
||||
tuple_(Message.created_at, Message.id)
|
||||
> tuple_(
|
||||
sa.literal(cursor[0], type_=sa.DateTime()),
|
||||
sa.literal(cursor[1], type_=Message.id.type),
|
||||
)
|
||||
)
|
||||
|
||||
rows = list(session.execute(stmt).all())
|
||||
|
||||
if not rows:
|
||||
return [], cursor
|
||||
|
||||
last = rows[-1]
|
||||
return rows, (last.created_at, last.id)
|
||||
|
||||
def _fetch_feedbacks(self, message_ids: list[str]) -> dict[str, list[AppMessageExportFeedback]]:
|
||||
if not message_ids:
|
||||
return {}
|
||||
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
stmt = (
|
||||
select(MessageFeedback)
|
||||
.where(
|
||||
MessageFeedback.message_id.in_(message_ids),
|
||||
MessageFeedback.from_source == "user",
|
||||
)
|
||||
.order_by(MessageFeedback.message_id, MessageFeedback.created_at)
|
||||
)
|
||||
feedbacks = list(session.scalars(stmt).all())
|
||||
|
||||
result: dict[str, list[AppMessageExportFeedback]] = defaultdict(list)
|
||||
for feedback in feedbacks:
|
||||
result[str(feedback.message_id)].append(AppMessageExportFeedback.model_validate(feedback.to_dict()))
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _build_record(row: Any, feedbacks_map: dict[str, list[AppMessageExportFeedback]]) -> AppMessageExportRecord:
|
||||
retriever_resources: list[Any] = []
|
||||
if row.message_metadata:
|
||||
try:
|
||||
metadata = json.loads(row.message_metadata)
|
||||
value = metadata.get("retriever_resources", [])
|
||||
if isinstance(value, list):
|
||||
retriever_resources = value
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
message_id = str(row.id)
|
||||
return AppMessageExportRecord(
|
||||
conversation_id=str(row.conversation_id),
|
||||
message_id=message_id,
|
||||
query=row.query,
|
||||
answer=row.answer,
|
||||
inputs=row._inputs if isinstance(row._inputs, dict) else {},
|
||||
retriever_resources=retriever_resources,
|
||||
feedback=feedbacks_map.get(message_id, []),
|
||||
)
|
||||
@ -12,6 +12,7 @@ from sqlalchemy.engine import CursorResult
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.model import (
|
||||
App,
|
||||
AppAnnotationHitHistory,
|
||||
@ -142,7 +143,7 @@ class MessagesCleanService:
|
||||
if batch_size <= 0:
|
||||
raise ValueError(f"batch_size ({batch_size}) must be greater than 0")
|
||||
|
||||
end_before = datetime.datetime.now() - datetime.timedelta(days=days)
|
||||
end_before = naive_utc_now() - datetime.timedelta(days=days)
|
||||
|
||||
logger.info(
|
||||
"clean_messages: days=%s, end_before=%s, batch_size=%s, policy=%s",
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable, Sequence
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Protocol
|
||||
|
||||
import click
|
||||
from celery import shared_task
|
||||
from celery import current_app, shared_task
|
||||
|
||||
from configs import dify_config
|
||||
from core.db.session_factory import session_factory
|
||||
@ -19,6 +20,12 @@ from tasks.generate_summary_index_task import generate_summary_index_task
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CeleryTaskLike(Protocol):
|
||||
def delay(self, *args: Any, **kwargs: Any) -> Any: ...
|
||||
|
||||
def apply_async(self, *args: Any, **kwargs: Any) -> Any: ...
|
||||
|
||||
|
||||
@shared_task(queue="dataset")
|
||||
def document_indexing_task(dataset_id: str, document_ids: list):
|
||||
"""
|
||||
@ -179,8 +186,8 @@ def _document_indexing(dataset_id: str, document_ids: Sequence[str]):
|
||||
|
||||
|
||||
def _document_indexing_with_tenant_queue(
|
||||
tenant_id: str, dataset_id: str, document_ids: Sequence[str], task_func: Callable[[str, str, Sequence[str]], None]
|
||||
):
|
||||
tenant_id: str, dataset_id: str, document_ids: Sequence[str], task_func: CeleryTaskLike
|
||||
) -> None:
|
||||
try:
|
||||
_document_indexing(dataset_id, document_ids)
|
||||
except Exception:
|
||||
@ -201,16 +208,20 @@ def _document_indexing_with_tenant_queue(
|
||||
logger.info("document indexing tenant isolation queue %s next tasks: %s", tenant_id, next_tasks)
|
||||
|
||||
if next_tasks:
|
||||
for next_task in next_tasks:
|
||||
document_task = DocumentTask(**next_task)
|
||||
# Process the next waiting task
|
||||
# Keep the flag set to indicate a task is running
|
||||
tenant_isolated_task_queue.set_task_waiting_time()
|
||||
task_func.delay( # type: ignore
|
||||
tenant_id=document_task.tenant_id,
|
||||
dataset_id=document_task.dataset_id,
|
||||
document_ids=document_task.document_ids,
|
||||
)
|
||||
with current_app.producer_or_acquire() as producer: # type: ignore
|
||||
for next_task in next_tasks:
|
||||
document_task = DocumentTask(**next_task)
|
||||
# Keep the flag set to indicate a task is running
|
||||
tenant_isolated_task_queue.set_task_waiting_time()
|
||||
task_func.apply_async(
|
||||
kwargs={
|
||||
"tenant_id": document_task.tenant_id,
|
||||
"dataset_id": document_task.dataset_id,
|
||||
"document_ids": document_task.document_ids,
|
||||
},
|
||||
producer=producer,
|
||||
)
|
||||
|
||||
else:
|
||||
# No more waiting tasks, clear the flag
|
||||
tenant_isolated_task_queue.delete_task_key()
|
||||
|
||||
@ -3,12 +3,13 @@ import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from itertools import islice
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
from celery import shared_task # type: ignore
|
||||
from celery import group, shared_task
|
||||
from flask import current_app, g
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
@ -27,6 +28,11 @@ from services.file_service import FileService
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def chunked(iterable: Sequence, size: int):
|
||||
it = iter(iterable)
|
||||
return iter(lambda: list(islice(it, size)), [])
|
||||
|
||||
|
||||
@shared_task(queue="pipeline")
|
||||
def rag_pipeline_run_task(
|
||||
rag_pipeline_invoke_entities_file_id: str,
|
||||
@ -83,16 +89,24 @@ def rag_pipeline_run_task(
|
||||
logger.info("rag pipeline tenant isolation queue %s next files: %s", tenant_id, next_file_ids)
|
||||
|
||||
if next_file_ids:
|
||||
for next_file_id in next_file_ids:
|
||||
# Process the next waiting task
|
||||
# Keep the flag set to indicate a task is running
|
||||
tenant_isolated_task_queue.set_task_waiting_time()
|
||||
rag_pipeline_run_task.delay( # type: ignore
|
||||
rag_pipeline_invoke_entities_file_id=next_file_id.decode("utf-8")
|
||||
if isinstance(next_file_id, bytes)
|
||||
else next_file_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
for batch in chunked(next_file_ids, 100):
|
||||
jobs = []
|
||||
for next_file_id in batch:
|
||||
tenant_isolated_task_queue.set_task_waiting_time()
|
||||
|
||||
file_id = (
|
||||
next_file_id.decode("utf-8") if isinstance(next_file_id, (bytes, bytearray)) else next_file_id
|
||||
)
|
||||
|
||||
jobs.append(
|
||||
rag_pipeline_run_task.s(
|
||||
rag_pipeline_invoke_entities_file_id=file_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
)
|
||||
|
||||
if jobs:
|
||||
group(jobs).apply_async()
|
||||
else:
|
||||
# No more waiting tasks, clear the flag
|
||||
tenant_isolated_task_queue.delete_task_key()
|
||||
|
||||
@ -0,0 +1,233 @@
|
||||
import datetime
|
||||
import json
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.model import (
|
||||
App,
|
||||
AppAnnotationHitHistory,
|
||||
Conversation,
|
||||
DatasetRetrieverResource,
|
||||
Message,
|
||||
MessageAgentThought,
|
||||
MessageAnnotation,
|
||||
MessageChain,
|
||||
MessageFeedback,
|
||||
MessageFile,
|
||||
)
|
||||
from models.web import SavedMessage
|
||||
from services.retention.conversation.message_export_service import AppMessageExportService, AppMessageExportStats
|
||||
|
||||
|
||||
class TestAppMessageExportServiceIntegration:
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_database(self, db_session_with_containers: Session):
|
||||
yield
|
||||
db_session_with_containers.query(DatasetRetrieverResource).delete()
|
||||
db_session_with_containers.query(AppAnnotationHitHistory).delete()
|
||||
db_session_with_containers.query(SavedMessage).delete()
|
||||
db_session_with_containers.query(MessageFile).delete()
|
||||
db_session_with_containers.query(MessageAgentThought).delete()
|
||||
db_session_with_containers.query(MessageChain).delete()
|
||||
db_session_with_containers.query(MessageAnnotation).delete()
|
||||
db_session_with_containers.query(MessageFeedback).delete()
|
||||
db_session_with_containers.query(Message).delete()
|
||||
db_session_with_containers.query(Conversation).delete()
|
||||
db_session_with_containers.query(App).delete()
|
||||
db_session_with_containers.query(TenantAccountJoin).delete()
|
||||
db_session_with_containers.query(Tenant).delete()
|
||||
db_session_with_containers.query(Account).delete()
|
||||
db_session_with_containers.commit()
|
||||
|
||||
@staticmethod
|
||||
def _create_app_context(session: Session) -> tuple[App, Conversation]:
|
||||
account = Account(
|
||||
email=f"test-{uuid.uuid4()}@example.com",
|
||||
name="tester",
|
||||
interface_language="en-US",
|
||||
status="active",
|
||||
)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
|
||||
tenant = Tenant(name=f"tenant-{uuid.uuid4()}", status="normal")
|
||||
session.add(tenant)
|
||||
session.flush()
|
||||
|
||||
join = TenantAccountJoin(
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
role=TenantAccountRole.OWNER,
|
||||
current=True,
|
||||
)
|
||||
session.add(join)
|
||||
session.flush()
|
||||
|
||||
app = App(
|
||||
tenant_id=tenant.id,
|
||||
name="export-app",
|
||||
description="integration test app",
|
||||
mode="chat",
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
api_rpm=60,
|
||||
api_rph=3600,
|
||||
is_demo=False,
|
||||
is_public=False,
|
||||
created_by=account.id,
|
||||
updated_by=account.id,
|
||||
)
|
||||
session.add(app)
|
||||
session.flush()
|
||||
|
||||
conversation = Conversation(
|
||||
app_id=app.id,
|
||||
app_model_config_id=str(uuid.uuid4()),
|
||||
model_provider="openai",
|
||||
model_id="gpt-4o-mini",
|
||||
mode="chat",
|
||||
name="conv",
|
||||
inputs={"seed": 1},
|
||||
status="normal",
|
||||
from_source="api",
|
||||
from_end_user_id=str(uuid.uuid4()),
|
||||
)
|
||||
session.add(conversation)
|
||||
session.commit()
|
||||
return app, conversation
|
||||
|
||||
@staticmethod
|
||||
def _create_message(
|
||||
session: Session,
|
||||
app: App,
|
||||
conversation: Conversation,
|
||||
created_at: datetime.datetime,
|
||||
*,
|
||||
query: str,
|
||||
answer: str,
|
||||
inputs: dict,
|
||||
message_metadata: str | None,
|
||||
) -> Message:
|
||||
message = Message(
|
||||
app_id=app.id,
|
||||
conversation_id=conversation.id,
|
||||
model_provider="openai",
|
||||
model_id="gpt-4o-mini",
|
||||
inputs=inputs,
|
||||
query=query,
|
||||
answer=answer,
|
||||
message=[{"role": "assistant", "content": answer}],
|
||||
message_tokens=10,
|
||||
message_unit_price=Decimal("0.001"),
|
||||
answer_tokens=20,
|
||||
answer_unit_price=Decimal("0.002"),
|
||||
total_price=Decimal("0.003"),
|
||||
currency="USD",
|
||||
message_metadata=message_metadata,
|
||||
from_source="api",
|
||||
from_end_user_id=conversation.from_end_user_id,
|
||||
created_at=created_at,
|
||||
)
|
||||
session.add(message)
|
||||
session.flush()
|
||||
return message
|
||||
|
||||
def test_iter_records_with_stats(self, db_session_with_containers: Session):
|
||||
app, conversation = self._create_app_context(db_session_with_containers)
|
||||
|
||||
first_inputs = {
|
||||
"plain": "v1",
|
||||
"nested": {"a": 1, "b": [1, {"x": True}]},
|
||||
"list": ["x", 2, {"y": "z"}],
|
||||
}
|
||||
second_inputs = {"other": "value", "items": [1, 2, 3]}
|
||||
|
||||
base_time = datetime.datetime(2026, 2, 25, 10, 0, 0)
|
||||
first_message = self._create_message(
|
||||
db_session_with_containers,
|
||||
app,
|
||||
conversation,
|
||||
created_at=base_time,
|
||||
query="q1",
|
||||
answer="a1",
|
||||
inputs=first_inputs,
|
||||
message_metadata=json.dumps({"retriever_resources": [{"dataset_id": "ds-1"}]}),
|
||||
)
|
||||
second_message = self._create_message(
|
||||
db_session_with_containers,
|
||||
app,
|
||||
conversation,
|
||||
created_at=base_time + datetime.timedelta(minutes=1),
|
||||
query="q2",
|
||||
answer="a2",
|
||||
inputs=second_inputs,
|
||||
message_metadata=None,
|
||||
)
|
||||
|
||||
user_feedback_1 = MessageFeedback(
|
||||
app_id=app.id,
|
||||
conversation_id=conversation.id,
|
||||
message_id=first_message.id,
|
||||
rating="like",
|
||||
from_source="user",
|
||||
content="first",
|
||||
from_end_user_id=conversation.from_end_user_id,
|
||||
)
|
||||
user_feedback_2 = MessageFeedback(
|
||||
app_id=app.id,
|
||||
conversation_id=conversation.id,
|
||||
message_id=first_message.id,
|
||||
rating="dislike",
|
||||
from_source="user",
|
||||
content="second",
|
||||
from_end_user_id=conversation.from_end_user_id,
|
||||
)
|
||||
admin_feedback = MessageFeedback(
|
||||
app_id=app.id,
|
||||
conversation_id=conversation.id,
|
||||
message_id=first_message.id,
|
||||
rating="like",
|
||||
from_source="admin",
|
||||
content="should-be-filtered",
|
||||
from_account_id=str(uuid.uuid4()),
|
||||
)
|
||||
db_session_with_containers.add_all([user_feedback_1, user_feedback_2, admin_feedback])
|
||||
user_feedback_1.created_at = base_time + datetime.timedelta(minutes=2)
|
||||
user_feedback_2.created_at = base_time + datetime.timedelta(minutes=3)
|
||||
admin_feedback.created_at = base_time + datetime.timedelta(minutes=4)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
service = AppMessageExportService(
|
||||
app_id=app.id,
|
||||
start_from=base_time - datetime.timedelta(minutes=1),
|
||||
end_before=base_time + datetime.timedelta(minutes=10),
|
||||
filename="unused",
|
||||
batch_size=1,
|
||||
dry_run=True,
|
||||
)
|
||||
stats = AppMessageExportStats()
|
||||
records = list(service._iter_records_with_stats(stats))
|
||||
service._finalize_stats(stats)
|
||||
|
||||
assert len(records) == 2
|
||||
assert records[0].message_id == first_message.id
|
||||
assert records[1].message_id == second_message.id
|
||||
|
||||
assert records[0].inputs == first_inputs
|
||||
assert records[1].inputs == second_inputs
|
||||
|
||||
assert records[0].retriever_resources == [{"dataset_id": "ds-1"}]
|
||||
assert records[1].retriever_resources == []
|
||||
|
||||
assert [feedback.rating for feedback in records[0].feedback] == ["like", "dislike"]
|
||||
assert [feedback.content for feedback in records[0].feedback] == ["first", "second"]
|
||||
assert records[1].feedback == []
|
||||
|
||||
assert stats.batches == 2
|
||||
assert stats.total_messages == 2
|
||||
assert stats.messages_with_feedback == 1
|
||||
assert stats.total_feedbacks == 2
|
||||
@ -322,11 +322,14 @@ class TestDatasetIndexingTaskIntegration:
|
||||
_document_indexing_with_tenant_queue(dataset.tenant_id, dataset.id, document_ids, task_dispatch_spy)
|
||||
|
||||
# Assert
|
||||
task_dispatch_spy.delay.assert_called_once_with(
|
||||
tenant_id=next_task["tenant_id"],
|
||||
dataset_id=next_task["dataset_id"],
|
||||
document_ids=next_task["document_ids"],
|
||||
)
|
||||
# apply_async is used by implementation; assert it was called once with expected kwargs
|
||||
assert task_dispatch_spy.apply_async.call_count == 1
|
||||
call_kwargs = task_dispatch_spy.apply_async.call_args.kwargs.get("kwargs", {})
|
||||
assert call_kwargs == {
|
||||
"tenant_id": next_task["tenant_id"],
|
||||
"dataset_id": next_task["dataset_id"],
|
||||
"document_ids": next_task["document_ids"],
|
||||
}
|
||||
set_waiting_spy.assert_called_once()
|
||||
delete_key_spy.assert_not_called()
|
||||
|
||||
@ -352,7 +355,7 @@ class TestDatasetIndexingTaskIntegration:
|
||||
_document_indexing_with_tenant_queue(dataset.tenant_id, dataset.id, document_ids, task_dispatch_spy)
|
||||
|
||||
# Assert
|
||||
task_dispatch_spy.delay.assert_not_called()
|
||||
task_dispatch_spy.apply_async.assert_not_called()
|
||||
delete_key_spy.assert_called_once()
|
||||
|
||||
def test_validation_failure_sets_error_status_when_vector_space_at_limit(
|
||||
@ -447,7 +450,7 @@ class TestDatasetIndexingTaskIntegration:
|
||||
_document_indexing_with_tenant_queue(dataset.tenant_id, dataset.id, document_ids, task_dispatch_spy)
|
||||
|
||||
# Assert
|
||||
task_dispatch_spy.delay.assert_called_once()
|
||||
task_dispatch_spy.apply_async.assert_called_once()
|
||||
|
||||
def test_sessions_close_on_successful_indexing(
|
||||
self,
|
||||
@ -534,7 +537,7 @@ class TestDatasetIndexingTaskIntegration:
|
||||
_document_indexing_with_tenant_queue(dataset.tenant_id, dataset.id, document_ids, task_dispatch_spy)
|
||||
|
||||
# Assert
|
||||
assert task_dispatch_spy.delay.call_count == concurrency_limit
|
||||
assert task_dispatch_spy.apply_async.call_count == concurrency_limit
|
||||
assert set_waiting_spy.call_count == concurrency_limit
|
||||
|
||||
def test_task_queue_fifo_ordering(self, db_session_with_containers, patched_external_dependencies):
|
||||
@ -565,9 +568,10 @@ class TestDatasetIndexingTaskIntegration:
|
||||
_document_indexing_with_tenant_queue(dataset.tenant_id, dataset.id, document_ids, task_dispatch_spy)
|
||||
|
||||
# Assert
|
||||
assert task_dispatch_spy.delay.call_count == 3
|
||||
assert task_dispatch_spy.apply_async.call_count == 3
|
||||
for index, expected_task in enumerate(ordered_tasks):
|
||||
assert task_dispatch_spy.delay.call_args_list[index].kwargs["document_ids"] == expected_task["document_ids"]
|
||||
call_kwargs = task_dispatch_spy.apply_async.call_args_list[index].kwargs.get("kwargs", {})
|
||||
assert call_kwargs.get("document_ids") == expected_task["document_ids"]
|
||||
|
||||
def test_billing_disabled_skips_limit_checks(self, db_session_with_containers, patched_external_dependencies):
|
||||
"""Skip limit checks when billing feature is disabled."""
|
||||
|
||||
@ -762,11 +762,12 @@ class TestDocumentIndexingTasks:
|
||||
mock_external_service_dependencies["indexing_runner_instance"].run.assert_called_once()
|
||||
|
||||
# Verify task function was called for each waiting task
|
||||
assert mock_task_func.delay.call_count == 1
|
||||
assert mock_task_func.apply_async.call_count == 1
|
||||
|
||||
# Verify correct parameters for each call
|
||||
calls = mock_task_func.delay.call_args_list
|
||||
assert calls[0][1] == {"tenant_id": tenant_id, "dataset_id": dataset_id, "document_ids": ["waiting-doc-1"]}
|
||||
calls = mock_task_func.apply_async.call_args_list
|
||||
sent_kwargs = calls[0][1]["kwargs"]
|
||||
assert sent_kwargs == {"tenant_id": tenant_id, "dataset_id": dataset_id, "document_ids": ["waiting-doc-1"]}
|
||||
|
||||
# Verify queue is empty after processing (tasks were pulled)
|
||||
remaining_tasks = queue.pull_tasks(count=10) # Pull more than we added
|
||||
@ -830,11 +831,15 @@ class TestDocumentIndexingTasks:
|
||||
assert updated_document.processing_started_at is not None
|
||||
|
||||
# Verify waiting task was still processed despite core processing error
|
||||
mock_task_func.delay.assert_called_once()
|
||||
mock_task_func.apply_async.assert_called_once()
|
||||
|
||||
# Verify correct parameters for the call
|
||||
call = mock_task_func.delay.call_args
|
||||
assert call[1] == {"tenant_id": tenant_id, "dataset_id": dataset_id, "document_ids": ["waiting-doc-1"]}
|
||||
call = mock_task_func.apply_async.call_args
|
||||
assert call[1]["kwargs"] == {
|
||||
"tenant_id": tenant_id,
|
||||
"dataset_id": dataset_id,
|
||||
"document_ids": ["waiting-doc-1"],
|
||||
}
|
||||
|
||||
# Verify queue is empty after processing (task was pulled)
|
||||
remaining_tasks = queue.pull_tasks(count=10)
|
||||
@ -896,9 +901,13 @@ class TestDocumentIndexingTasks:
|
||||
mock_external_service_dependencies["indexing_runner_instance"].run.assert_called_once()
|
||||
|
||||
# Verify only tenant1's waiting task was processed
|
||||
mock_task_func.delay.assert_called_once()
|
||||
call = mock_task_func.delay.call_args
|
||||
assert call[1] == {"tenant_id": tenant1_id, "dataset_id": dataset1_id, "document_ids": ["tenant1-doc-1"]}
|
||||
mock_task_func.apply_async.assert_called_once()
|
||||
call = mock_task_func.apply_async.call_args
|
||||
assert call[1]["kwargs"] == {
|
||||
"tenant_id": tenant1_id,
|
||||
"dataset_id": dataset1_id,
|
||||
"document_ids": ["tenant1-doc-1"],
|
||||
}
|
||||
|
||||
# Verify tenant1's queue is empty
|
||||
remaining_tasks1 = queue1.pull_tasks(count=10)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
@ -388,8 +388,10 @@ class TestRagPipelineRunTasks:
|
||||
# Set the task key to indicate there are waiting tasks (legacy behavior)
|
||||
redis_client.set(legacy_task_key, 1, ex=60 * 60)
|
||||
|
||||
# Mock the task function calls
|
||||
with patch("tasks.rag_pipeline.rag_pipeline_run_task.rag_pipeline_run_task.delay") as mock_delay:
|
||||
# Mock the Celery group scheduling used by the implementation
|
||||
with patch("tasks.rag_pipeline.rag_pipeline_run_task.group") as mock_group:
|
||||
mock_group.return_value.apply_async = MagicMock()
|
||||
|
||||
# Act: Execute the priority task with new code but legacy queue data
|
||||
rag_pipeline_run_task(file_id, tenant.id)
|
||||
|
||||
@ -398,13 +400,14 @@ class TestRagPipelineRunTasks:
|
||||
mock_file_service["delete_file"].assert_called_once_with(file_id)
|
||||
assert mock_pipeline_generator.call_count == 1
|
||||
|
||||
# Verify waiting tasks were processed, pull 1 task a time by default
|
||||
assert mock_delay.call_count == 1
|
||||
# Verify waiting tasks were processed via group, pull 1 task a time by default
|
||||
assert mock_group.return_value.apply_async.called
|
||||
|
||||
# Verify correct parameters for the call
|
||||
call_kwargs = mock_delay.call_args[1] if mock_delay.call_args else {}
|
||||
assert call_kwargs.get("rag_pipeline_invoke_entities_file_id") == legacy_file_ids[0]
|
||||
assert call_kwargs.get("tenant_id") == tenant.id
|
||||
# Verify correct parameters for the first scheduled job signature
|
||||
jobs = mock_group.call_args.args[0] if mock_group.call_args else []
|
||||
first_kwargs = jobs[0].kwargs if jobs else {}
|
||||
assert first_kwargs.get("rag_pipeline_invoke_entities_file_id") == legacy_file_ids[0]
|
||||
assert first_kwargs.get("tenant_id") == tenant.id
|
||||
|
||||
# Verify that new code can process legacy queue entries
|
||||
# The new TenantIsolatedTaskQueue should be able to read from the legacy format
|
||||
@ -446,8 +449,10 @@ class TestRagPipelineRunTasks:
|
||||
waiting_file_ids = [str(uuid.uuid4()) for _ in range(3)]
|
||||
queue.push_tasks(waiting_file_ids)
|
||||
|
||||
# Mock the task function calls
|
||||
with patch("tasks.rag_pipeline.rag_pipeline_run_task.rag_pipeline_run_task.delay") as mock_delay:
|
||||
# Mock the Celery group scheduling used by the implementation
|
||||
with patch("tasks.rag_pipeline.rag_pipeline_run_task.group") as mock_group:
|
||||
mock_group.return_value.apply_async = MagicMock()
|
||||
|
||||
# Act: Execute the regular task
|
||||
rag_pipeline_run_task(file_id, tenant.id)
|
||||
|
||||
@ -456,13 +461,14 @@ class TestRagPipelineRunTasks:
|
||||
mock_file_service["delete_file"].assert_called_once_with(file_id)
|
||||
assert mock_pipeline_generator.call_count == 1
|
||||
|
||||
# Verify waiting tasks were processed, pull 1 task a time by default
|
||||
assert mock_delay.call_count == 1
|
||||
# Verify waiting tasks were processed via group.apply_async
|
||||
assert mock_group.return_value.apply_async.called
|
||||
|
||||
# Verify correct parameters for the call
|
||||
call_kwargs = mock_delay.call_args[1] if mock_delay.call_args else {}
|
||||
assert call_kwargs.get("rag_pipeline_invoke_entities_file_id") == waiting_file_ids[0]
|
||||
assert call_kwargs.get("tenant_id") == tenant.id
|
||||
# Verify correct parameters for the first scheduled job signature
|
||||
jobs = mock_group.call_args.args[0] if mock_group.call_args else []
|
||||
first_kwargs = jobs[0].kwargs if jobs else {}
|
||||
assert first_kwargs.get("rag_pipeline_invoke_entities_file_id") == waiting_file_ids[0]
|
||||
assert first_kwargs.get("tenant_id") == tenant.id
|
||||
|
||||
# Verify queue still has remaining tasks (only 1 was pulled)
|
||||
remaining_tasks = queue.pull_tasks(count=10)
|
||||
@ -557,8 +563,10 @@ class TestRagPipelineRunTasks:
|
||||
waiting_file_id = str(uuid.uuid4())
|
||||
queue.push_tasks([waiting_file_id])
|
||||
|
||||
# Mock the task function calls
|
||||
with patch("tasks.rag_pipeline.rag_pipeline_run_task.rag_pipeline_run_task.delay") as mock_delay:
|
||||
# Mock the Celery group scheduling used by the implementation
|
||||
with patch("tasks.rag_pipeline.rag_pipeline_run_task.group") as mock_group:
|
||||
mock_group.return_value.apply_async = MagicMock()
|
||||
|
||||
# Act: Execute the regular task (should not raise exception)
|
||||
rag_pipeline_run_task(file_id, tenant.id)
|
||||
|
||||
@ -569,12 +577,13 @@ class TestRagPipelineRunTasks:
|
||||
assert mock_pipeline_generator.call_count == 1
|
||||
|
||||
# Verify waiting task was still processed despite core processing error
|
||||
mock_delay.assert_called_once()
|
||||
assert mock_group.return_value.apply_async.called
|
||||
|
||||
# Verify correct parameters for the call
|
||||
call_kwargs = mock_delay.call_args[1] if mock_delay.call_args else {}
|
||||
assert call_kwargs.get("rag_pipeline_invoke_entities_file_id") == waiting_file_id
|
||||
assert call_kwargs.get("tenant_id") == tenant.id
|
||||
# Verify correct parameters for the first scheduled job signature
|
||||
jobs = mock_group.call_args.args[0] if mock_group.call_args else []
|
||||
first_kwargs = jobs[0].kwargs if jobs else {}
|
||||
assert first_kwargs.get("rag_pipeline_invoke_entities_file_id") == waiting_file_id
|
||||
assert first_kwargs.get("tenant_id") == tenant.id
|
||||
|
||||
# Verify queue is empty after processing (task was pulled)
|
||||
remaining_tasks = queue.pull_tasks(count=10)
|
||||
@ -684,8 +693,10 @@ class TestRagPipelineRunTasks:
|
||||
queue1.push_tasks([waiting_file_id1])
|
||||
queue2.push_tasks([waiting_file_id2])
|
||||
|
||||
# Mock the task function calls
|
||||
with patch("tasks.rag_pipeline.rag_pipeline_run_task.rag_pipeline_run_task.delay") as mock_delay:
|
||||
# Mock the Celery group scheduling used by the implementation
|
||||
with patch("tasks.rag_pipeline.rag_pipeline_run_task.group") as mock_group:
|
||||
mock_group.return_value.apply_async = MagicMock()
|
||||
|
||||
# Act: Execute the regular task for tenant1 only
|
||||
rag_pipeline_run_task(file_id1, tenant1.id)
|
||||
|
||||
@ -694,11 +705,12 @@ class TestRagPipelineRunTasks:
|
||||
assert mock_file_service["delete_file"].call_count == 1
|
||||
assert mock_pipeline_generator.call_count == 1
|
||||
|
||||
# Verify only tenant1's waiting task was processed
|
||||
mock_delay.assert_called_once()
|
||||
call_kwargs = mock_delay.call_args[1] if mock_delay.call_args else {}
|
||||
assert call_kwargs.get("rag_pipeline_invoke_entities_file_id") == waiting_file_id1
|
||||
assert call_kwargs.get("tenant_id") == tenant1.id
|
||||
# Verify only tenant1's waiting task was processed (via group)
|
||||
assert mock_group.return_value.apply_async.called
|
||||
jobs = mock_group.call_args.args[0] if mock_group.call_args else []
|
||||
first_kwargs = jobs[0].kwargs if jobs else {}
|
||||
assert first_kwargs.get("rag_pipeline_invoke_entities_file_id") == waiting_file_id1
|
||||
assert first_kwargs.get("tenant_id") == tenant1.id
|
||||
|
||||
# Verify tenant1's queue is empty
|
||||
remaining_tasks1 = queue1.pull_tasks(count=10)
|
||||
@ -913,8 +925,10 @@ class TestRagPipelineRunTasks:
|
||||
waiting_file_id = str(uuid.uuid4())
|
||||
queue.push_tasks([waiting_file_id])
|
||||
|
||||
# Mock the task function calls
|
||||
with patch("tasks.rag_pipeline.rag_pipeline_run_task.rag_pipeline_run_task.delay") as mock_delay:
|
||||
# Mock the Celery group scheduling used by the implementation
|
||||
with patch("tasks.rag_pipeline.rag_pipeline_run_task.group") as mock_group:
|
||||
mock_group.return_value.apply_async = MagicMock()
|
||||
|
||||
# Act & Assert: Execute the regular task (should raise Exception)
|
||||
with pytest.raises(Exception, match="File not found"):
|
||||
rag_pipeline_run_task(file_id, tenant.id)
|
||||
@ -924,12 +938,13 @@ class TestRagPipelineRunTasks:
|
||||
mock_pipeline_generator.assert_not_called()
|
||||
|
||||
# Verify waiting task was still processed despite file error
|
||||
mock_delay.assert_called_once()
|
||||
assert mock_group.return_value.apply_async.called
|
||||
|
||||
# Verify correct parameters for the call
|
||||
call_kwargs = mock_delay.call_args[1] if mock_delay.call_args else {}
|
||||
assert call_kwargs.get("rag_pipeline_invoke_entities_file_id") == waiting_file_id
|
||||
assert call_kwargs.get("tenant_id") == tenant.id
|
||||
# Verify correct parameters for the first scheduled job signature
|
||||
jobs = mock_group.call_args.args[0] if mock_group.call_args else []
|
||||
first_kwargs = jobs[0].kwargs if jobs else {}
|
||||
assert first_kwargs.get("rag_pipeline_invoke_entities_file_id") == waiting_file_id
|
||||
assert first_kwargs.get("tenant_id") == tenant.id
|
||||
|
||||
# Verify queue is empty after processing (task was pulled)
|
||||
remaining_tasks = queue.pull_tasks(count=10)
|
||||
|
||||
@ -105,18 +105,26 @@ def app_model(
|
||||
|
||||
|
||||
class MockCeleryGroup:
|
||||
"""Mock for celery group() function that collects dispatched tasks."""
|
||||
"""Mock for celery group() function that collects dispatched tasks.
|
||||
|
||||
Matches the Celery group API loosely, accepting arbitrary kwargs on apply_async
|
||||
(e.g. producer) so production code can pass broker-related options without
|
||||
breaking tests.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.collected: list[dict[str, Any]] = []
|
||||
self._applied = False
|
||||
self.last_apply_async_kwargs: dict[str, Any] | None = None
|
||||
|
||||
def __call__(self, items: Any) -> MockCeleryGroup:
|
||||
self.collected = list(items)
|
||||
return self
|
||||
|
||||
def apply_async(self) -> None:
|
||||
def apply_async(self, **kwargs: Any) -> None:
|
||||
# Accept arbitrary kwargs like producer to be compatible with Celery
|
||||
self._applied = True
|
||||
self.last_apply_async_kwargs = kwargs
|
||||
|
||||
@property
|
||||
def applied(self) -> bool:
|
||||
|
||||
181
api/tests/unit_tests/commands/test_clean_expired_messages.py
Normal file
181
api/tests/unit_tests/commands/test_clean_expired_messages.py
Normal file
@ -0,0 +1,181 @@
|
||||
import datetime
|
||||
import re
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
from commands import clean_expired_messages
|
||||
|
||||
|
||||
def _mock_service() -> MagicMock:
|
||||
service = MagicMock()
|
||||
service.run.return_value = {
|
||||
"batches": 1,
|
||||
"total_messages": 10,
|
||||
"filtered_messages": 5,
|
||||
"total_deleted": 5,
|
||||
}
|
||||
return service
|
||||
|
||||
|
||||
def test_absolute_mode_calls_from_time_range():
|
||||
policy = object()
|
||||
service = _mock_service()
|
||||
start_from = datetime.datetime(2024, 1, 1, 0, 0, 0)
|
||||
end_before = datetime.datetime(2024, 2, 1, 0, 0, 0)
|
||||
|
||||
with (
|
||||
patch("commands.create_message_clean_policy", return_value=policy),
|
||||
patch("commands.MessagesCleanService.from_time_range", return_value=service) as mock_from_time_range,
|
||||
patch("commands.MessagesCleanService.from_days") as mock_from_days,
|
||||
):
|
||||
clean_expired_messages.callback(
|
||||
batch_size=200,
|
||||
graceful_period=21,
|
||||
start_from=start_from,
|
||||
end_before=end_before,
|
||||
from_days_ago=None,
|
||||
before_days=None,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
mock_from_time_range.assert_called_once_with(
|
||||
policy=policy,
|
||||
start_from=start_from,
|
||||
end_before=end_before,
|
||||
batch_size=200,
|
||||
dry_run=True,
|
||||
)
|
||||
mock_from_days.assert_not_called()
|
||||
|
||||
|
||||
def test_relative_mode_before_days_only_calls_from_days():
|
||||
policy = object()
|
||||
service = _mock_service()
|
||||
|
||||
with (
|
||||
patch("commands.create_message_clean_policy", return_value=policy),
|
||||
patch("commands.MessagesCleanService.from_days", return_value=service) as mock_from_days,
|
||||
patch("commands.MessagesCleanService.from_time_range") as mock_from_time_range,
|
||||
):
|
||||
clean_expired_messages.callback(
|
||||
batch_size=500,
|
||||
graceful_period=14,
|
||||
start_from=None,
|
||||
end_before=None,
|
||||
from_days_ago=None,
|
||||
before_days=30,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
mock_from_days.assert_called_once_with(
|
||||
policy=policy,
|
||||
days=30,
|
||||
batch_size=500,
|
||||
dry_run=False,
|
||||
)
|
||||
mock_from_time_range.assert_not_called()
|
||||
|
||||
|
||||
def test_relative_mode_with_from_days_ago_calls_from_time_range():
|
||||
policy = object()
|
||||
service = _mock_service()
|
||||
fixed_now = datetime.datetime(2024, 8, 20, 12, 0, 0)
|
||||
|
||||
with (
|
||||
patch("commands.create_message_clean_policy", return_value=policy),
|
||||
patch("commands.MessagesCleanService.from_time_range", return_value=service) as mock_from_time_range,
|
||||
patch("commands.MessagesCleanService.from_days") as mock_from_days,
|
||||
patch("commands.naive_utc_now", return_value=fixed_now),
|
||||
):
|
||||
clean_expired_messages.callback(
|
||||
batch_size=1000,
|
||||
graceful_period=21,
|
||||
start_from=None,
|
||||
end_before=None,
|
||||
from_days_ago=60,
|
||||
before_days=30,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
mock_from_time_range.assert_called_once_with(
|
||||
policy=policy,
|
||||
start_from=fixed_now - datetime.timedelta(days=60),
|
||||
end_before=fixed_now - datetime.timedelta(days=30),
|
||||
batch_size=1000,
|
||||
dry_run=False,
|
||||
)
|
||||
mock_from_days.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "message"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"start_from": datetime.datetime(2024, 1, 1),
|
||||
"end_before": datetime.datetime(2024, 2, 1),
|
||||
"from_days_ago": None,
|
||||
"before_days": 30,
|
||||
},
|
||||
"mutually exclusive",
|
||||
),
|
||||
(
|
||||
{
|
||||
"start_from": datetime.datetime(2024, 1, 1),
|
||||
"end_before": None,
|
||||
"from_days_ago": None,
|
||||
"before_days": None,
|
||||
},
|
||||
"Both --start-from and --end-before are required",
|
||||
),
|
||||
(
|
||||
{
|
||||
"start_from": None,
|
||||
"end_before": None,
|
||||
"from_days_ago": 10,
|
||||
"before_days": None,
|
||||
},
|
||||
"--from-days-ago must be used together with --before-days",
|
||||
),
|
||||
(
|
||||
{
|
||||
"start_from": None,
|
||||
"end_before": None,
|
||||
"from_days_ago": None,
|
||||
"before_days": -1,
|
||||
},
|
||||
"--before-days must be >= 0",
|
||||
),
|
||||
(
|
||||
{
|
||||
"start_from": None,
|
||||
"end_before": None,
|
||||
"from_days_ago": 30,
|
||||
"before_days": 30,
|
||||
},
|
||||
"--from-days-ago must be greater than --before-days",
|
||||
),
|
||||
(
|
||||
{
|
||||
"start_from": None,
|
||||
"end_before": None,
|
||||
"from_days_ago": None,
|
||||
"before_days": None,
|
||||
},
|
||||
"You must provide either (--start-from,--end-before) or (--before-days [--from-days-ago])",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_invalid_inputs_raise_usage_error(kwargs: dict, message: str):
|
||||
with pytest.raises(click.UsageError, match=re.escape(message)):
|
||||
clean_expired_messages.callback(
|
||||
batch_size=1000,
|
||||
graceful_period=21,
|
||||
start_from=kwargs["start_from"],
|
||||
end_before=kwargs["end_before"],
|
||||
from_days_ago=kwargs["from_days_ago"],
|
||||
before_days=kwargs["before_days"],
|
||||
dry_run=False,
|
||||
)
|
||||
70
api/tests/unit_tests/controllers/common/test_errors.py
Normal file
70
api/tests/unit_tests/controllers/common/test_errors.py
Normal file
@ -0,0 +1,70 @@
|
||||
from controllers.common.errors import (
|
||||
BlockedFileExtensionError,
|
||||
FilenameNotExistsError,
|
||||
FileTooLargeError,
|
||||
NoFileUploadedError,
|
||||
RemoteFileUploadError,
|
||||
TooManyFilesError,
|
||||
UnsupportedFileTypeError,
|
||||
)
|
||||
|
||||
|
||||
class TestFilenameNotExistsError:
|
||||
def test_defaults(self):
|
||||
error = FilenameNotExistsError()
|
||||
|
||||
assert error.code == 400
|
||||
assert error.description == "The specified filename does not exist."
|
||||
|
||||
|
||||
class TestRemoteFileUploadError:
|
||||
def test_defaults(self):
|
||||
error = RemoteFileUploadError()
|
||||
|
||||
assert error.code == 400
|
||||
assert error.description == "Error uploading remote file."
|
||||
|
||||
|
||||
class TestFileTooLargeError:
|
||||
def test_defaults(self):
|
||||
error = FileTooLargeError()
|
||||
|
||||
assert error.code == 413
|
||||
assert error.error_code == "file_too_large"
|
||||
assert error.description == "File size exceeded. {message}"
|
||||
|
||||
|
||||
class TestUnsupportedFileTypeError:
|
||||
def test_defaults(self):
|
||||
error = UnsupportedFileTypeError()
|
||||
|
||||
assert error.code == 415
|
||||
assert error.error_code == "unsupported_file_type"
|
||||
assert error.description == "File type not allowed."
|
||||
|
||||
|
||||
class TestBlockedFileExtensionError:
|
||||
def test_defaults(self):
|
||||
error = BlockedFileExtensionError()
|
||||
|
||||
assert error.code == 400
|
||||
assert error.error_code == "file_extension_blocked"
|
||||
assert error.description == "The file extension is blocked for security reasons."
|
||||
|
||||
|
||||
class TestTooManyFilesError:
|
||||
def test_defaults(self):
|
||||
error = TooManyFilesError()
|
||||
|
||||
assert error.code == 400
|
||||
assert error.error_code == "too_many_files"
|
||||
assert error.description == "Only one file is allowed."
|
||||
|
||||
|
||||
class TestNoFileUploadedError:
|
||||
def test_defaults(self):
|
||||
error = NoFileUploadedError()
|
||||
|
||||
assert error.code == 400
|
||||
assert error.error_code == "no_file_uploaded"
|
||||
assert error.description == "Please upload your file."
|
||||
@ -1,22 +1,95 @@
|
||||
from flask import Response
|
||||
|
||||
from controllers.common.file_response import enforce_download_for_html, is_html_content
|
||||
from controllers.common.file_response import (
|
||||
_normalize_mime_type,
|
||||
enforce_download_for_html,
|
||||
is_html_content,
|
||||
)
|
||||
|
||||
|
||||
class TestFileResponseHelpers:
|
||||
def test_is_html_content_detects_mime_type(self):
|
||||
class TestNormalizeMimeType:
|
||||
def test_returns_empty_string_for_none(self):
|
||||
assert _normalize_mime_type(None) == ""
|
||||
|
||||
def test_returns_empty_string_for_empty_string(self):
|
||||
assert _normalize_mime_type("") == ""
|
||||
|
||||
def test_normalizes_mime_type(self):
|
||||
assert _normalize_mime_type("Text/HTML; Charset=UTF-8") == "text/html"
|
||||
|
||||
|
||||
class TestIsHtmlContent:
|
||||
def test_detects_html_via_mime_type(self):
|
||||
mime_type = "text/html; charset=UTF-8"
|
||||
|
||||
result = is_html_content(mime_type, filename="file.txt", extension="txt")
|
||||
result = is_html_content(
|
||||
mime_type=mime_type,
|
||||
filename="file.txt",
|
||||
extension="txt",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_is_html_content_detects_extension(self):
|
||||
result = is_html_content("text/plain", filename="report.html", extension=None)
|
||||
def test_detects_html_via_extension_argument(self):
|
||||
result = is_html_content(
|
||||
mime_type="text/plain",
|
||||
filename=None,
|
||||
extension="html",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_enforce_download_for_html_sets_headers(self):
|
||||
def test_detects_html_via_filename_extension(self):
|
||||
result = is_html_content(
|
||||
mime_type="text/plain",
|
||||
filename="report.html",
|
||||
extension=None,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_returns_false_when_no_html_detected_anywhere(self):
|
||||
"""
|
||||
Missing negative test:
|
||||
- MIME type is not HTML
|
||||
- filename has no HTML extension
|
||||
- extension argument is not HTML
|
||||
"""
|
||||
result = is_html_content(
|
||||
mime_type="application/json",
|
||||
filename="data.json",
|
||||
extension="json",
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_returns_false_when_all_inputs_are_none(self):
|
||||
result = is_html_content(
|
||||
mime_type=None,
|
||||
filename=None,
|
||||
extension=None,
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestEnforceDownloadForHtml:
|
||||
def test_sets_attachment_when_filename_missing(self):
|
||||
response = Response("payload", mimetype="text/html")
|
||||
|
||||
updated = enforce_download_for_html(
|
||||
response,
|
||||
mime_type="text/html",
|
||||
filename=None,
|
||||
extension="html",
|
||||
)
|
||||
|
||||
assert updated is True
|
||||
assert response.headers["Content-Disposition"] == "attachment"
|
||||
assert response.headers["Content-Type"] == "application/octet-stream"
|
||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||
|
||||
def test_sets_headers_when_filename_present(self):
|
||||
response = Response("payload", mimetype="text/html")
|
||||
|
||||
updated = enforce_download_for_html(
|
||||
@ -27,11 +100,12 @@ class TestFileResponseHelpers:
|
||||
)
|
||||
|
||||
assert updated is True
|
||||
assert "attachment" in response.headers["Content-Disposition"]
|
||||
assert response.headers["Content-Disposition"].startswith("attachment")
|
||||
assert "unsafe.html" in response.headers["Content-Disposition"]
|
||||
assert response.headers["Content-Type"] == "application/octet-stream"
|
||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||
|
||||
def test_enforce_download_for_html_no_change_for_non_html(self):
|
||||
def test_does_not_modify_response_for_non_html_content(self):
|
||||
response = Response("payload", mimetype="text/plain")
|
||||
|
||||
updated = enforce_download_for_html(
|
||||
|
||||
188
api/tests/unit_tests/controllers/common/test_helpers.py
Normal file
188
api/tests/unit_tests/controllers/common/test_helpers.py
Normal file
@ -0,0 +1,188 @@
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from controllers.common import helpers
|
||||
from controllers.common.helpers import FileInfo, guess_file_info_from_response
|
||||
|
||||
|
||||
def make_response(
|
||||
url="https://example.com/file.txt",
|
||||
headers=None,
|
||||
content=None,
|
||||
):
|
||||
return httpx.Response(
|
||||
200,
|
||||
request=httpx.Request("GET", url),
|
||||
headers=headers or {},
|
||||
content=content or b"",
|
||||
)
|
||||
|
||||
|
||||
class TestGuessFileInfoFromResponse:
|
||||
def test_filename_from_url(self):
|
||||
response = make_response(
|
||||
url="https://example.com/test.pdf",
|
||||
content=b"Hello World",
|
||||
)
|
||||
|
||||
info = guess_file_info_from_response(response)
|
||||
|
||||
assert info.filename == "test.pdf"
|
||||
assert info.extension == ".pdf"
|
||||
assert info.mimetype == "application/pdf"
|
||||
|
||||
def test_filename_from_content_disposition(self):
|
||||
headers = {
|
||||
"Content-Disposition": "attachment; filename=myfile.csv",
|
||||
"Content-Type": "text/csv",
|
||||
}
|
||||
response = make_response(
|
||||
url="https://example.com/",
|
||||
headers=headers,
|
||||
content=b"Hello World",
|
||||
)
|
||||
|
||||
info = guess_file_info_from_response(response)
|
||||
|
||||
assert info.filename == "myfile.csv"
|
||||
assert info.extension == ".csv"
|
||||
assert info.mimetype == "text/csv"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("magic_available", "expected_ext"),
|
||||
[
|
||||
(True, "txt"),
|
||||
(False, "bin"),
|
||||
],
|
||||
)
|
||||
def test_generated_filename_when_missing(self, monkeypatch, magic_available, expected_ext):
|
||||
if magic_available:
|
||||
if helpers.magic is None:
|
||||
pytest.skip("python-magic is not installed, cannot run 'magic_available=True' test variant")
|
||||
else:
|
||||
monkeypatch.setattr(helpers, "magic", None)
|
||||
|
||||
response = make_response(
|
||||
url="https://example.com/",
|
||||
content=b"Hello World",
|
||||
)
|
||||
|
||||
info = guess_file_info_from_response(response)
|
||||
|
||||
name, ext = info.filename.split(".")
|
||||
UUID(name)
|
||||
assert ext == expected_ext
|
||||
|
||||
def test_mimetype_from_header_when_unknown(self):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
response = make_response(
|
||||
url="https://example.com/file.unknown",
|
||||
headers=headers,
|
||||
content=b'{"a": 1}',
|
||||
)
|
||||
|
||||
info = guess_file_info_from_response(response)
|
||||
|
||||
assert info.mimetype == "application/json"
|
||||
|
||||
def test_extension_added_when_missing(self):
|
||||
headers = {"Content-Type": "image/png"}
|
||||
response = make_response(
|
||||
url="https://example.com/image",
|
||||
headers=headers,
|
||||
content=b"fakepngdata",
|
||||
)
|
||||
|
||||
info = guess_file_info_from_response(response)
|
||||
|
||||
assert info.extension == ".png"
|
||||
assert info.filename.endswith(".png")
|
||||
|
||||
def test_content_length_used_as_size(self):
|
||||
headers = {
|
||||
"Content-Length": "1234",
|
||||
"Content-Type": "text/plain",
|
||||
}
|
||||
response = make_response(
|
||||
url="https://example.com/a.txt",
|
||||
headers=headers,
|
||||
content=b"a" * 1234,
|
||||
)
|
||||
|
||||
info = guess_file_info_from_response(response)
|
||||
|
||||
assert info.size == 1234
|
||||
|
||||
def test_size_minus_one_when_header_missing(self):
|
||||
response = make_response(url="https://example.com/a.txt")
|
||||
|
||||
info = guess_file_info_from_response(response)
|
||||
|
||||
assert info.size == -1
|
||||
|
||||
def test_fallback_to_bin_extension(self):
|
||||
headers = {"Content-Type": "application/octet-stream"}
|
||||
response = make_response(
|
||||
url="https://example.com/download",
|
||||
headers=headers,
|
||||
content=b"\x00\x01\x02\x03",
|
||||
)
|
||||
|
||||
info = guess_file_info_from_response(response)
|
||||
|
||||
assert info.extension == ".bin"
|
||||
assert info.filename.endswith(".bin")
|
||||
|
||||
def test_return_type(self):
|
||||
response = make_response()
|
||||
|
||||
info = guess_file_info_from_response(response)
|
||||
|
||||
assert isinstance(info, FileInfo)
|
||||
|
||||
|
||||
class TestMagicImportWarnings:
|
||||
@pytest.mark.parametrize(
|
||||
("platform_name", "expected_message"),
|
||||
[
|
||||
("Windows", "pip install python-magic-bin"),
|
||||
("Darwin", "brew install libmagic"),
|
||||
("Linux", "sudo apt-get install libmagic1"),
|
||||
("Other", "install `libmagic`"),
|
||||
],
|
||||
)
|
||||
def test_magic_import_warning_per_platform(
|
||||
self,
|
||||
monkeypatch,
|
||||
platform_name,
|
||||
expected_message,
|
||||
):
|
||||
import builtins
|
||||
import importlib
|
||||
|
||||
# Force ImportError when "magic" is imported
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "magic":
|
||||
raise ImportError("No module named magic")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
monkeypatch.setattr("platform.system", lambda: platform_name)
|
||||
|
||||
# Remove helpers so it imports fresh
|
||||
import sys
|
||||
|
||||
original_helpers = sys.modules.get(helpers.__name__)
|
||||
sys.modules.pop(helpers.__name__, None)
|
||||
|
||||
try:
|
||||
with pytest.warns(UserWarning, match="To use python-magic") as warning:
|
||||
imported_helpers = importlib.import_module(helpers.__name__)
|
||||
assert expected_message in str(warning[0].message)
|
||||
finally:
|
||||
if original_helpers is not None:
|
||||
sys.modules[helpers.__name__] = original_helpers
|
||||
189
api/tests/unit_tests/controllers/common/test_schema.py
Normal file
189
api/tests/unit_tests/controllers/common/test_schema.py
Normal file
@ -0,0 +1,189 @@
|
||||
import sys
|
||||
from enum import StrEnum
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask_restx import Namespace
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UserModel(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class ProductModel(BaseModel):
|
||||
id: int
|
||||
price: float
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_console_ns():
|
||||
"""Mock the console_ns to avoid circular imports during test collection."""
|
||||
mock_ns = MagicMock(spec=Namespace)
|
||||
mock_ns.models = {}
|
||||
|
||||
# Inject mock before importing schema module
|
||||
with patch.dict(sys.modules, {"controllers.console": MagicMock(console_ns=mock_ns)}):
|
||||
yield mock_ns
|
||||
|
||||
|
||||
def test_default_ref_template_value():
|
||||
from controllers.common.schema import DEFAULT_REF_TEMPLATE_SWAGGER_2_0
|
||||
|
||||
assert DEFAULT_REF_TEMPLATE_SWAGGER_2_0 == "#/definitions/{model}"
|
||||
|
||||
|
||||
def test_register_schema_model_calls_namespace_schema_model():
|
||||
from controllers.common.schema import register_schema_model
|
||||
|
||||
namespace = MagicMock(spec=Namespace)
|
||||
|
||||
register_schema_model(namespace, UserModel)
|
||||
|
||||
namespace.schema_model.assert_called_once()
|
||||
|
||||
model_name, schema = namespace.schema_model.call_args.args
|
||||
|
||||
assert model_name == "UserModel"
|
||||
assert isinstance(schema, dict)
|
||||
assert "properties" in schema
|
||||
|
||||
|
||||
def test_register_schema_model_passes_schema_from_pydantic():
|
||||
from controllers.common.schema import DEFAULT_REF_TEMPLATE_SWAGGER_2_0, register_schema_model
|
||||
|
||||
namespace = MagicMock(spec=Namespace)
|
||||
|
||||
register_schema_model(namespace, UserModel)
|
||||
|
||||
schema = namespace.schema_model.call_args.args[1]
|
||||
|
||||
expected_schema = UserModel.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
|
||||
|
||||
assert schema == expected_schema
|
||||
|
||||
|
||||
def test_register_schema_models_registers_multiple_models():
|
||||
from controllers.common.schema import register_schema_models
|
||||
|
||||
namespace = MagicMock(spec=Namespace)
|
||||
|
||||
register_schema_models(namespace, UserModel, ProductModel)
|
||||
|
||||
assert namespace.schema_model.call_count == 2
|
||||
|
||||
called_names = [call.args[0] for call in namespace.schema_model.call_args_list]
|
||||
assert called_names == ["UserModel", "ProductModel"]
|
||||
|
||||
|
||||
def test_register_schema_models_calls_register_schema_model(monkeypatch):
|
||||
from controllers.common.schema import register_schema_models
|
||||
|
||||
namespace = MagicMock(spec=Namespace)
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_register(ns, model):
|
||||
calls.append((ns, model))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"controllers.common.schema.register_schema_model",
|
||||
fake_register,
|
||||
)
|
||||
|
||||
register_schema_models(namespace, UserModel, ProductModel)
|
||||
|
||||
assert calls == [
|
||||
(namespace, UserModel),
|
||||
(namespace, ProductModel),
|
||||
]
|
||||
|
||||
|
||||
class StatusEnum(StrEnum):
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
|
||||
|
||||
class PriorityEnum(StrEnum):
|
||||
HIGH = "high"
|
||||
LOW = "low"
|
||||
|
||||
|
||||
def test_get_or_create_model_returns_existing_model(mock_console_ns):
|
||||
from controllers.common.schema import get_or_create_model
|
||||
|
||||
existing_model = MagicMock()
|
||||
mock_console_ns.models = {"TestModel": existing_model}
|
||||
|
||||
result = get_or_create_model("TestModel", {"key": "value"})
|
||||
|
||||
assert result == existing_model
|
||||
mock_console_ns.model.assert_not_called()
|
||||
|
||||
|
||||
def test_get_or_create_model_creates_new_model_when_not_exists(mock_console_ns):
|
||||
from controllers.common.schema import get_or_create_model
|
||||
|
||||
mock_console_ns.models = {}
|
||||
new_model = MagicMock()
|
||||
mock_console_ns.model.return_value = new_model
|
||||
field_def = {"name": {"type": "string"}}
|
||||
|
||||
result = get_or_create_model("NewModel", field_def)
|
||||
|
||||
assert result == new_model
|
||||
mock_console_ns.model.assert_called_once_with("NewModel", field_def)
|
||||
|
||||
|
||||
def test_get_or_create_model_does_not_call_model_if_exists(mock_console_ns):
|
||||
from controllers.common.schema import get_or_create_model
|
||||
|
||||
existing_model = MagicMock()
|
||||
mock_console_ns.models = {"ExistingModel": existing_model}
|
||||
|
||||
result = get_or_create_model("ExistingModel", {"key": "value"})
|
||||
|
||||
assert result == existing_model
|
||||
mock_console_ns.model.assert_not_called()
|
||||
|
||||
|
||||
def test_register_enum_models_registers_single_enum():
|
||||
from controllers.common.schema import register_enum_models
|
||||
|
||||
namespace = MagicMock(spec=Namespace)
|
||||
|
||||
register_enum_models(namespace, StatusEnum)
|
||||
|
||||
namespace.schema_model.assert_called_once()
|
||||
|
||||
model_name, schema = namespace.schema_model.call_args.args
|
||||
|
||||
assert model_name == "StatusEnum"
|
||||
assert isinstance(schema, dict)
|
||||
|
||||
|
||||
def test_register_enum_models_registers_multiple_enums():
|
||||
from controllers.common.schema import register_enum_models
|
||||
|
||||
namespace = MagicMock(spec=Namespace)
|
||||
|
||||
register_enum_models(namespace, StatusEnum, PriorityEnum)
|
||||
|
||||
assert namespace.schema_model.call_count == 2
|
||||
|
||||
called_names = [call.args[0] for call in namespace.schema_model.call_args_list]
|
||||
assert called_names == ["StatusEnum", "PriorityEnum"]
|
||||
|
||||
|
||||
def test_register_enum_models_uses_correct_ref_template():
|
||||
from controllers.common.schema import register_enum_models
|
||||
|
||||
namespace = MagicMock(spec=Namespace)
|
||||
|
||||
register_enum_models(namespace, StatusEnum)
|
||||
|
||||
schema = namespace.schema_model.call_args.args[1]
|
||||
|
||||
# Verify the schema contains enum values
|
||||
assert "enum" in schema or "anyOf" in schema
|
||||
0
api/tests/unit_tests/controllers/web/__init__.py
Normal file
0
api/tests/unit_tests/controllers/web/__init__.py
Normal file
85
api/tests/unit_tests/controllers/web/conftest.py
Normal file
85
api/tests/unit_tests/controllers/web/conftest.py
Normal file
@ -0,0 +1,85 @@
|
||||
"""Shared fixtures for controllers.web unit tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app() -> Flask:
|
||||
"""Minimal Flask app for request contexts."""
|
||||
flask_app = Flask(__name__)
|
||||
flask_app.config["TESTING"] = True
|
||||
return flask_app
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""Stand-in for db.session that returns pre-seeded objects by model class name."""
|
||||
|
||||
def __init__(self, mapping: dict[str, Any] | None = None):
|
||||
self._mapping: dict[str, Any] = mapping or {}
|
||||
self._model_name: str | None = None
|
||||
|
||||
def query(self, model: type) -> FakeSession:
|
||||
self._model_name = model.__name__
|
||||
return self
|
||||
|
||||
def where(self, *_args: object, **_kwargs: object) -> FakeSession:
|
||||
return self
|
||||
|
||||
def first(self) -> Any:
|
||||
assert self._model_name is not None
|
||||
return self._mapping.get(self._model_name)
|
||||
|
||||
|
||||
class FakeDB:
|
||||
"""Minimal db stub exposing engine and session."""
|
||||
|
||||
def __init__(self, session: FakeSession | None = None):
|
||||
self.session = session or FakeSession()
|
||||
self.engine = object()
|
||||
|
||||
|
||||
def make_app_model(
|
||||
*,
|
||||
app_id: str = "app-1",
|
||||
tenant_id: str = "tenant-1",
|
||||
mode: str = "chat",
|
||||
enable_site: bool = True,
|
||||
status: str = "normal",
|
||||
) -> SimpleNamespace:
|
||||
"""Build a fake App model with common defaults."""
|
||||
tenant = SimpleNamespace(
|
||||
id=tenant_id,
|
||||
status="normal",
|
||||
plan="basic",
|
||||
custom_config_dict={},
|
||||
)
|
||||
return SimpleNamespace(
|
||||
id=app_id,
|
||||
tenant_id=tenant_id,
|
||||
tenant=tenant,
|
||||
mode=mode,
|
||||
enable_site=enable_site,
|
||||
status=status,
|
||||
workflow=None,
|
||||
app_model_config=None,
|
||||
)
|
||||
|
||||
|
||||
def make_end_user(
|
||||
*,
|
||||
user_id: str = "end-user-1",
|
||||
session_id: str = "session-1",
|
||||
external_user_id: str = "ext-user-1",
|
||||
) -> SimpleNamespace:
|
||||
"""Build a fake EndUser model with common defaults."""
|
||||
return SimpleNamespace(
|
||||
id=user_id,
|
||||
session_id=session_id,
|
||||
external_user_id=external_user_id,
|
||||
)
|
||||
165
api/tests/unit_tests/controllers/web/test_app.py
Normal file
165
api/tests/unit_tests/controllers/web/test_app.py
Normal file
@ -0,0 +1,165 @@
|
||||
"""Unit tests for controllers.web.app endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.web.app import AppAccessMode, AppMeta, AppParameterApi, AppWebAuthPermission
|
||||
from controllers.web.error import AppUnavailableError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AppParameterApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAppParameterApi:
|
||||
def test_advanced_chat_mode_uses_workflow(self, app: Flask) -> None:
|
||||
features_dict = {"opening_statement": "Hello"}
|
||||
workflow = SimpleNamespace(
|
||||
features_dict=features_dict,
|
||||
user_input_form=lambda to_old_structure=False: [],
|
||||
)
|
||||
app_model = SimpleNamespace(mode="advanced-chat", workflow=workflow)
|
||||
|
||||
with (
|
||||
app.test_request_context("/parameters"),
|
||||
patch("controllers.web.app.get_parameters_from_feature_dict", return_value={}) as mock_params,
|
||||
patch("controllers.web.app.fields.Parameters") as mock_fields,
|
||||
):
|
||||
mock_fields.model_validate.return_value.model_dump.return_value = {"result": "ok"}
|
||||
result = AppParameterApi().get(app_model, SimpleNamespace())
|
||||
|
||||
mock_params.assert_called_once_with(features_dict=features_dict, user_input_form=[])
|
||||
assert result == {"result": "ok"}
|
||||
|
||||
def test_workflow_mode_uses_workflow(self, app: Flask) -> None:
|
||||
features_dict = {}
|
||||
workflow = SimpleNamespace(
|
||||
features_dict=features_dict,
|
||||
user_input_form=lambda to_old_structure=False: [{"var": "x"}],
|
||||
)
|
||||
app_model = SimpleNamespace(mode="workflow", workflow=workflow)
|
||||
|
||||
with (
|
||||
app.test_request_context("/parameters"),
|
||||
patch("controllers.web.app.get_parameters_from_feature_dict", return_value={}) as mock_params,
|
||||
patch("controllers.web.app.fields.Parameters") as mock_fields,
|
||||
):
|
||||
mock_fields.model_validate.return_value.model_dump.return_value = {}
|
||||
AppParameterApi().get(app_model, SimpleNamespace())
|
||||
|
||||
mock_params.assert_called_once_with(features_dict=features_dict, user_input_form=[{"var": "x"}])
|
||||
|
||||
def test_advanced_chat_mode_no_workflow_raises(self, app: Flask) -> None:
|
||||
app_model = SimpleNamespace(mode="advanced-chat", workflow=None)
|
||||
with app.test_request_context("/parameters"):
|
||||
with pytest.raises(AppUnavailableError):
|
||||
AppParameterApi().get(app_model, SimpleNamespace())
|
||||
|
||||
def test_standard_mode_uses_app_model_config(self, app: Flask) -> None:
|
||||
config = SimpleNamespace(to_dict=lambda: {"user_input_form": [{"var": "y"}], "key": "val"})
|
||||
app_model = SimpleNamespace(mode="chat", app_model_config=config)
|
||||
|
||||
with (
|
||||
app.test_request_context("/parameters"),
|
||||
patch("controllers.web.app.get_parameters_from_feature_dict", return_value={}) as mock_params,
|
||||
patch("controllers.web.app.fields.Parameters") as mock_fields,
|
||||
):
|
||||
mock_fields.model_validate.return_value.model_dump.return_value = {}
|
||||
AppParameterApi().get(app_model, SimpleNamespace())
|
||||
|
||||
call_kwargs = mock_params.call_args
|
||||
assert call_kwargs.kwargs["user_input_form"] == [{"var": "y"}]
|
||||
|
||||
def test_standard_mode_no_config_raises(self, app: Flask) -> None:
|
||||
app_model = SimpleNamespace(mode="chat", app_model_config=None)
|
||||
with app.test_request_context("/parameters"):
|
||||
with pytest.raises(AppUnavailableError):
|
||||
AppParameterApi().get(app_model, SimpleNamespace())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AppMeta
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAppMeta:
|
||||
@patch("controllers.web.app.AppService")
|
||||
def test_get_returns_meta(self, mock_service_cls: MagicMock, app: Flask) -> None:
|
||||
mock_service_cls.return_value.get_app_meta.return_value = {"tool_icons": {}}
|
||||
app_model = SimpleNamespace(id="app-1")
|
||||
|
||||
with app.test_request_context("/meta"):
|
||||
result = AppMeta().get(app_model, SimpleNamespace())
|
||||
|
||||
assert result == {"tool_icons": {}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AppAccessMode
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAppAccessMode:
|
||||
@patch("controllers.web.app.FeatureService.get_system_features")
|
||||
def test_returns_public_when_webapp_auth_disabled(self, mock_features: MagicMock, app: Flask) -> None:
|
||||
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
|
||||
|
||||
with app.test_request_context("/webapp/access-mode?appId=app-1"):
|
||||
result = AppAccessMode().get()
|
||||
|
||||
assert result == {"accessMode": "public"}
|
||||
|
||||
@patch("controllers.web.app.EnterpriseService.WebAppAuth.get_app_access_mode_by_id")
|
||||
@patch("controllers.web.app.FeatureService.get_system_features")
|
||||
def test_returns_access_mode_with_app_id(
|
||||
self, mock_features: MagicMock, mock_access: MagicMock, app: Flask
|
||||
) -> None:
|
||||
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True))
|
||||
mock_access.return_value = SimpleNamespace(access_mode="internal")
|
||||
|
||||
with app.test_request_context("/webapp/access-mode?appId=app-1"):
|
||||
result = AppAccessMode().get()
|
||||
|
||||
assert result == {"accessMode": "internal"}
|
||||
mock_access.assert_called_once_with("app-1")
|
||||
|
||||
@patch("controllers.web.app.AppService.get_app_id_by_code", return_value="resolved-id")
|
||||
@patch("controllers.web.app.EnterpriseService.WebAppAuth.get_app_access_mode_by_id")
|
||||
@patch("controllers.web.app.FeatureService.get_system_features")
|
||||
def test_resolves_app_code_to_id(
|
||||
self, mock_features: MagicMock, mock_access: MagicMock, mock_resolve: MagicMock, app: Flask
|
||||
) -> None:
|
||||
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True))
|
||||
mock_access.return_value = SimpleNamespace(access_mode="external")
|
||||
|
||||
with app.test_request_context("/webapp/access-mode?appCode=code1"):
|
||||
result = AppAccessMode().get()
|
||||
|
||||
mock_resolve.assert_called_once_with("code1")
|
||||
mock_access.assert_called_once_with("resolved-id")
|
||||
assert result == {"accessMode": "external"}
|
||||
|
||||
@patch("controllers.web.app.FeatureService.get_system_features")
|
||||
def test_raises_when_no_app_id_or_code(self, mock_features: MagicMock, app: Flask) -> None:
|
||||
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True))
|
||||
|
||||
with app.test_request_context("/webapp/access-mode"):
|
||||
with pytest.raises(ValueError, match="appId or appCode"):
|
||||
AppAccessMode().get()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AppWebAuthPermission
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAppWebAuthPermission:
|
||||
@patch("controllers.web.app.WebAppAuthService.is_app_require_permission_check", return_value=False)
|
||||
def test_returns_true_when_no_permission_check_required(self, mock_check: MagicMock, app: Flask) -> None:
|
||||
with app.test_request_context("/webapp/permission?appId=app-1", headers={"X-App-Code": "code1"}):
|
||||
result = AppWebAuthPermission().get()
|
||||
|
||||
assert result == {"result": True}
|
||||
|
||||
def test_raises_when_missing_app_id(self, app: Flask) -> None:
|
||||
with app.test_request_context("/webapp/permission", headers={"X-App-Code": "code1"}):
|
||||
with pytest.raises(ValueError, match="appId"):
|
||||
AppWebAuthPermission().get()
|
||||
135
api/tests/unit_tests/controllers/web/test_audio.py
Normal file
135
api/tests/unit_tests/controllers/web/test_audio.py
Normal file
@ -0,0 +1,135 @@
|
||||
"""Unit tests for controllers.web.audio endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.web.audio import AudioApi, TextApi
|
||||
from controllers.web.error import (
|
||||
AudioTooLargeError,
|
||||
CompletionRequestError,
|
||||
NoAudioUploadedError,
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderNotSupportSpeechToTextError,
|
||||
ProviderQuotaExceededError,
|
||||
UnsupportedAudioTypeError,
|
||||
)
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from dify_graph.model_runtime.errors.invoke import InvokeError
|
||||
from services.errors.audio import (
|
||||
AudioTooLargeServiceError,
|
||||
NoAudioUploadedServiceError,
|
||||
ProviderNotSupportSpeechToTextServiceError,
|
||||
UnsupportedAudioTypeServiceError,
|
||||
)
|
||||
|
||||
|
||||
def _app_model() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="app-1", mode="chat")
|
||||
|
||||
|
||||
def _end_user() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="eu-1", external_user_id="ext-1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AudioApi (audio-to-text)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAudioApi:
|
||||
@patch("controllers.web.audio.AudioService.transcript_asr", return_value={"text": "hello"})
|
||||
def test_happy_path(self, mock_asr: MagicMock, app: Flask) -> None:
|
||||
app.config["RESTX_MASK_HEADER"] = "X-Fields"
|
||||
data = {"file": (BytesIO(b"fake-audio"), "test.mp3")}
|
||||
with app.test_request_context("/audio-to-text", method="POST", data=data, content_type="multipart/form-data"):
|
||||
result = AudioApi().post(_app_model(), _end_user())
|
||||
|
||||
assert result == {"text": "hello"}
|
||||
|
||||
@patch("controllers.web.audio.AudioService.transcript_asr", side_effect=NoAudioUploadedServiceError())
|
||||
def test_no_audio_uploaded(self, mock_asr: MagicMock, app: Flask) -> None:
|
||||
data = {"file": (BytesIO(b""), "empty.mp3")}
|
||||
with app.test_request_context("/audio-to-text", method="POST", data=data, content_type="multipart/form-data"):
|
||||
with pytest.raises(NoAudioUploadedError):
|
||||
AudioApi().post(_app_model(), _end_user())
|
||||
|
||||
@patch("controllers.web.audio.AudioService.transcript_asr", side_effect=AudioTooLargeServiceError("too big"))
|
||||
def test_audio_too_large(self, mock_asr: MagicMock, app: Flask) -> None:
|
||||
data = {"file": (BytesIO(b"big"), "big.mp3")}
|
||||
with app.test_request_context("/audio-to-text", method="POST", data=data, content_type="multipart/form-data"):
|
||||
with pytest.raises(AudioTooLargeError):
|
||||
AudioApi().post(_app_model(), _end_user())
|
||||
|
||||
@patch("controllers.web.audio.AudioService.transcript_asr", side_effect=UnsupportedAudioTypeServiceError())
|
||||
def test_unsupported_type(self, mock_asr: MagicMock, app: Flask) -> None:
|
||||
data = {"file": (BytesIO(b"bad"), "bad.xyz")}
|
||||
with app.test_request_context("/audio-to-text", method="POST", data=data, content_type="multipart/form-data"):
|
||||
with pytest.raises(UnsupportedAudioTypeError):
|
||||
AudioApi().post(_app_model(), _end_user())
|
||||
|
||||
@patch(
|
||||
"controllers.web.audio.AudioService.transcript_asr",
|
||||
side_effect=ProviderNotSupportSpeechToTextServiceError(),
|
||||
)
|
||||
def test_provider_not_support(self, mock_asr: MagicMock, app: Flask) -> None:
|
||||
data = {"file": (BytesIO(b"x"), "x.mp3")}
|
||||
with app.test_request_context("/audio-to-text", method="POST", data=data, content_type="multipart/form-data"):
|
||||
with pytest.raises(ProviderNotSupportSpeechToTextError):
|
||||
AudioApi().post(_app_model(), _end_user())
|
||||
|
||||
@patch(
|
||||
"controllers.web.audio.AudioService.transcript_asr",
|
||||
side_effect=ProviderTokenNotInitError(description="no token"),
|
||||
)
|
||||
def test_provider_not_init(self, mock_asr: MagicMock, app: Flask) -> None:
|
||||
data = {"file": (BytesIO(b"x"), "x.mp3")}
|
||||
with app.test_request_context("/audio-to-text", method="POST", data=data, content_type="multipart/form-data"):
|
||||
with pytest.raises(ProviderNotInitializeError):
|
||||
AudioApi().post(_app_model(), _end_user())
|
||||
|
||||
@patch("controllers.web.audio.AudioService.transcript_asr", side_effect=QuotaExceededError())
|
||||
def test_quota_exceeded(self, mock_asr: MagicMock, app: Flask) -> None:
|
||||
data = {"file": (BytesIO(b"x"), "x.mp3")}
|
||||
with app.test_request_context("/audio-to-text", method="POST", data=data, content_type="multipart/form-data"):
|
||||
with pytest.raises(ProviderQuotaExceededError):
|
||||
AudioApi().post(_app_model(), _end_user())
|
||||
|
||||
@patch("controllers.web.audio.AudioService.transcript_asr", side_effect=ModelCurrentlyNotSupportError())
|
||||
def test_model_not_support(self, mock_asr: MagicMock, app: Flask) -> None:
|
||||
data = {"file": (BytesIO(b"x"), "x.mp3")}
|
||||
with app.test_request_context("/audio-to-text", method="POST", data=data, content_type="multipart/form-data"):
|
||||
with pytest.raises(ProviderModelCurrentlyNotSupportError):
|
||||
AudioApi().post(_app_model(), _end_user())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TextApi (text-to-audio)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestTextApi:
|
||||
@patch("controllers.web.audio.AudioService.transcript_tts", return_value="audio-bytes")
|
||||
@patch("controllers.web.audio.web_ns")
|
||||
def test_happy_path(self, mock_ns: MagicMock, mock_tts: MagicMock, app: Flask) -> None:
|
||||
mock_ns.payload = {"text": "hello", "voice": "alloy"}
|
||||
|
||||
with app.test_request_context("/text-to-audio", method="POST"):
|
||||
result = TextApi().post(_app_model(), _end_user())
|
||||
|
||||
assert result == "audio-bytes"
|
||||
mock_tts.assert_called_once()
|
||||
|
||||
@patch(
|
||||
"controllers.web.audio.AudioService.transcript_tts",
|
||||
side_effect=InvokeError(description="invoke failed"),
|
||||
)
|
||||
@patch("controllers.web.audio.web_ns")
|
||||
def test_invoke_error_mapped(self, mock_ns: MagicMock, mock_tts: MagicMock, app: Flask) -> None:
|
||||
mock_ns.payload = {"text": "hello"}
|
||||
|
||||
with app.test_request_context("/text-to-audio", method="POST"):
|
||||
with pytest.raises(CompletionRequestError):
|
||||
TextApi().post(_app_model(), _end_user())
|
||||
161
api/tests/unit_tests/controllers/web/test_completion.py
Normal file
161
api/tests/unit_tests/controllers/web/test_completion.py
Normal file
@ -0,0 +1,161 @@
|
||||
"""Unit tests for controllers.web.completion endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.web.completion import ChatApi, ChatStopApi, CompletionApi, CompletionStopApi
|
||||
from controllers.web.error import (
|
||||
CompletionRequestError,
|
||||
NotChatAppError,
|
||||
NotCompletionAppError,
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
)
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from dify_graph.model_runtime.errors.invoke import InvokeError
|
||||
|
||||
|
||||
def _completion_app() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="app-1", mode="completion")
|
||||
|
||||
|
||||
def _chat_app() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="app-1", mode="chat")
|
||||
|
||||
|
||||
def _end_user() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="eu-1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CompletionApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestCompletionApi:
|
||||
def test_wrong_mode_raises(self, app: Flask) -> None:
|
||||
with app.test_request_context("/completion-messages", method="POST"):
|
||||
with pytest.raises(NotCompletionAppError):
|
||||
CompletionApi().post(_chat_app(), _end_user())
|
||||
|
||||
@patch("controllers.web.completion.helper.compact_generate_response", return_value={"answer": "hi"})
|
||||
@patch("controllers.web.completion.AppGenerateService.generate")
|
||||
@patch("controllers.web.completion.web_ns")
|
||||
def test_happy_path(self, mock_ns: MagicMock, mock_gen: MagicMock, mock_compact: MagicMock, app: Flask) -> None:
|
||||
mock_ns.payload = {"inputs": {}, "query": "test"}
|
||||
mock_gen.return_value = "response-obj"
|
||||
|
||||
with app.test_request_context("/completion-messages", method="POST"):
|
||||
result = CompletionApi().post(_completion_app(), _end_user())
|
||||
|
||||
assert result == {"answer": "hi"}
|
||||
|
||||
@patch(
|
||||
"controllers.web.completion.AppGenerateService.generate",
|
||||
side_effect=ProviderTokenNotInitError(description="not init"),
|
||||
)
|
||||
@patch("controllers.web.completion.web_ns")
|
||||
def test_provider_not_init_error(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
|
||||
mock_ns.payload = {"inputs": {}}
|
||||
|
||||
with app.test_request_context("/completion-messages", method="POST"):
|
||||
with pytest.raises(ProviderNotInitializeError):
|
||||
CompletionApi().post(_completion_app(), _end_user())
|
||||
|
||||
@patch(
|
||||
"controllers.web.completion.AppGenerateService.generate",
|
||||
side_effect=QuotaExceededError(),
|
||||
)
|
||||
@patch("controllers.web.completion.web_ns")
|
||||
def test_quota_exceeded_error(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
|
||||
mock_ns.payload = {"inputs": {}}
|
||||
|
||||
with app.test_request_context("/completion-messages", method="POST"):
|
||||
with pytest.raises(ProviderQuotaExceededError):
|
||||
CompletionApi().post(_completion_app(), _end_user())
|
||||
|
||||
@patch(
|
||||
"controllers.web.completion.AppGenerateService.generate",
|
||||
side_effect=ModelCurrentlyNotSupportError(),
|
||||
)
|
||||
@patch("controllers.web.completion.web_ns")
|
||||
def test_model_not_support_error(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
|
||||
mock_ns.payload = {"inputs": {}}
|
||||
|
||||
with app.test_request_context("/completion-messages", method="POST"):
|
||||
with pytest.raises(ProviderModelCurrentlyNotSupportError):
|
||||
CompletionApi().post(_completion_app(), _end_user())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CompletionStopApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestCompletionStopApi:
|
||||
def test_wrong_mode_raises(self, app: Flask) -> None:
|
||||
with app.test_request_context("/completion-messages/task-1/stop", method="POST"):
|
||||
with pytest.raises(NotCompletionAppError):
|
||||
CompletionStopApi().post(_chat_app(), _end_user(), "task-1")
|
||||
|
||||
@patch("controllers.web.completion.AppTaskService.stop_task")
|
||||
def test_stop_success(self, mock_stop: MagicMock, app: Flask) -> None:
|
||||
with app.test_request_context("/completion-messages/task-1/stop", method="POST"):
|
||||
result, status = CompletionStopApi().post(_completion_app(), _end_user(), "task-1")
|
||||
|
||||
assert status == 200
|
||||
assert result == {"result": "success"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChatApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestChatApi:
|
||||
def test_wrong_mode_raises(self, app: Flask) -> None:
|
||||
with app.test_request_context("/chat-messages", method="POST"):
|
||||
with pytest.raises(NotChatAppError):
|
||||
ChatApi().post(_completion_app(), _end_user())
|
||||
|
||||
@patch("controllers.web.completion.helper.compact_generate_response", return_value={"answer": "reply"})
|
||||
@patch("controllers.web.completion.AppGenerateService.generate")
|
||||
@patch("controllers.web.completion.web_ns")
|
||||
def test_happy_path(self, mock_ns: MagicMock, mock_gen: MagicMock, mock_compact: MagicMock, app: Flask) -> None:
|
||||
mock_ns.payload = {"inputs": {}, "query": "hi"}
|
||||
mock_gen.return_value = "response"
|
||||
|
||||
with app.test_request_context("/chat-messages", method="POST"):
|
||||
result = ChatApi().post(_chat_app(), _end_user())
|
||||
|
||||
assert result == {"answer": "reply"}
|
||||
|
||||
@patch(
|
||||
"controllers.web.completion.AppGenerateService.generate",
|
||||
side_effect=InvokeError(description="rate limit"),
|
||||
)
|
||||
@patch("controllers.web.completion.web_ns")
|
||||
def test_invoke_error_mapped(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
|
||||
mock_ns.payload = {"inputs": {}, "query": "x"}
|
||||
|
||||
with app.test_request_context("/chat-messages", method="POST"):
|
||||
with pytest.raises(CompletionRequestError):
|
||||
ChatApi().post(_chat_app(), _end_user())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChatStopApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestChatStopApi:
|
||||
def test_wrong_mode_raises(self, app: Flask) -> None:
|
||||
with app.test_request_context("/chat-messages/task-1/stop", method="POST"):
|
||||
with pytest.raises(NotChatAppError):
|
||||
ChatStopApi().post(_completion_app(), _end_user(), "task-1")
|
||||
|
||||
@patch("controllers.web.completion.AppTaskService.stop_task")
|
||||
def test_stop_success(self, mock_stop: MagicMock, app: Flask) -> None:
|
||||
with app.test_request_context("/chat-messages/task-1/stop", method="POST"):
|
||||
result, status = ChatStopApi().post(_chat_app(), _end_user(), "task-1")
|
||||
|
||||
assert status == 200
|
||||
assert result == {"result": "success"}
|
||||
183
api/tests/unit_tests/controllers/web/test_conversation.py
Normal file
183
api/tests/unit_tests/controllers/web/test_conversation.py
Normal file
@ -0,0 +1,183 @@
|
||||
"""Unit tests for controllers.web.conversation endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.web.conversation import (
|
||||
ConversationApi,
|
||||
ConversationListApi,
|
||||
ConversationPinApi,
|
||||
ConversationRenameApi,
|
||||
ConversationUnPinApi,
|
||||
)
|
||||
from controllers.web.error import NotChatAppError
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
|
||||
|
||||
def _chat_app() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="app-1", mode="chat")
|
||||
|
||||
|
||||
def _completion_app() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="app-1", mode="completion")
|
||||
|
||||
|
||||
def _end_user() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="eu-1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ConversationListApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestConversationListApi:
|
||||
def test_non_chat_mode_raises(self, app: Flask) -> None:
|
||||
with app.test_request_context("/conversations"):
|
||||
with pytest.raises(NotChatAppError):
|
||||
ConversationListApi().get(_completion_app(), _end_user())
|
||||
|
||||
@patch("controllers.web.conversation.WebConversationService.pagination_by_last_id")
|
||||
@patch("controllers.web.conversation.db")
|
||||
def test_happy_path(self, mock_db: MagicMock, mock_paginate: MagicMock, app: Flask) -> None:
|
||||
conv_id = str(uuid4())
|
||||
conv = SimpleNamespace(
|
||||
id=conv_id,
|
||||
name="Test",
|
||||
inputs={},
|
||||
status="normal",
|
||||
introduction="",
|
||||
created_at=1700000000,
|
||||
updated_at=1700000000,
|
||||
)
|
||||
mock_paginate.return_value = SimpleNamespace(limit=20, has_more=False, data=[conv])
|
||||
mock_db.engine = "engine"
|
||||
|
||||
session_mock = MagicMock()
|
||||
session_ctx = MagicMock()
|
||||
session_ctx.__enter__ = MagicMock(return_value=session_mock)
|
||||
session_ctx.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with (
|
||||
app.test_request_context("/conversations?limit=20"),
|
||||
patch("controllers.web.conversation.Session", return_value=session_ctx),
|
||||
):
|
||||
result = ConversationListApi().get(_chat_app(), _end_user())
|
||||
|
||||
assert result["limit"] == 20
|
||||
assert result["has_more"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ConversationApi (delete)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestConversationApi:
|
||||
def test_non_chat_mode_raises(self, app: Flask) -> None:
|
||||
with app.test_request_context(f"/conversations/{uuid4()}"):
|
||||
with pytest.raises(NotChatAppError):
|
||||
ConversationApi().delete(_completion_app(), _end_user(), uuid4())
|
||||
|
||||
@patch("controllers.web.conversation.ConversationService.delete")
|
||||
def test_delete_success(self, mock_delete: MagicMock, app: Flask) -> None:
|
||||
c_id = uuid4()
|
||||
with app.test_request_context(f"/conversations/{c_id}"):
|
||||
result, status = ConversationApi().delete(_chat_app(), _end_user(), c_id)
|
||||
|
||||
assert status == 204
|
||||
assert result["result"] == "success"
|
||||
|
||||
@patch("controllers.web.conversation.ConversationService.delete", side_effect=ConversationNotExistsError())
|
||||
def test_delete_not_found(self, mock_delete: MagicMock, app: Flask) -> None:
|
||||
c_id = uuid4()
|
||||
with app.test_request_context(f"/conversations/{c_id}"):
|
||||
with pytest.raises(NotFound, match="Conversation Not Exists"):
|
||||
ConversationApi().delete(_chat_app(), _end_user(), c_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ConversationRenameApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestConversationRenameApi:
|
||||
def test_non_chat_mode_raises(self, app: Flask) -> None:
|
||||
with app.test_request_context(f"/conversations/{uuid4()}/name", method="POST", json={"name": "x"}):
|
||||
with pytest.raises(NotChatAppError):
|
||||
ConversationRenameApi().post(_completion_app(), _end_user(), uuid4())
|
||||
|
||||
@patch("controllers.web.conversation.ConversationService.rename")
|
||||
@patch("controllers.web.conversation.web_ns")
|
||||
def test_rename_success(self, mock_ns: MagicMock, mock_rename: MagicMock, app: Flask) -> None:
|
||||
c_id = uuid4()
|
||||
mock_ns.payload = {"name": "New Name", "auto_generate": False}
|
||||
conv = SimpleNamespace(
|
||||
id=str(c_id),
|
||||
name="New Name",
|
||||
inputs={},
|
||||
status="normal",
|
||||
introduction="",
|
||||
created_at=1700000000,
|
||||
updated_at=1700000000,
|
||||
)
|
||||
mock_rename.return_value = conv
|
||||
|
||||
with app.test_request_context(f"/conversations/{c_id}/name", method="POST", json={"name": "New Name"}):
|
||||
result = ConversationRenameApi().post(_chat_app(), _end_user(), c_id)
|
||||
|
||||
assert result["name"] == "New Name"
|
||||
|
||||
@patch(
|
||||
"controllers.web.conversation.ConversationService.rename",
|
||||
side_effect=ConversationNotExistsError(),
|
||||
)
|
||||
@patch("controllers.web.conversation.web_ns")
|
||||
def test_rename_not_found(self, mock_ns: MagicMock, mock_rename: MagicMock, app: Flask) -> None:
|
||||
c_id = uuid4()
|
||||
mock_ns.payload = {"name": "X", "auto_generate": False}
|
||||
|
||||
with app.test_request_context(f"/conversations/{c_id}/name", method="POST", json={"name": "X"}):
|
||||
with pytest.raises(NotFound, match="Conversation Not Exists"):
|
||||
ConversationRenameApi().post(_chat_app(), _end_user(), c_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ConversationPinApi / ConversationUnPinApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestConversationPinApi:
|
||||
def test_non_chat_mode_raises(self, app: Flask) -> None:
|
||||
with app.test_request_context(f"/conversations/{uuid4()}/pin", method="PATCH"):
|
||||
with pytest.raises(NotChatAppError):
|
||||
ConversationPinApi().patch(_completion_app(), _end_user(), uuid4())
|
||||
|
||||
@patch("controllers.web.conversation.WebConversationService.pin")
|
||||
def test_pin_success(self, mock_pin: MagicMock, app: Flask) -> None:
|
||||
c_id = uuid4()
|
||||
with app.test_request_context(f"/conversations/{c_id}/pin", method="PATCH"):
|
||||
result = ConversationPinApi().patch(_chat_app(), _end_user(), c_id)
|
||||
|
||||
assert result["result"] == "success"
|
||||
|
||||
@patch("controllers.web.conversation.WebConversationService.pin", side_effect=ConversationNotExistsError())
|
||||
def test_pin_not_found(self, mock_pin: MagicMock, app: Flask) -> None:
|
||||
c_id = uuid4()
|
||||
with app.test_request_context(f"/conversations/{c_id}/pin", method="PATCH"):
|
||||
with pytest.raises(NotFound):
|
||||
ConversationPinApi().patch(_chat_app(), _end_user(), c_id)
|
||||
|
||||
|
||||
class TestConversationUnPinApi:
|
||||
def test_non_chat_mode_raises(self, app: Flask) -> None:
|
||||
with app.test_request_context(f"/conversations/{uuid4()}/unpin", method="PATCH"):
|
||||
with pytest.raises(NotChatAppError):
|
||||
ConversationUnPinApi().patch(_completion_app(), _end_user(), uuid4())
|
||||
|
||||
@patch("controllers.web.conversation.WebConversationService.unpin")
|
||||
def test_unpin_success(self, mock_unpin: MagicMock, app: Flask) -> None:
|
||||
c_id = uuid4()
|
||||
with app.test_request_context(f"/conversations/{c_id}/unpin", method="PATCH"):
|
||||
result = ConversationUnPinApi().patch(_chat_app(), _end_user(), c_id)
|
||||
|
||||
assert result["result"] == "success"
|
||||
75
api/tests/unit_tests/controllers/web/test_error.py
Normal file
75
api/tests/unit_tests/controllers/web/test_error.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""Unit tests for controllers.web.error HTTP exception classes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from controllers.web.error import (
|
||||
AppMoreLikeThisDisabledError,
|
||||
AppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
AppUnavailableError,
|
||||
AudioTooLargeError,
|
||||
CompletionRequestError,
|
||||
ConversationCompletedError,
|
||||
InvalidArgumentError,
|
||||
InvokeRateLimitError,
|
||||
NoAudioUploadedError,
|
||||
NotChatAppError,
|
||||
NotCompletionAppError,
|
||||
NotFoundError,
|
||||
NotWorkflowAppError,
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderNotSupportSpeechToTextError,
|
||||
ProviderQuotaExceededError,
|
||||
UnsupportedAudioTypeError,
|
||||
WebAppAuthAccessDeniedError,
|
||||
WebAppAuthRequiredError,
|
||||
WebFormRateLimitExceededError,
|
||||
)
|
||||
|
||||
_ERROR_SPECS: list[tuple[type, str, int]] = [
|
||||
(AppUnavailableError, "app_unavailable", 400),
|
||||
(NotCompletionAppError, "not_completion_app", 400),
|
||||
(NotChatAppError, "not_chat_app", 400),
|
||||
(NotWorkflowAppError, "not_workflow_app", 400),
|
||||
(ConversationCompletedError, "conversation_completed", 400),
|
||||
(ProviderNotInitializeError, "provider_not_initialize", 400),
|
||||
(ProviderQuotaExceededError, "provider_quota_exceeded", 400),
|
||||
(ProviderModelCurrentlyNotSupportError, "model_currently_not_support", 400),
|
||||
(CompletionRequestError, "completion_request_error", 400),
|
||||
(AppMoreLikeThisDisabledError, "app_more_like_this_disabled", 403),
|
||||
(AppSuggestedQuestionsAfterAnswerDisabledError, "app_suggested_questions_after_answer_disabled", 403),
|
||||
(NoAudioUploadedError, "no_audio_uploaded", 400),
|
||||
(AudioTooLargeError, "audio_too_large", 413),
|
||||
(UnsupportedAudioTypeError, "unsupported_audio_type", 415),
|
||||
(ProviderNotSupportSpeechToTextError, "provider_not_support_speech_to_text", 400),
|
||||
(WebAppAuthRequiredError, "web_sso_auth_required", 401),
|
||||
(WebAppAuthAccessDeniedError, "web_app_access_denied", 401),
|
||||
(InvokeRateLimitError, "rate_limit_error", 429),
|
||||
(WebFormRateLimitExceededError, "web_form_rate_limit_exceeded", 429),
|
||||
(NotFoundError, "not_found", 404),
|
||||
(InvalidArgumentError, "invalid_param", 400),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cls", "expected_code", "expected_status"),
|
||||
_ERROR_SPECS,
|
||||
ids=[cls.__name__ for cls, _, _ in _ERROR_SPECS],
|
||||
)
|
||||
def test_error_class_attributes(cls: type, expected_code: str, expected_status: int) -> None:
|
||||
"""Each error class exposes the correct error_code and HTTP status code."""
|
||||
assert cls.error_code == expected_code
|
||||
assert cls.code == expected_status
|
||||
|
||||
|
||||
def test_error_classes_have_description() -> None:
|
||||
"""Every error class has a description (string or None for generic errors)."""
|
||||
# NotFoundError and InvalidArgumentError use None description by design
|
||||
_NO_DESCRIPTION = {NotFoundError, InvalidArgumentError}
|
||||
for cls, _, _ in _ERROR_SPECS:
|
||||
if cls in _NO_DESCRIPTION:
|
||||
continue
|
||||
assert isinstance(cls.description, str), f"{cls.__name__} missing description"
|
||||
assert len(cls.description) > 0, f"{cls.__name__} has empty description"
|
||||
38
api/tests/unit_tests/controllers/web/test_feature.py
Normal file
38
api/tests/unit_tests/controllers/web/test_feature.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""Unit tests for controllers.web.feature endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from controllers.web.feature import SystemFeatureApi
|
||||
|
||||
|
||||
class TestSystemFeatureApi:
|
||||
@patch("controllers.web.feature.FeatureService.get_system_features")
|
||||
def test_returns_system_features(self, mock_features: MagicMock, app: Flask) -> None:
|
||||
mock_model = MagicMock()
|
||||
mock_model.model_dump.return_value = {"sso_enforced_for_signin": False, "webapp_auth": {"enabled": False}}
|
||||
mock_features.return_value = mock_model
|
||||
|
||||
with app.test_request_context("/system-features"):
|
||||
result = SystemFeatureApi().get()
|
||||
|
||||
assert result == {"sso_enforced_for_signin": False, "webapp_auth": {"enabled": False}}
|
||||
mock_features.assert_called_once()
|
||||
|
||||
@patch("controllers.web.feature.FeatureService.get_system_features")
|
||||
def test_unauthenticated_access(self, mock_features: MagicMock, app: Flask) -> None:
|
||||
"""SystemFeatureApi is unauthenticated by design — no WebApiResource decorator."""
|
||||
mock_model = MagicMock()
|
||||
mock_model.model_dump.return_value = {}
|
||||
mock_features.return_value = mock_model
|
||||
|
||||
# Verify it's a bare Resource, not WebApiResource
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.web.wraps import WebApiResource
|
||||
|
||||
assert issubclass(SystemFeatureApi, Resource)
|
||||
assert not issubclass(SystemFeatureApi, WebApiResource)
|
||||
89
api/tests/unit_tests/controllers/web/test_files.py
Normal file
89
api/tests/unit_tests/controllers/web/test_files.py
Normal file
@ -0,0 +1,89 @@
|
||||
"""Unit tests for controllers.web.files endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.common.errors import (
|
||||
FilenameNotExistsError,
|
||||
FileTooLargeError,
|
||||
NoFileUploadedError,
|
||||
TooManyFilesError,
|
||||
)
|
||||
from controllers.web.files import FileApi
|
||||
|
||||
|
||||
def _app_model() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="app-1")
|
||||
|
||||
|
||||
def _end_user() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="eu-1")
|
||||
|
||||
|
||||
class TestFileApi:
|
||||
def test_no_file_uploaded(self, app: Flask) -> None:
|
||||
with app.test_request_context("/files/upload", method="POST", content_type="multipart/form-data"):
|
||||
with pytest.raises(NoFileUploadedError):
|
||||
FileApi().post(_app_model(), _end_user())
|
||||
|
||||
def test_too_many_files(self, app: Flask) -> None:
|
||||
data = {
|
||||
"file": (BytesIO(b"a"), "a.txt"),
|
||||
"file2": (BytesIO(b"b"), "b.txt"),
|
||||
}
|
||||
with app.test_request_context("/files/upload", method="POST", data=data, content_type="multipart/form-data"):
|
||||
# Now has "file" key but len(request.files) > 1
|
||||
with pytest.raises(TooManyFilesError):
|
||||
FileApi().post(_app_model(), _end_user())
|
||||
|
||||
def test_filename_missing(self, app: Flask) -> None:
|
||||
data = {"file": (BytesIO(b"content"), "")}
|
||||
with app.test_request_context("/files/upload", method="POST", data=data, content_type="multipart/form-data"):
|
||||
with pytest.raises(FilenameNotExistsError):
|
||||
FileApi().post(_app_model(), _end_user())
|
||||
|
||||
@patch("controllers.web.files.FileService")
|
||||
@patch("controllers.web.files.db")
|
||||
def test_upload_success(self, mock_db: MagicMock, mock_file_svc_cls: MagicMock, app: Flask) -> None:
|
||||
mock_db.engine = "engine"
|
||||
from datetime import datetime
|
||||
|
||||
upload_file = SimpleNamespace(
|
||||
id="file-1",
|
||||
name="test.txt",
|
||||
size=100,
|
||||
extension="txt",
|
||||
mime_type="text/plain",
|
||||
created_by="eu-1",
|
||||
created_at=datetime(2024, 1, 1),
|
||||
)
|
||||
mock_file_svc_cls.return_value.upload_file.return_value = upload_file
|
||||
|
||||
data = {"file": (BytesIO(b"content"), "test.txt")}
|
||||
with app.test_request_context("/files/upload", method="POST", data=data, content_type="multipart/form-data"):
|
||||
result, status = FileApi().post(_app_model(), _end_user())
|
||||
|
||||
assert status == 201
|
||||
assert result["id"] == "file-1"
|
||||
assert result["name"] == "test.txt"
|
||||
|
||||
@patch("controllers.web.files.FileService")
|
||||
@patch("controllers.web.files.db")
|
||||
def test_file_too_large_from_service(self, mock_db: MagicMock, mock_file_svc_cls: MagicMock, app: Flask) -> None:
|
||||
import services.errors.file
|
||||
|
||||
mock_db.engine = "engine"
|
||||
mock_file_svc_cls.return_value.upload_file.side_effect = services.errors.file.FileTooLargeError(
|
||||
description="max 10MB"
|
||||
)
|
||||
|
||||
data = {"file": (BytesIO(b"big"), "big.txt")}
|
||||
with app.test_request_context("/files/upload", method="POST", data=data, content_type="multipart/form-data"):
|
||||
with pytest.raises(FileTooLargeError):
|
||||
FileApi().post(_app_model(), _end_user())
|
||||
156
api/tests/unit_tests/controllers/web/test_message_endpoints.py
Normal file
156
api/tests/unit_tests/controllers/web/test_message_endpoints.py
Normal file
@ -0,0 +1,156 @@
|
||||
"""Unit tests for controllers.web.message — feedback, more-like-this, suggested questions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.web.error import (
|
||||
AppMoreLikeThisDisabledError,
|
||||
NotChatAppError,
|
||||
NotCompletionAppError,
|
||||
)
|
||||
from controllers.web.message import (
|
||||
MessageFeedbackApi,
|
||||
MessageMoreLikeThisApi,
|
||||
MessageSuggestedQuestionApi,
|
||||
)
|
||||
from services.errors.app import MoreLikeThisDisabledError
|
||||
from services.errors.message import MessageNotExistsError
|
||||
|
||||
|
||||
def _chat_app() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="app-1", mode="chat")
|
||||
|
||||
|
||||
def _completion_app() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="app-1", mode="completion")
|
||||
|
||||
|
||||
def _end_user() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="eu-1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MessageFeedbackApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestMessageFeedbackApi:
|
||||
@patch("controllers.web.message.MessageService.create_feedback")
|
||||
@patch("controllers.web.message.web_ns")
|
||||
def test_feedback_success(self, mock_ns: MagicMock, mock_create: MagicMock, app: Flask) -> None:
|
||||
mock_ns.payload = {"rating": "like", "content": "great"}
|
||||
msg_id = uuid4()
|
||||
|
||||
with app.test_request_context(f"/messages/{msg_id}/feedbacks", method="POST"):
|
||||
result = MessageFeedbackApi().post(_chat_app(), _end_user(), msg_id)
|
||||
|
||||
assert result == {"result": "success"}
|
||||
mock_create.assert_called_once()
|
||||
|
||||
@patch("controllers.web.message.MessageService.create_feedback")
|
||||
@patch("controllers.web.message.web_ns")
|
||||
def test_feedback_null_rating(self, mock_ns: MagicMock, mock_create: MagicMock, app: Flask) -> None:
|
||||
mock_ns.payload = {"rating": None}
|
||||
msg_id = uuid4()
|
||||
|
||||
with app.test_request_context(f"/messages/{msg_id}/feedbacks", method="POST"):
|
||||
result = MessageFeedbackApi().post(_chat_app(), _end_user(), msg_id)
|
||||
|
||||
assert result == {"result": "success"}
|
||||
|
||||
@patch(
|
||||
"controllers.web.message.MessageService.create_feedback",
|
||||
side_effect=MessageNotExistsError(),
|
||||
)
|
||||
@patch("controllers.web.message.web_ns")
|
||||
def test_feedback_message_not_found(self, mock_ns: MagicMock, mock_create: MagicMock, app: Flask) -> None:
|
||||
mock_ns.payload = {"rating": "dislike"}
|
||||
msg_id = uuid4()
|
||||
|
||||
with app.test_request_context(f"/messages/{msg_id}/feedbacks", method="POST"):
|
||||
with pytest.raises(NotFound, match="Message Not Exists"):
|
||||
MessageFeedbackApi().post(_chat_app(), _end_user(), msg_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MessageMoreLikeThisApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestMessageMoreLikeThisApi:
|
||||
def test_wrong_mode_raises(self, app: Flask) -> None:
|
||||
msg_id = uuid4()
|
||||
with app.test_request_context(f"/messages/{msg_id}/more-like-this?response_mode=blocking"):
|
||||
with pytest.raises(NotCompletionAppError):
|
||||
MessageMoreLikeThisApi().get(_chat_app(), _end_user(), msg_id)
|
||||
|
||||
@patch("controllers.web.message.helper.compact_generate_response", return_value={"answer": "similar"})
|
||||
@patch("controllers.web.message.AppGenerateService.generate_more_like_this")
|
||||
def test_happy_path(self, mock_gen: MagicMock, mock_compact: MagicMock, app: Flask) -> None:
|
||||
msg_id = uuid4()
|
||||
mock_gen.return_value = "response"
|
||||
|
||||
with app.test_request_context(f"/messages/{msg_id}/more-like-this?response_mode=blocking"):
|
||||
result = MessageMoreLikeThisApi().get(_completion_app(), _end_user(), msg_id)
|
||||
|
||||
assert result == {"answer": "similar"}
|
||||
|
||||
@patch(
|
||||
"controllers.web.message.AppGenerateService.generate_more_like_this",
|
||||
side_effect=MessageNotExistsError(),
|
||||
)
|
||||
def test_message_not_found(self, mock_gen: MagicMock, app: Flask) -> None:
|
||||
msg_id = uuid4()
|
||||
with app.test_request_context(f"/messages/{msg_id}/more-like-this?response_mode=blocking"):
|
||||
with pytest.raises(NotFound, match="Message Not Exists"):
|
||||
MessageMoreLikeThisApi().get(_completion_app(), _end_user(), msg_id)
|
||||
|
||||
@patch(
|
||||
"controllers.web.message.AppGenerateService.generate_more_like_this",
|
||||
side_effect=MoreLikeThisDisabledError(),
|
||||
)
|
||||
def test_feature_disabled(self, mock_gen: MagicMock, app: Flask) -> None:
|
||||
msg_id = uuid4()
|
||||
with app.test_request_context(f"/messages/{msg_id}/more-like-this?response_mode=blocking"):
|
||||
with pytest.raises(AppMoreLikeThisDisabledError):
|
||||
MessageMoreLikeThisApi().get(_completion_app(), _end_user(), msg_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MessageSuggestedQuestionApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestMessageSuggestedQuestionApi:
|
||||
def test_wrong_mode_raises(self, app: Flask) -> None:
|
||||
msg_id = uuid4()
|
||||
with app.test_request_context(f"/messages/{msg_id}/suggested-questions"):
|
||||
with pytest.raises(NotChatAppError):
|
||||
MessageSuggestedQuestionApi().get(_completion_app(), _end_user(), msg_id)
|
||||
|
||||
def test_wrong_mode_raises(self, app: Flask) -> None:
|
||||
msg_id = uuid4()
|
||||
with app.test_request_context(f"/messages/{msg_id}/suggested-questions"):
|
||||
with pytest.raises(NotChatAppError):
|
||||
MessageSuggestedQuestionApi().get(_completion_app(), _end_user(), msg_id)
|
||||
|
||||
@patch("controllers.web.message.MessageService.get_suggested_questions_after_answer")
|
||||
def test_happy_path(self, mock_suggest: MagicMock, app: Flask) -> None:
|
||||
msg_id = uuid4()
|
||||
mock_suggest.return_value = ["What about X?", "Tell me more about Y."]
|
||||
|
||||
with app.test_request_context(f"/messages/{msg_id}/suggested-questions"):
|
||||
result = MessageSuggestedQuestionApi().get(_chat_app(), _end_user(), msg_id)
|
||||
|
||||
assert result["data"] == ["What about X?", "Tell me more about Y."]
|
||||
|
||||
@patch(
|
||||
"controllers.web.message.MessageService.get_suggested_questions_after_answer",
|
||||
side_effect=MessageNotExistsError(),
|
||||
)
|
||||
def test_message_not_found(self, mock_suggest: MagicMock, app: Flask) -> None:
|
||||
msg_id = uuid4()
|
||||
with app.test_request_context(f"/messages/{msg_id}/suggested-questions"):
|
||||
with pytest.raises(NotFound, match="Message not found"):
|
||||
MessageSuggestedQuestionApi().get(_chat_app(), _end_user(), msg_id)
|
||||
103
api/tests/unit_tests/controllers/web/test_passport.py
Normal file
103
api/tests/unit_tests/controllers/web/test_passport.py
Normal file
@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from werkzeug.exceptions import NotFound, Unauthorized
|
||||
|
||||
from controllers.web.error import WebAppAuthRequiredError
|
||||
from controllers.web.passport import (
|
||||
PassportService,
|
||||
decode_enterprise_webapp_user_id,
|
||||
exchange_token_for_existing_web_user,
|
||||
generate_session_id,
|
||||
)
|
||||
from services.webapp_auth_service import WebAppAuthType
|
||||
|
||||
|
||||
def test_decode_enterprise_webapp_user_id_none() -> None:
|
||||
assert decode_enterprise_webapp_user_id(None) is None
|
||||
|
||||
|
||||
def test_decode_enterprise_webapp_user_id_invalid_source(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(PassportService, "verify", lambda *_args, **_kwargs: {"token_source": "bad"})
|
||||
with pytest.raises(Unauthorized):
|
||||
decode_enterprise_webapp_user_id("token")
|
||||
|
||||
|
||||
def test_decode_enterprise_webapp_user_id_valid(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
decoded = {"token_source": "webapp_login_token", "user_id": "u1"}
|
||||
monkeypatch.setattr(PassportService, "verify", lambda *_args, **_kwargs: decoded)
|
||||
assert decode_enterprise_webapp_user_id("token") == decoded
|
||||
|
||||
|
||||
def test_exchange_token_public_flow(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
site = SimpleNamespace(id="s1", app_id="a1", code="code", status="normal")
|
||||
app_model = SimpleNamespace(id="a1", status="normal", enable_site=True)
|
||||
|
||||
def _scalar_side_effect(*_args, **_kwargs):
|
||||
if not hasattr(_scalar_side_effect, "calls"):
|
||||
_scalar_side_effect.calls = 0
|
||||
_scalar_side_effect.calls += 1
|
||||
return site if _scalar_side_effect.calls == 1 else app_model
|
||||
|
||||
db_session = SimpleNamespace(scalar=_scalar_side_effect)
|
||||
monkeypatch.setattr("controllers.web.passport.db", SimpleNamespace(session=db_session))
|
||||
monkeypatch.setattr("controllers.web.passport._exchange_for_public_app_token", lambda *_args, **_kwargs: "resp")
|
||||
|
||||
decoded = {"auth_type": "public"}
|
||||
result = exchange_token_for_existing_web_user("code", decoded, WebAppAuthType.PUBLIC)
|
||||
assert result == "resp"
|
||||
|
||||
|
||||
def test_exchange_token_requires_external(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
site = SimpleNamespace(id="s1", app_id="a1", code="code", status="normal")
|
||||
app_model = SimpleNamespace(id="a1", status="normal", enable_site=True)
|
||||
|
||||
def _scalar_side_effect(*_args, **_kwargs):
|
||||
if not hasattr(_scalar_side_effect, "calls"):
|
||||
_scalar_side_effect.calls = 0
|
||||
_scalar_side_effect.calls += 1
|
||||
return site if _scalar_side_effect.calls == 1 else app_model
|
||||
|
||||
db_session = SimpleNamespace(scalar=_scalar_side_effect)
|
||||
monkeypatch.setattr("controllers.web.passport.db", SimpleNamespace(session=db_session))
|
||||
|
||||
decoded = {"auth_type": "internal"}
|
||||
with pytest.raises(WebAppAuthRequiredError):
|
||||
exchange_token_for_existing_web_user("code", decoded, WebAppAuthType.EXTERNAL)
|
||||
|
||||
|
||||
def test_exchange_token_missing_session_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
site = SimpleNamespace(id="s1", app_id="a1", code="code", status="normal")
|
||||
app_model = SimpleNamespace(id="a1", status="normal", enable_site=True, tenant_id="t1")
|
||||
|
||||
def _scalar_side_effect(*_args, **_kwargs):
|
||||
if not hasattr(_scalar_side_effect, "calls"):
|
||||
_scalar_side_effect.calls = 0
|
||||
_scalar_side_effect.calls += 1
|
||||
if _scalar_side_effect.calls == 1:
|
||||
return site
|
||||
if _scalar_side_effect.calls == 2:
|
||||
return app_model
|
||||
return None
|
||||
|
||||
db_session = SimpleNamespace(scalar=_scalar_side_effect, add=lambda *_a, **_k: None, commit=lambda: None)
|
||||
monkeypatch.setattr("controllers.web.passport.db", SimpleNamespace(session=db_session))
|
||||
|
||||
decoded = {"auth_type": "internal"}
|
||||
with pytest.raises(NotFound):
|
||||
exchange_token_for_existing_web_user("code", decoded, WebAppAuthType.INTERNAL)
|
||||
|
||||
|
||||
def test_generate_session_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
counts = [1, 0]
|
||||
|
||||
def _scalar(*_args, **_kwargs):
|
||||
return counts.pop(0)
|
||||
|
||||
db_session = SimpleNamespace(scalar=_scalar)
|
||||
monkeypatch.setattr("controllers.web.passport.db", SimpleNamespace(session=db_session))
|
||||
|
||||
session_id = generate_session_id()
|
||||
assert session_id
|
||||
423
api/tests/unit_tests/controllers/web/test_pydantic_models.py
Normal file
423
api/tests/unit_tests/controllers/web/test_pydantic_models.py
Normal file
@ -0,0 +1,423 @@
|
||||
"""Unit tests for Pydantic models defined in controllers.web modules.
|
||||
|
||||
Covers validation logic, field defaults, constraints, and custom validators
|
||||
for all ~15 Pydantic models across the web controller layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# app.py models
|
||||
# ---------------------------------------------------------------------------
|
||||
from controllers.web.app import AppAccessModeQuery
|
||||
|
||||
|
||||
class TestAppAccessModeQuery:
|
||||
def test_alias_resolution(self) -> None:
|
||||
q = AppAccessModeQuery.model_validate({"appId": "abc", "appCode": "xyz"})
|
||||
assert q.app_id == "abc"
|
||||
assert q.app_code == "xyz"
|
||||
|
||||
def test_defaults_to_none(self) -> None:
|
||||
q = AppAccessModeQuery.model_validate({})
|
||||
assert q.app_id is None
|
||||
assert q.app_code is None
|
||||
|
||||
def test_accepts_snake_case(self) -> None:
|
||||
q = AppAccessModeQuery(app_id="id1", app_code="code1")
|
||||
assert q.app_id == "id1"
|
||||
assert q.app_code == "code1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# audio.py models
|
||||
# ---------------------------------------------------------------------------
|
||||
from controllers.web.audio import TextToAudioPayload
|
||||
|
||||
|
||||
class TestTextToAudioPayload:
|
||||
def test_defaults(self) -> None:
|
||||
p = TextToAudioPayload.model_validate({})
|
||||
assert p.message_id is None
|
||||
assert p.voice is None
|
||||
assert p.text is None
|
||||
assert p.streaming is None
|
||||
|
||||
def test_valid_uuid_message_id(self) -> None:
|
||||
uid = str(uuid4())
|
||||
p = TextToAudioPayload(message_id=uid)
|
||||
assert p.message_id == uid
|
||||
|
||||
def test_none_message_id_passthrough(self) -> None:
|
||||
p = TextToAudioPayload(message_id=None)
|
||||
assert p.message_id is None
|
||||
|
||||
def test_invalid_uuid_message_id(self) -> None:
|
||||
with pytest.raises(ValidationError, match="not a valid uuid"):
|
||||
TextToAudioPayload(message_id="not-a-uuid")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# completion.py models
|
||||
# ---------------------------------------------------------------------------
|
||||
from controllers.web.completion import ChatMessagePayload, CompletionMessagePayload
|
||||
|
||||
|
||||
class TestCompletionMessagePayload:
|
||||
def test_defaults(self) -> None:
|
||||
p = CompletionMessagePayload(inputs={})
|
||||
assert p.query == ""
|
||||
assert p.files is None
|
||||
assert p.response_mode is None
|
||||
assert p.retriever_from == "web_app"
|
||||
|
||||
def test_accepts_full_payload(self) -> None:
|
||||
p = CompletionMessagePayload(
|
||||
inputs={"key": "val"},
|
||||
query="test",
|
||||
files=[{"id": "f1"}],
|
||||
response_mode="streaming",
|
||||
)
|
||||
assert p.response_mode == "streaming"
|
||||
assert p.files == [{"id": "f1"}]
|
||||
|
||||
def test_invalid_response_mode(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
CompletionMessagePayload(inputs={}, response_mode="invalid")
|
||||
|
||||
|
||||
class TestChatMessagePayload:
|
||||
def test_valid_uuid_fields(self) -> None:
|
||||
cid = str(uuid4())
|
||||
pid = str(uuid4())
|
||||
p = ChatMessagePayload(inputs={}, query="hi", conversation_id=cid, parent_message_id=pid)
|
||||
assert p.conversation_id == cid
|
||||
assert p.parent_message_id == pid
|
||||
|
||||
def test_none_uuid_fields(self) -> None:
|
||||
p = ChatMessagePayload(inputs={}, query="hi")
|
||||
assert p.conversation_id is None
|
||||
assert p.parent_message_id is None
|
||||
|
||||
def test_invalid_conversation_id(self) -> None:
|
||||
with pytest.raises(ValidationError, match="not a valid uuid"):
|
||||
ChatMessagePayload(inputs={}, query="hi", conversation_id="bad")
|
||||
|
||||
def test_invalid_parent_message_id(self) -> None:
|
||||
with pytest.raises(ValidationError, match="not a valid uuid"):
|
||||
ChatMessagePayload(inputs={}, query="hi", parent_message_id="bad")
|
||||
|
||||
def test_query_required(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ChatMessagePayload(inputs={})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# conversation.py models
|
||||
# ---------------------------------------------------------------------------
|
||||
from controllers.web.conversation import ConversationListQuery, ConversationRenamePayload
|
||||
|
||||
|
||||
class TestConversationListQuery:
|
||||
def test_defaults(self) -> None:
|
||||
q = ConversationListQuery()
|
||||
assert q.last_id is None
|
||||
assert q.limit == 20
|
||||
assert q.pinned is None
|
||||
assert q.sort_by == "-updated_at"
|
||||
|
||||
def test_limit_lower_bound(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ConversationListQuery(limit=0)
|
||||
|
||||
def test_limit_upper_bound(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ConversationListQuery(limit=101)
|
||||
|
||||
def test_limit_boundaries_valid(self) -> None:
|
||||
assert ConversationListQuery(limit=1).limit == 1
|
||||
assert ConversationListQuery(limit=100).limit == 100
|
||||
|
||||
def test_valid_sort_by_options(self) -> None:
|
||||
for opt in ("created_at", "-created_at", "updated_at", "-updated_at"):
|
||||
assert ConversationListQuery(sort_by=opt).sort_by == opt
|
||||
|
||||
def test_invalid_sort_by(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ConversationListQuery(sort_by="invalid")
|
||||
|
||||
def test_valid_last_id(self) -> None:
|
||||
uid = str(uuid4())
|
||||
assert ConversationListQuery(last_id=uid).last_id == uid
|
||||
|
||||
def test_invalid_last_id(self) -> None:
|
||||
with pytest.raises(ValidationError, match="not a valid uuid"):
|
||||
ConversationListQuery(last_id="not-uuid")
|
||||
|
||||
|
||||
class TestConversationRenamePayload:
|
||||
def test_auto_generate_true_no_name_required(self) -> None:
|
||||
p = ConversationRenamePayload(auto_generate=True)
|
||||
assert p.name is None
|
||||
|
||||
def test_auto_generate_false_requires_name(self) -> None:
|
||||
with pytest.raises(ValidationError, match="name is required"):
|
||||
ConversationRenamePayload(auto_generate=False)
|
||||
|
||||
def test_auto_generate_false_blank_name_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError, match="name is required"):
|
||||
ConversationRenamePayload(auto_generate=False, name=" ")
|
||||
|
||||
def test_auto_generate_false_with_valid_name(self) -> None:
|
||||
p = ConversationRenamePayload(auto_generate=False, name="My Chat")
|
||||
assert p.name == "My Chat"
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
p = ConversationRenamePayload(name="test")
|
||||
assert p.auto_generate is False
|
||||
assert p.name == "test"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# message.py models
|
||||
# ---------------------------------------------------------------------------
|
||||
from controllers.web.message import MessageFeedbackPayload, MessageListQuery, MessageMoreLikeThisQuery
|
||||
|
||||
|
||||
class TestMessageListQuery:
|
||||
def test_valid_query(self) -> None:
|
||||
cid = str(uuid4())
|
||||
q = MessageListQuery(conversation_id=cid)
|
||||
assert q.conversation_id == cid
|
||||
assert q.first_id is None
|
||||
assert q.limit == 20
|
||||
|
||||
def test_invalid_conversation_id(self) -> None:
|
||||
with pytest.raises(ValidationError, match="not a valid uuid"):
|
||||
MessageListQuery(conversation_id="bad")
|
||||
|
||||
def test_limit_bounds(self) -> None:
|
||||
cid = str(uuid4())
|
||||
with pytest.raises(ValidationError):
|
||||
MessageListQuery(conversation_id=cid, limit=0)
|
||||
with pytest.raises(ValidationError):
|
||||
MessageListQuery(conversation_id=cid, limit=101)
|
||||
|
||||
def test_valid_first_id(self) -> None:
|
||||
cid = str(uuid4())
|
||||
fid = str(uuid4())
|
||||
q = MessageListQuery(conversation_id=cid, first_id=fid)
|
||||
assert q.first_id == fid
|
||||
|
||||
def test_invalid_first_id(self) -> None:
|
||||
cid = str(uuid4())
|
||||
with pytest.raises(ValidationError, match="not a valid uuid"):
|
||||
MessageListQuery(conversation_id=cid, first_id="invalid")
|
||||
|
||||
|
||||
class TestMessageFeedbackPayload:
|
||||
def test_defaults(self) -> None:
|
||||
p = MessageFeedbackPayload()
|
||||
assert p.rating is None
|
||||
assert p.content is None
|
||||
|
||||
def test_valid_ratings(self) -> None:
|
||||
assert MessageFeedbackPayload(rating="like").rating == "like"
|
||||
assert MessageFeedbackPayload(rating="dislike").rating == "dislike"
|
||||
|
||||
def test_invalid_rating(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
MessageFeedbackPayload(rating="neutral")
|
||||
|
||||
|
||||
class TestMessageMoreLikeThisQuery:
|
||||
def test_valid_modes(self) -> None:
|
||||
assert MessageMoreLikeThisQuery(response_mode="blocking").response_mode == "blocking"
|
||||
assert MessageMoreLikeThisQuery(response_mode="streaming").response_mode == "streaming"
|
||||
|
||||
def test_invalid_mode(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
MessageMoreLikeThisQuery(response_mode="invalid")
|
||||
|
||||
def test_required(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
MessageMoreLikeThisQuery()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# remote_files.py models
|
||||
# ---------------------------------------------------------------------------
|
||||
from controllers.web.remote_files import RemoteFileUploadPayload
|
||||
|
||||
|
||||
class TestRemoteFileUploadPayload:
|
||||
def test_valid_url(self) -> None:
|
||||
p = RemoteFileUploadPayload(url="https://example.com/file.pdf")
|
||||
assert str(p.url) == "https://example.com/file.pdf"
|
||||
|
||||
def test_invalid_url(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
RemoteFileUploadPayload(url="not-a-url")
|
||||
|
||||
def test_url_required(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
RemoteFileUploadPayload()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# saved_message.py models
|
||||
# ---------------------------------------------------------------------------
|
||||
from controllers.web.saved_message import SavedMessageCreatePayload, SavedMessageListQuery
|
||||
|
||||
|
||||
class TestSavedMessageListQuery:
|
||||
def test_defaults(self) -> None:
|
||||
q = SavedMessageListQuery()
|
||||
assert q.last_id is None
|
||||
assert q.limit == 20
|
||||
|
||||
def test_limit_bounds(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
SavedMessageListQuery(limit=0)
|
||||
with pytest.raises(ValidationError):
|
||||
SavedMessageListQuery(limit=101)
|
||||
|
||||
def test_valid_last_id(self) -> None:
|
||||
uid = str(uuid4())
|
||||
q = SavedMessageListQuery(last_id=uid)
|
||||
assert q.last_id == uid
|
||||
|
||||
def test_empty_last_id(self) -> None:
|
||||
q = SavedMessageListQuery(last_id="")
|
||||
assert q.last_id == ""
|
||||
|
||||
|
||||
class TestSavedMessageCreatePayload:
|
||||
def test_valid_message_id(self) -> None:
|
||||
uid = str(uuid4())
|
||||
p = SavedMessageCreatePayload(message_id=uid)
|
||||
assert p.message_id == uid
|
||||
|
||||
def test_required(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
SavedMessageCreatePayload()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# workflow.py models
|
||||
# ---------------------------------------------------------------------------
|
||||
from controllers.web.workflow import WorkflowRunPayload
|
||||
|
||||
|
||||
class TestWorkflowRunPayload:
|
||||
def test_defaults(self) -> None:
|
||||
p = WorkflowRunPayload(inputs={})
|
||||
assert p.inputs == {}
|
||||
assert p.files is None
|
||||
|
||||
def test_with_files(self) -> None:
|
||||
p = WorkflowRunPayload(inputs={"k": "v"}, files=[{"id": "f1"}])
|
||||
assert p.files == [{"id": "f1"}]
|
||||
|
||||
def test_inputs_required(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
WorkflowRunPayload()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# forgot_password.py models
|
||||
# ---------------------------------------------------------------------------
|
||||
from controllers.web.forgot_password import (
|
||||
ForgotPasswordCheckPayload,
|
||||
ForgotPasswordResetPayload,
|
||||
ForgotPasswordSendPayload,
|
||||
)
|
||||
|
||||
|
||||
class TestForgotPasswordSendPayload:
|
||||
def test_valid_email(self) -> None:
|
||||
p = ForgotPasswordSendPayload(email="user@example.com")
|
||||
assert p.email == "user@example.com"
|
||||
|
||||
def test_invalid_email(self) -> None:
|
||||
with pytest.raises(ValidationError, match="not a valid email"):
|
||||
ForgotPasswordSendPayload(email="not-an-email")
|
||||
|
||||
def test_language_optional(self) -> None:
|
||||
p = ForgotPasswordSendPayload(email="a@b.com")
|
||||
assert p.language is None
|
||||
|
||||
|
||||
class TestForgotPasswordCheckPayload:
|
||||
def test_valid(self) -> None:
|
||||
p = ForgotPasswordCheckPayload(email="a@b.com", code="1234", token="tok")
|
||||
assert p.email == "a@b.com"
|
||||
assert p.code == "1234"
|
||||
assert p.token == "tok"
|
||||
|
||||
def test_empty_token_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ForgotPasswordCheckPayload(email="a@b.com", code="1234", token="")
|
||||
|
||||
|
||||
class TestForgotPasswordResetPayload:
|
||||
def test_valid_passwords(self) -> None:
|
||||
p = ForgotPasswordResetPayload(token="tok", new_password="Valid1234", password_confirm="Valid1234")
|
||||
assert p.new_password == "Valid1234"
|
||||
|
||||
def test_weak_password_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError, match="Password must contain"):
|
||||
ForgotPasswordResetPayload(token="tok", new_password="short", password_confirm="short")
|
||||
|
||||
def test_letters_only_password_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError, match="Password must contain"):
|
||||
ForgotPasswordResetPayload(token="tok", new_password="abcdefghi", password_confirm="abcdefghi")
|
||||
|
||||
def test_digits_only_password_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError, match="Password must contain"):
|
||||
ForgotPasswordResetPayload(token="tok", new_password="123456789", password_confirm="123456789")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# login.py models
|
||||
# ---------------------------------------------------------------------------
|
||||
from controllers.web.login import EmailCodeLoginSendPayload, EmailCodeLoginVerifyPayload, LoginPayload
|
||||
|
||||
|
||||
class TestLoginPayload:
|
||||
def test_valid(self) -> None:
|
||||
p = LoginPayload(email="a@b.com", password="Valid1234")
|
||||
assert p.email == "a@b.com"
|
||||
|
||||
def test_invalid_email(self) -> None:
|
||||
with pytest.raises(ValidationError, match="not a valid email"):
|
||||
LoginPayload(email="bad", password="Valid1234")
|
||||
|
||||
def test_weak_password(self) -> None:
|
||||
with pytest.raises(ValidationError, match="Password must contain"):
|
||||
LoginPayload(email="a@b.com", password="weak")
|
||||
|
||||
|
||||
class TestEmailCodeLoginSendPayload:
|
||||
def test_valid(self) -> None:
|
||||
p = EmailCodeLoginSendPayload(email="a@b.com")
|
||||
assert p.language is None
|
||||
|
||||
def test_with_language(self) -> None:
|
||||
p = EmailCodeLoginSendPayload(email="a@b.com", language="zh-Hans")
|
||||
assert p.language == "zh-Hans"
|
||||
|
||||
|
||||
class TestEmailCodeLoginVerifyPayload:
|
||||
def test_valid(self) -> None:
|
||||
p = EmailCodeLoginVerifyPayload(email="a@b.com", code="1234", token="tok")
|
||||
assert p.code == "1234"
|
||||
|
||||
def test_empty_token_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
EmailCodeLoginVerifyPayload(email="a@b.com", code="1234", token="")
|
||||
147
api/tests/unit_tests/controllers/web/test_remote_files.py
Normal file
147
api/tests/unit_tests/controllers/web/test_remote_files.py
Normal file
@ -0,0 +1,147 @@
|
||||
"""Unit tests for controllers.web.remote_files endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.common.errors import FileTooLargeError, RemoteFileUploadError
|
||||
from controllers.web.remote_files import RemoteFileInfoApi, RemoteFileUploadApi
|
||||
|
||||
|
||||
def _app_model() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="app-1")
|
||||
|
||||
|
||||
def _end_user() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="eu-1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RemoteFileInfoApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestRemoteFileInfoApi:
|
||||
@patch("controllers.web.remote_files.ssrf_proxy")
|
||||
def test_head_success(self, mock_proxy: MagicMock, app: Flask) -> None:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
|
||||
mock_proxy.head.return_value = mock_resp
|
||||
|
||||
with app.test_request_context("/remote-files/https%3A%2F%2Fexample.com%2Ffile.pdf"):
|
||||
result = RemoteFileInfoApi().get(_app_model(), _end_user(), "https%3A%2F%2Fexample.com%2Ffile.pdf")
|
||||
|
||||
assert result["file_type"] == "application/pdf"
|
||||
assert result["file_length"] == 1024
|
||||
|
||||
@patch("controllers.web.remote_files.ssrf_proxy")
|
||||
def test_fallback_to_get(self, mock_proxy: MagicMock, app: Flask) -> None:
|
||||
head_resp = MagicMock()
|
||||
head_resp.status_code = 405 # Method not allowed
|
||||
get_resp = MagicMock()
|
||||
get_resp.status_code = 200
|
||||
get_resp.headers = {"Content-Type": "text/plain", "Content-Length": "42"}
|
||||
get_resp.raise_for_status = MagicMock()
|
||||
mock_proxy.head.return_value = head_resp
|
||||
mock_proxy.get.return_value = get_resp
|
||||
|
||||
with app.test_request_context("/remote-files/https%3A%2F%2Fexample.com%2Ffile.txt"):
|
||||
result = RemoteFileInfoApi().get(_app_model(), _end_user(), "https%3A%2F%2Fexample.com%2Ffile.txt")
|
||||
|
||||
assert result["file_type"] == "text/plain"
|
||||
mock_proxy.get.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RemoteFileUploadApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestRemoteFileUploadApi:
|
||||
@patch("controllers.web.remote_files.file_helpers.get_signed_file_url", return_value="https://signed-url")
|
||||
@patch("controllers.web.remote_files.FileService")
|
||||
@patch("controllers.web.remote_files.helpers.guess_file_info_from_response")
|
||||
@patch("controllers.web.remote_files.ssrf_proxy")
|
||||
@patch("controllers.web.remote_files.web_ns")
|
||||
@patch("controllers.web.remote_files.db")
|
||||
def test_upload_success(
|
||||
self,
|
||||
mock_db: MagicMock,
|
||||
mock_ns: MagicMock,
|
||||
mock_proxy: MagicMock,
|
||||
mock_guess: MagicMock,
|
||||
mock_file_svc_cls: MagicMock,
|
||||
mock_signed: MagicMock,
|
||||
app: Flask,
|
||||
) -> None:
|
||||
mock_db.engine = "engine"
|
||||
mock_ns.payload = {"url": "https://example.com/file.pdf"}
|
||||
head_resp = MagicMock()
|
||||
head_resp.status_code = 200
|
||||
head_resp.content = b"pdf-content"
|
||||
head_resp.request.method = "HEAD"
|
||||
mock_proxy.head.return_value = head_resp
|
||||
get_resp = MagicMock()
|
||||
get_resp.content = b"pdf-content"
|
||||
mock_proxy.get.return_value = get_resp
|
||||
|
||||
mock_guess.return_value = SimpleNamespace(
|
||||
filename="file.pdf", extension="pdf", mimetype="application/pdf", size=100
|
||||
)
|
||||
mock_file_svc_cls.is_file_size_within_limit.return_value = True
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
upload_file = SimpleNamespace(
|
||||
id="f-1",
|
||||
name="file.pdf",
|
||||
size=100,
|
||||
extension="pdf",
|
||||
mime_type="application/pdf",
|
||||
created_by="eu-1",
|
||||
created_at=datetime(2024, 1, 1),
|
||||
)
|
||||
mock_file_svc_cls.return_value.upload_file.return_value = upload_file
|
||||
|
||||
with app.test_request_context("/remote-files/upload", method="POST"):
|
||||
result, status = RemoteFileUploadApi().post(_app_model(), _end_user())
|
||||
|
||||
assert status == 201
|
||||
assert result["id"] == "f-1"
|
||||
|
||||
@patch("controllers.web.remote_files.FileService.is_file_size_within_limit", return_value=False)
|
||||
@patch("controllers.web.remote_files.helpers.guess_file_info_from_response")
|
||||
@patch("controllers.web.remote_files.ssrf_proxy")
|
||||
@patch("controllers.web.remote_files.web_ns")
|
||||
def test_file_too_large(
|
||||
self,
|
||||
mock_ns: MagicMock,
|
||||
mock_proxy: MagicMock,
|
||||
mock_guess: MagicMock,
|
||||
mock_size_check: MagicMock,
|
||||
app: Flask,
|
||||
) -> None:
|
||||
mock_ns.payload = {"url": "https://example.com/big.zip"}
|
||||
head_resp = MagicMock()
|
||||
head_resp.status_code = 200
|
||||
mock_proxy.head.return_value = head_resp
|
||||
mock_guess.return_value = SimpleNamespace(
|
||||
filename="big.zip", extension="zip", mimetype="application/zip", size=999999999
|
||||
)
|
||||
|
||||
with app.test_request_context("/remote-files/upload", method="POST"):
|
||||
with pytest.raises(FileTooLargeError):
|
||||
RemoteFileUploadApi().post(_app_model(), _end_user())
|
||||
|
||||
@patch("controllers.web.remote_files.ssrf_proxy")
|
||||
@patch("controllers.web.remote_files.web_ns")
|
||||
def test_fetch_failure_raises(self, mock_ns: MagicMock, mock_proxy: MagicMock, app: Flask) -> None:
|
||||
import httpx
|
||||
|
||||
mock_ns.payload = {"url": "https://example.com/bad"}
|
||||
mock_proxy.head.side_effect = httpx.RequestError("connection failed")
|
||||
|
||||
with app.test_request_context("/remote-files/upload", method="POST"):
|
||||
with pytest.raises(RemoteFileUploadError):
|
||||
RemoteFileUploadApi().post(_app_model(), _end_user())
|
||||
97
api/tests/unit_tests/controllers/web/test_saved_message.py
Normal file
97
api/tests/unit_tests/controllers/web/test_saved_message.py
Normal file
@ -0,0 +1,97 @@
|
||||
"""Unit tests for controllers.web.saved_message endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.web.error import NotCompletionAppError
|
||||
from controllers.web.saved_message import SavedMessageApi, SavedMessageListApi
|
||||
from services.errors.message import MessageNotExistsError
|
||||
|
||||
|
||||
def _completion_app() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="app-1", mode="completion")
|
||||
|
||||
|
||||
def _chat_app() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="app-1", mode="chat")
|
||||
|
||||
|
||||
def _end_user() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="eu-1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SavedMessageListApi (GET)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestSavedMessageListApiGet:
|
||||
def test_non_completion_mode_raises(self, app: Flask) -> None:
|
||||
with app.test_request_context("/saved-messages"):
|
||||
with pytest.raises(NotCompletionAppError):
|
||||
SavedMessageListApi().get(_chat_app(), _end_user())
|
||||
|
||||
@patch("controllers.web.saved_message.SavedMessageService.pagination_by_last_id")
|
||||
def test_happy_path(self, mock_paginate: MagicMock, app: Flask) -> None:
|
||||
mock_paginate.return_value = SimpleNamespace(limit=20, has_more=False, data=[])
|
||||
|
||||
with app.test_request_context("/saved-messages?limit=20"):
|
||||
result = SavedMessageListApi().get(_completion_app(), _end_user())
|
||||
|
||||
assert result["limit"] == 20
|
||||
assert result["has_more"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SavedMessageListApi (POST)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestSavedMessageListApiPost:
|
||||
def test_non_completion_mode_raises(self, app: Flask) -> None:
|
||||
with app.test_request_context("/saved-messages", method="POST"):
|
||||
with pytest.raises(NotCompletionAppError):
|
||||
SavedMessageListApi().post(_chat_app(), _end_user())
|
||||
|
||||
@patch("controllers.web.saved_message.SavedMessageService.save")
|
||||
@patch("controllers.web.saved_message.web_ns")
|
||||
def test_save_success(self, mock_ns: MagicMock, mock_save: MagicMock, app: Flask) -> None:
|
||||
msg_id = str(uuid4())
|
||||
mock_ns.payload = {"message_id": msg_id}
|
||||
|
||||
with app.test_request_context("/saved-messages", method="POST"):
|
||||
result = SavedMessageListApi().post(_completion_app(), _end_user())
|
||||
|
||||
assert result["result"] == "success"
|
||||
|
||||
@patch("controllers.web.saved_message.SavedMessageService.save", side_effect=MessageNotExistsError())
|
||||
@patch("controllers.web.saved_message.web_ns")
|
||||
def test_save_not_found(self, mock_ns: MagicMock, mock_save: MagicMock, app: Flask) -> None:
|
||||
mock_ns.payload = {"message_id": str(uuid4())}
|
||||
|
||||
with app.test_request_context("/saved-messages", method="POST"):
|
||||
with pytest.raises(NotFound, match="Message Not Exists"):
|
||||
SavedMessageListApi().post(_completion_app(), _end_user())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SavedMessageApi (DELETE)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestSavedMessageApi:
|
||||
def test_non_completion_mode_raises(self, app: Flask) -> None:
|
||||
msg_id = uuid4()
|
||||
with app.test_request_context(f"/saved-messages/{msg_id}", method="DELETE"):
|
||||
with pytest.raises(NotCompletionAppError):
|
||||
SavedMessageApi().delete(_chat_app(), _end_user(), msg_id)
|
||||
|
||||
@patch("controllers.web.saved_message.SavedMessageService.delete")
|
||||
def test_delete_success(self, mock_delete: MagicMock, app: Flask) -> None:
|
||||
msg_id = uuid4()
|
||||
with app.test_request_context(f"/saved-messages/{msg_id}", method="DELETE"):
|
||||
result, status = SavedMessageApi().delete(_completion_app(), _end_user(), msg_id)
|
||||
|
||||
assert status == 204
|
||||
assert result["result"] == "success"
|
||||
126
api/tests/unit_tests/controllers/web/test_site.py
Normal file
126
api/tests/unit_tests/controllers/web/test_site.py
Normal file
@ -0,0 +1,126 @@
|
||||
"""Unit tests for controllers.web.site endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from controllers.web.site import AppSiteApi, AppSiteInfo
|
||||
|
||||
|
||||
def _tenant(*, status: str = "normal") -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id="tenant-1",
|
||||
status=status,
|
||||
plan="basic",
|
||||
custom_config_dict={"remove_webapp_brand": False, "replace_webapp_logo": False},
|
||||
)
|
||||
|
||||
|
||||
def _site() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
title="Site",
|
||||
icon_type="emoji",
|
||||
icon="robot",
|
||||
icon_background="#fff",
|
||||
description="desc",
|
||||
default_language="en",
|
||||
chat_color_theme="light",
|
||||
chat_color_theme_inverted=False,
|
||||
copyright=None,
|
||||
privacy_policy=None,
|
||||
custom_disclaimer=None,
|
||||
prompt_public=False,
|
||||
show_workflow_steps=True,
|
||||
use_icon_as_answer_icon=False,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AppSiteApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAppSiteApi:
|
||||
@patch("controllers.web.site.FeatureService.get_features")
|
||||
@patch("controllers.web.site.db")
|
||||
def test_happy_path(self, mock_db: MagicMock, mock_features: MagicMock, app: Flask) -> None:
|
||||
app.config["RESTX_MASK_HEADER"] = "X-Fields"
|
||||
mock_features.return_value = SimpleNamespace(can_replace_logo=False)
|
||||
site_obj = _site()
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = site_obj
|
||||
tenant = _tenant()
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant, enable_site=True)
|
||||
end_user = SimpleNamespace(id="eu-1")
|
||||
|
||||
with app.test_request_context("/site"):
|
||||
result = AppSiteApi().get(app_model, end_user)
|
||||
|
||||
# marshal_with serializes AppSiteInfo to a dict
|
||||
assert result["app_id"] == "app-1"
|
||||
assert result["plan"] == "basic"
|
||||
assert result["enable_site"] is True
|
||||
|
||||
@patch("controllers.web.site.db")
|
||||
def test_missing_site_raises_forbidden(self, mock_db: MagicMock, app: Flask) -> None:
|
||||
app.config["RESTX_MASK_HEADER"] = "X-Fields"
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = None
|
||||
tenant = _tenant()
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant)
|
||||
end_user = SimpleNamespace(id="eu-1")
|
||||
|
||||
with app.test_request_context("/site"):
|
||||
with pytest.raises(Forbidden):
|
||||
AppSiteApi().get(app_model, end_user)
|
||||
|
||||
@patch("controllers.web.site.db")
|
||||
def test_archived_tenant_raises_forbidden(self, mock_db: MagicMock, app: Flask) -> None:
|
||||
app.config["RESTX_MASK_HEADER"] = "X-Fields"
|
||||
from models.account import TenantStatus
|
||||
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = _site()
|
||||
tenant = SimpleNamespace(
|
||||
id="tenant-1",
|
||||
status=TenantStatus.ARCHIVE,
|
||||
plan="basic",
|
||||
custom_config_dict={},
|
||||
)
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant)
|
||||
end_user = SimpleNamespace(id="eu-1")
|
||||
|
||||
with app.test_request_context("/site"):
|
||||
with pytest.raises(Forbidden):
|
||||
AppSiteApi().get(app_model, end_user)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AppSiteInfo
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAppSiteInfo:
|
||||
def test_basic_fields(self) -> None:
|
||||
tenant = _tenant()
|
||||
site_obj = _site()
|
||||
info = AppSiteInfo(tenant, SimpleNamespace(id="app-1", enable_site=True), site_obj, "eu-1", False)
|
||||
|
||||
assert info.app_id == "app-1"
|
||||
assert info.end_user_id == "eu-1"
|
||||
assert info.enable_site is True
|
||||
assert info.plan == "basic"
|
||||
assert info.can_replace_logo is False
|
||||
assert info.model_config is None
|
||||
|
||||
@patch("controllers.web.site.dify_config", SimpleNamespace(FILES_URL="https://files.example.com"))
|
||||
def test_can_replace_logo_sets_custom_config(self) -> None:
|
||||
tenant = SimpleNamespace(
|
||||
id="tenant-1",
|
||||
plan="pro",
|
||||
custom_config_dict={"remove_webapp_brand": True, "replace_webapp_logo": True},
|
||||
)
|
||||
site_obj = _site()
|
||||
info = AppSiteInfo(tenant, SimpleNamespace(id="app-1", enable_site=True), site_obj, "eu-1", True)
|
||||
|
||||
assert info.can_replace_logo is True
|
||||
assert info.custom_config["remove_webapp_brand"] is True
|
||||
assert "webapp-logo" in info.custom_config["replace_webapp_logo"]
|
||||
@ -5,7 +5,8 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.web.login import EmailCodeLoginApi, EmailCodeLoginSendEmailApi
|
||||
import services.errors.account
|
||||
from controllers.web.login import EmailCodeLoginApi, EmailCodeLoginSendEmailApi, LoginApi, LoginStatusApi, LogoutApi
|
||||
|
||||
|
||||
def encode_code(code: str) -> str:
|
||||
@ -89,3 +90,114 @@ class TestEmailCodeLoginApi:
|
||||
mock_revoke_token.assert_called_once_with("token-123")
|
||||
mock_login.assert_called_once()
|
||||
mock_reset_login_rate.assert_called_once_with("user@example.com")
|
||||
|
||||
|
||||
class TestLoginApi:
|
||||
@patch("controllers.web.login.WebAppAuthService.login", return_value="access-tok")
|
||||
@patch("controllers.web.login.WebAppAuthService.authenticate")
|
||||
def test_login_success(self, mock_auth: MagicMock, mock_login: MagicMock, app: Flask) -> None:
|
||||
mock_auth.return_value = MagicMock()
|
||||
|
||||
with app.test_request_context(
|
||||
"/web/login",
|
||||
method="POST",
|
||||
json={"email": "user@example.com", "password": base64.b64encode(b"Valid1234").decode()},
|
||||
):
|
||||
response = LoginApi().post()
|
||||
|
||||
assert response.get_json()["data"]["access_token"] == "access-tok"
|
||||
mock_auth.assert_called_once()
|
||||
|
||||
@patch(
|
||||
"controllers.web.login.WebAppAuthService.authenticate",
|
||||
side_effect=services.errors.account.AccountLoginError(),
|
||||
)
|
||||
def test_login_banned_account(self, mock_auth: MagicMock, app: Flask) -> None:
|
||||
from controllers.console.error import AccountBannedError
|
||||
|
||||
with app.test_request_context(
|
||||
"/web/login",
|
||||
method="POST",
|
||||
json={"email": "user@example.com", "password": base64.b64encode(b"Valid1234").decode()},
|
||||
):
|
||||
with pytest.raises(AccountBannedError):
|
||||
LoginApi().post()
|
||||
|
||||
@patch(
|
||||
"controllers.web.login.WebAppAuthService.authenticate",
|
||||
side_effect=services.errors.account.AccountPasswordError(),
|
||||
)
|
||||
def test_login_wrong_password(self, mock_auth: MagicMock, app: Flask) -> None:
|
||||
from controllers.console.auth.error import AuthenticationFailedError
|
||||
|
||||
with app.test_request_context(
|
||||
"/web/login",
|
||||
method="POST",
|
||||
json={"email": "user@example.com", "password": base64.b64encode(b"Valid1234").decode()},
|
||||
):
|
||||
with pytest.raises(AuthenticationFailedError):
|
||||
LoginApi().post()
|
||||
|
||||
|
||||
class TestLoginStatusApi:
|
||||
@patch("controllers.web.login.extract_webapp_access_token", return_value=None)
|
||||
def test_no_app_code_returns_logged_in_false(self, mock_extract: MagicMock, app: Flask) -> None:
|
||||
with app.test_request_context("/web/login/status"):
|
||||
result = LoginStatusApi().get()
|
||||
|
||||
assert result["logged_in"] is False
|
||||
assert result["app_logged_in"] is False
|
||||
|
||||
@patch("controllers.web.login.decode_jwt_token")
|
||||
@patch("controllers.web.login.PassportService")
|
||||
@patch("controllers.web.login.WebAppAuthService.is_app_require_permission_check", return_value=False)
|
||||
@patch("controllers.web.login.AppService.get_app_id_by_code", return_value="app-1")
|
||||
@patch("controllers.web.login.extract_webapp_access_token", return_value="tok")
|
||||
def test_public_app_user_logged_in(
|
||||
self,
|
||||
mock_extract: MagicMock,
|
||||
mock_app_id: MagicMock,
|
||||
mock_perm: MagicMock,
|
||||
mock_passport: MagicMock,
|
||||
mock_decode: MagicMock,
|
||||
app: Flask,
|
||||
) -> None:
|
||||
mock_decode.return_value = (MagicMock(), MagicMock())
|
||||
|
||||
with app.test_request_context("/web/login/status?app_code=code1"):
|
||||
result = LoginStatusApi().get()
|
||||
|
||||
assert result["logged_in"] is True
|
||||
assert result["app_logged_in"] is True
|
||||
|
||||
@patch("controllers.web.login.decode_jwt_token", side_effect=Exception("bad"))
|
||||
@patch("controllers.web.login.PassportService")
|
||||
@patch("controllers.web.login.WebAppAuthService.is_app_require_permission_check", return_value=True)
|
||||
@patch("controllers.web.login.AppService.get_app_id_by_code", return_value="app-1")
|
||||
@patch("controllers.web.login.extract_webapp_access_token", return_value="tok")
|
||||
def test_private_app_passport_fails(
|
||||
self,
|
||||
mock_extract: MagicMock,
|
||||
mock_app_id: MagicMock,
|
||||
mock_perm: MagicMock,
|
||||
mock_passport_cls: MagicMock,
|
||||
mock_decode: MagicMock,
|
||||
app: Flask,
|
||||
) -> None:
|
||||
mock_passport_cls.return_value.verify.side_effect = Exception("bad")
|
||||
|
||||
with app.test_request_context("/web/login/status?app_code=code1"):
|
||||
result = LoginStatusApi().get()
|
||||
|
||||
assert result["logged_in"] is False
|
||||
assert result["app_logged_in"] is False
|
||||
|
||||
|
||||
class TestLogoutApi:
|
||||
@patch("controllers.web.login.clear_webapp_access_token_from_cookie")
|
||||
def test_logout_success(self, mock_clear: MagicMock, app: Flask) -> None:
|
||||
with app.test_request_context("/web/logout", method="POST"):
|
||||
response = LogoutApi().post()
|
||||
|
||||
assert response.get_json() == {"result": "success"}
|
||||
mock_clear.assert_called_once()
|
||||
|
||||
192
api/tests/unit_tests/controllers/web/test_web_passport.py
Normal file
192
api/tests/unit_tests/controllers/web/test_web_passport.py
Normal file
@ -0,0 +1,192 @@
|
||||
"""Unit tests for controllers.web.passport — token issuance and enterprise auth exchange."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import NotFound, Unauthorized
|
||||
|
||||
from controllers.web.error import WebAppAuthRequiredError
|
||||
from controllers.web.passport import (
|
||||
PassportResource,
|
||||
decode_enterprise_webapp_user_id,
|
||||
exchange_token_for_existing_web_user,
|
||||
generate_session_id,
|
||||
)
|
||||
from services.webapp_auth_service import WebAppAuthType
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# decode_enterprise_webapp_user_id
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestDecodeEnterpriseWebappUserId:
|
||||
def test_none_token_returns_none(self) -> None:
|
||||
assert decode_enterprise_webapp_user_id(None) is None
|
||||
|
||||
@patch("controllers.web.passport.PassportService")
|
||||
def test_valid_token_returns_decoded(self, mock_passport_cls: MagicMock) -> None:
|
||||
mock_passport_cls.return_value.verify.return_value = {
|
||||
"token_source": "webapp_login_token",
|
||||
"user_id": "u1",
|
||||
}
|
||||
result = decode_enterprise_webapp_user_id("valid-jwt")
|
||||
assert result["user_id"] == "u1"
|
||||
|
||||
@patch("controllers.web.passport.PassportService")
|
||||
def test_wrong_source_raises_unauthorized(self, mock_passport_cls: MagicMock) -> None:
|
||||
mock_passport_cls.return_value.verify.return_value = {
|
||||
"token_source": "other_source",
|
||||
}
|
||||
with pytest.raises(Unauthorized, match="Expected 'webapp_login_token'"):
|
||||
decode_enterprise_webapp_user_id("bad-jwt")
|
||||
|
||||
@patch("controllers.web.passport.PassportService")
|
||||
def test_missing_source_raises_unauthorized(self, mock_passport_cls: MagicMock) -> None:
|
||||
mock_passport_cls.return_value.verify.return_value = {}
|
||||
with pytest.raises(Unauthorized, match="Expected 'webapp_login_token'"):
|
||||
decode_enterprise_webapp_user_id("no-source-jwt")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate_session_id
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestGenerateSessionId:
|
||||
@patch("controllers.web.passport.db")
|
||||
def test_returns_unique_session_id(self, mock_db: MagicMock) -> None:
|
||||
mock_db.session.scalar.return_value = 0
|
||||
sid = generate_session_id()
|
||||
assert isinstance(sid, str)
|
||||
assert len(sid) == 36 # UUID format
|
||||
|
||||
@patch("controllers.web.passport.db")
|
||||
def test_retries_on_collision(self, mock_db: MagicMock) -> None:
|
||||
# First call returns count=1 (collision), second returns 0
|
||||
mock_db.session.scalar.side_effect = [1, 0]
|
||||
sid = generate_session_id()
|
||||
assert isinstance(sid, str)
|
||||
assert mock_db.session.scalar.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# exchange_token_for_existing_web_user
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestExchangeTokenForExistingWebUser:
|
||||
@patch("controllers.web.passport.PassportService")
|
||||
@patch("controllers.web.passport.db")
|
||||
def test_external_auth_type_mismatch_raises(self, mock_db: MagicMock, mock_passport_cls: MagicMock) -> None:
|
||||
site = SimpleNamespace(code="code1", app_id="app-1")
|
||||
app_model = SimpleNamespace(id="app-1", status="normal", enable_site=True, tenant_id="t1")
|
||||
mock_db.session.scalar.side_effect = [site, app_model]
|
||||
|
||||
decoded = {"user_id": "u1", "auth_type": "internal"} # mismatch: expected "external"
|
||||
with pytest.raises(WebAppAuthRequiredError, match="external"):
|
||||
exchange_token_for_existing_web_user(
|
||||
app_code="code1", enterprise_user_decoded=decoded, auth_type=WebAppAuthType.EXTERNAL
|
||||
)
|
||||
|
||||
@patch("controllers.web.passport.PassportService")
|
||||
@patch("controllers.web.passport.db")
|
||||
def test_internal_auth_type_mismatch_raises(self, mock_db: MagicMock, mock_passport_cls: MagicMock) -> None:
|
||||
site = SimpleNamespace(code="code1", app_id="app-1")
|
||||
app_model = SimpleNamespace(id="app-1", status="normal", enable_site=True, tenant_id="t1")
|
||||
mock_db.session.scalar.side_effect = [site, app_model]
|
||||
|
||||
decoded = {"user_id": "u1", "auth_type": "external"} # mismatch: expected "internal"
|
||||
with pytest.raises(WebAppAuthRequiredError, match="internal"):
|
||||
exchange_token_for_existing_web_user(
|
||||
app_code="code1", enterprise_user_decoded=decoded, auth_type=WebAppAuthType.INTERNAL
|
||||
)
|
||||
|
||||
@patch("controllers.web.passport.PassportService")
|
||||
@patch("controllers.web.passport.db")
|
||||
def test_site_not_found_raises(self, mock_db: MagicMock, mock_passport_cls: MagicMock) -> None:
|
||||
mock_db.session.scalar.return_value = None
|
||||
decoded = {"user_id": "u1", "auth_type": "external"}
|
||||
with pytest.raises(NotFound):
|
||||
exchange_token_for_existing_web_user(
|
||||
app_code="code1", enterprise_user_decoded=decoded, auth_type=WebAppAuthType.EXTERNAL
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PassportResource.get
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestPassportResource:
|
||||
@patch("controllers.web.passport.FeatureService.get_system_features")
|
||||
def test_missing_app_code_raises_unauthorized(self, mock_features: MagicMock, app: Flask) -> None:
|
||||
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
|
||||
with app.test_request_context("/passport"):
|
||||
with pytest.raises(Unauthorized, match="X-App-Code"):
|
||||
PassportResource().get()
|
||||
|
||||
@patch("controllers.web.passport.PassportService")
|
||||
@patch("controllers.web.passport.generate_session_id", return_value="new-sess-id")
|
||||
@patch("controllers.web.passport.db")
|
||||
@patch("controllers.web.passport.FeatureService.get_system_features")
|
||||
def test_creates_new_end_user_when_no_user_id(
|
||||
self,
|
||||
mock_features: MagicMock,
|
||||
mock_db: MagicMock,
|
||||
mock_gen_session: MagicMock,
|
||||
mock_passport_cls: MagicMock,
|
||||
app: Flask,
|
||||
) -> None:
|
||||
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
|
||||
site = SimpleNamespace(app_id="app-1", code="code1")
|
||||
app_model = SimpleNamespace(id="app-1", status="normal", enable_site=True, tenant_id="t1")
|
||||
mock_db.session.scalar.side_effect = [site, app_model]
|
||||
mock_passport_cls.return_value.issue.return_value = "issued-token"
|
||||
|
||||
with app.test_request_context("/passport", headers={"X-App-Code": "code1"}):
|
||||
response = PassportResource().get()
|
||||
|
||||
assert response.get_json()["access_token"] == "issued-token"
|
||||
mock_db.session.add.assert_called_once()
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
@patch("controllers.web.passport.PassportService")
|
||||
@patch("controllers.web.passport.db")
|
||||
@patch("controllers.web.passport.FeatureService.get_system_features")
|
||||
def test_reuses_existing_end_user_when_user_id_provided(
|
||||
self,
|
||||
mock_features: MagicMock,
|
||||
mock_db: MagicMock,
|
||||
mock_passport_cls: MagicMock,
|
||||
app: Flask,
|
||||
) -> None:
|
||||
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
|
||||
site = SimpleNamespace(app_id="app-1", code="code1")
|
||||
app_model = SimpleNamespace(id="app-1", status="normal", enable_site=True, tenant_id="t1")
|
||||
existing_user = SimpleNamespace(id="eu-1", session_id="sess-existing")
|
||||
mock_db.session.scalar.side_effect = [site, app_model, existing_user]
|
||||
mock_passport_cls.return_value.issue.return_value = "reused-token"
|
||||
|
||||
with app.test_request_context("/passport?user_id=sess-existing", headers={"X-App-Code": "code1"}):
|
||||
response = PassportResource().get()
|
||||
|
||||
assert response.get_json()["access_token"] == "reused-token"
|
||||
# Should not create a new end user
|
||||
mock_db.session.add.assert_not_called()
|
||||
|
||||
@patch("controllers.web.passport.db")
|
||||
@patch("controllers.web.passport.FeatureService.get_system_features")
|
||||
def test_site_not_found_raises(self, mock_features: MagicMock, mock_db: MagicMock, app: Flask) -> None:
|
||||
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
|
||||
mock_db.session.scalar.return_value = None
|
||||
with app.test_request_context("/passport", headers={"X-App-Code": "code1"}):
|
||||
with pytest.raises(NotFound):
|
||||
PassportResource().get()
|
||||
|
||||
@patch("controllers.web.passport.db")
|
||||
@patch("controllers.web.passport.FeatureService.get_system_features")
|
||||
def test_disabled_app_raises_not_found(self, mock_features: MagicMock, mock_db: MagicMock, app: Flask) -> None:
|
||||
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
|
||||
site = SimpleNamespace(app_id="app-1", code="code1")
|
||||
disabled_app = SimpleNamespace(id="app-1", status="normal", enable_site=False)
|
||||
mock_db.session.scalar.side_effect = [site, disabled_app]
|
||||
with app.test_request_context("/passport", headers={"X-App-Code": "code1"}):
|
||||
with pytest.raises(NotFound):
|
||||
PassportResource().get()
|
||||
95
api/tests/unit_tests/controllers/web/test_workflow.py
Normal file
95
api/tests/unit_tests/controllers/web/test_workflow.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""Unit tests for controllers.web.workflow endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.web.error import (
|
||||
NotWorkflowAppError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
)
|
||||
from controllers.web.workflow import WorkflowRunApi, WorkflowTaskStopApi
|
||||
from core.errors.error import ProviderTokenNotInitError, QuotaExceededError
|
||||
|
||||
|
||||
def _workflow_app() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="app-1", mode="workflow")
|
||||
|
||||
|
||||
def _chat_app() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="app-1", mode="chat")
|
||||
|
||||
|
||||
def _end_user() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="eu-1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WorkflowRunApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestWorkflowRunApi:
|
||||
def test_wrong_mode_raises(self, app: Flask) -> None:
|
||||
with app.test_request_context("/workflows/run", method="POST"):
|
||||
with pytest.raises(NotWorkflowAppError):
|
||||
WorkflowRunApi().post(_chat_app(), _end_user())
|
||||
|
||||
@patch("controllers.web.workflow.helper.compact_generate_response", return_value={"result": "ok"})
|
||||
@patch("controllers.web.workflow.AppGenerateService.generate")
|
||||
@patch("controllers.web.workflow.web_ns")
|
||||
def test_happy_path(self, mock_ns: MagicMock, mock_gen: MagicMock, mock_compact: MagicMock, app: Flask) -> None:
|
||||
mock_ns.payload = {"inputs": {"key": "val"}}
|
||||
mock_gen.return_value = "response"
|
||||
|
||||
with app.test_request_context("/workflows/run", method="POST"):
|
||||
result = WorkflowRunApi().post(_workflow_app(), _end_user())
|
||||
|
||||
assert result == {"result": "ok"}
|
||||
|
||||
@patch(
|
||||
"controllers.web.workflow.AppGenerateService.generate",
|
||||
side_effect=ProviderTokenNotInitError(description="not init"),
|
||||
)
|
||||
@patch("controllers.web.workflow.web_ns")
|
||||
def test_provider_not_init(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
|
||||
mock_ns.payload = {"inputs": {}}
|
||||
|
||||
with app.test_request_context("/workflows/run", method="POST"):
|
||||
with pytest.raises(ProviderNotInitializeError):
|
||||
WorkflowRunApi().post(_workflow_app(), _end_user())
|
||||
|
||||
@patch(
|
||||
"controllers.web.workflow.AppGenerateService.generate",
|
||||
side_effect=QuotaExceededError(),
|
||||
)
|
||||
@patch("controllers.web.workflow.web_ns")
|
||||
def test_quota_exceeded(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
|
||||
mock_ns.payload = {"inputs": {}}
|
||||
|
||||
with app.test_request_context("/workflows/run", method="POST"):
|
||||
with pytest.raises(ProviderQuotaExceededError):
|
||||
WorkflowRunApi().post(_workflow_app(), _end_user())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WorkflowTaskStopApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestWorkflowTaskStopApi:
|
||||
def test_wrong_mode_raises(self, app: Flask) -> None:
|
||||
with app.test_request_context("/workflows/tasks/task-1/stop", method="POST"):
|
||||
with pytest.raises(NotWorkflowAppError):
|
||||
WorkflowTaskStopApi().post(_chat_app(), _end_user(), "task-1")
|
||||
|
||||
@patch("controllers.web.workflow.GraphEngineManager.send_stop_command")
|
||||
@patch("controllers.web.workflow.AppQueueManager.set_stop_flag_no_user_check")
|
||||
def test_stop_calls_both_mechanisms(self, mock_legacy: MagicMock, mock_graph: MagicMock, app: Flask) -> None:
|
||||
with app.test_request_context("/workflows/tasks/task-1/stop", method="POST"):
|
||||
result = WorkflowTaskStopApi().post(_workflow_app(), _end_user(), "task-1")
|
||||
|
||||
assert result == {"result": "success"}
|
||||
mock_legacy.assert_called_once_with("task-1")
|
||||
mock_graph.assert_called_once_with("task-1")
|
||||
127
api/tests/unit_tests/controllers/web/test_workflow_events.py
Normal file
127
api/tests/unit_tests/controllers/web/test_workflow_events.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""Unit tests for controllers.web.workflow_events endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.web.error import NotFoundError
|
||||
from controllers.web.workflow_events import WorkflowEventsApi
|
||||
from models.enums import CreatorUserRole
|
||||
|
||||
|
||||
def _workflow_app() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow")
|
||||
|
||||
|
||||
def _end_user() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="eu-1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WorkflowEventsApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestWorkflowEventsApi:
|
||||
@patch("controllers.web.workflow_events.DifyAPIRepositoryFactory")
|
||||
@patch("controllers.web.workflow_events.db")
|
||||
def test_workflow_run_not_found(self, mock_db: MagicMock, mock_factory: MagicMock, app: Flask) -> None:
|
||||
mock_db.engine = "engine"
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_workflow_run_by_id_and_tenant_id.return_value = None
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_repo
|
||||
|
||||
with app.test_request_context("/workflow/run-1/events"):
|
||||
with pytest.raises(NotFoundError):
|
||||
WorkflowEventsApi().get(_workflow_app(), _end_user(), "run-1")
|
||||
|
||||
@patch("controllers.web.workflow_events.DifyAPIRepositoryFactory")
|
||||
@patch("controllers.web.workflow_events.db")
|
||||
def test_workflow_run_wrong_app(self, mock_db: MagicMock, mock_factory: MagicMock, app: Flask) -> None:
|
||||
mock_db.engine = "engine"
|
||||
run = SimpleNamespace(
|
||||
id="run-1",
|
||||
app_id="other-app",
|
||||
created_by_role=CreatorUserRole.END_USER,
|
||||
created_by="eu-1",
|
||||
finished_at=None,
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_workflow_run_by_id_and_tenant_id.return_value = run
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_repo
|
||||
|
||||
with app.test_request_context("/workflow/run-1/events"):
|
||||
with pytest.raises(NotFoundError):
|
||||
WorkflowEventsApi().get(_workflow_app(), _end_user(), "run-1")
|
||||
|
||||
@patch("controllers.web.workflow_events.DifyAPIRepositoryFactory")
|
||||
@patch("controllers.web.workflow_events.db")
|
||||
def test_workflow_run_not_created_by_end_user(
|
||||
self, mock_db: MagicMock, mock_factory: MagicMock, app: Flask
|
||||
) -> None:
|
||||
mock_db.engine = "engine"
|
||||
run = SimpleNamespace(
|
||||
id="run-1",
|
||||
app_id="app-1",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="eu-1",
|
||||
finished_at=None,
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_workflow_run_by_id_and_tenant_id.return_value = run
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_repo
|
||||
|
||||
with app.test_request_context("/workflow/run-1/events"):
|
||||
with pytest.raises(NotFoundError):
|
||||
WorkflowEventsApi().get(_workflow_app(), _end_user(), "run-1")
|
||||
|
||||
@patch("controllers.web.workflow_events.DifyAPIRepositoryFactory")
|
||||
@patch("controllers.web.workflow_events.db")
|
||||
def test_workflow_run_wrong_end_user(self, mock_db: MagicMock, mock_factory: MagicMock, app: Flask) -> None:
|
||||
mock_db.engine = "engine"
|
||||
run = SimpleNamespace(
|
||||
id="run-1",
|
||||
app_id="app-1",
|
||||
created_by_role=CreatorUserRole.END_USER,
|
||||
created_by="other-user",
|
||||
finished_at=None,
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_workflow_run_by_id_and_tenant_id.return_value = run
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_repo
|
||||
|
||||
with app.test_request_context("/workflow/run-1/events"):
|
||||
with pytest.raises(NotFoundError):
|
||||
WorkflowEventsApi().get(_workflow_app(), _end_user(), "run-1")
|
||||
|
||||
@patch("controllers.web.workflow_events.WorkflowResponseConverter")
|
||||
@patch("controllers.web.workflow_events.DifyAPIRepositoryFactory")
|
||||
@patch("controllers.web.workflow_events.db")
|
||||
def test_finished_run_returns_sse_response(
|
||||
self, mock_db: MagicMock, mock_factory: MagicMock, mock_converter: MagicMock, app: Flask
|
||||
) -> None:
|
||||
from datetime import datetime
|
||||
|
||||
mock_db.engine = "engine"
|
||||
run = SimpleNamespace(
|
||||
id="run-1",
|
||||
app_id="app-1",
|
||||
created_by_role=CreatorUserRole.END_USER,
|
||||
created_by="eu-1",
|
||||
finished_at=datetime(2024, 1, 1),
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_workflow_run_by_id_and_tenant_id.return_value = run
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_repo
|
||||
|
||||
finish_response = MagicMock()
|
||||
finish_response.model_dump.return_value = {"task_id": "run-1"}
|
||||
finish_response.event.value = "workflow_finished"
|
||||
mock_converter.workflow_run_result_to_finish_response.return_value = finish_response
|
||||
|
||||
with app.test_request_context("/workflow/run-1/events"):
|
||||
response = WorkflowEventsApi().get(_workflow_app(), _end_user(), "run-1")
|
||||
|
||||
assert response.mimetype == "text/event-stream"
|
||||
393
api/tests/unit_tests/controllers/web/test_wraps.py
Normal file
393
api/tests/unit_tests/controllers/web/test_wraps.py
Normal file
@ -0,0 +1,393 @@
|
||||
"""Unit tests for controllers.web.wraps — JWT auth decorator and validation helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import BadRequest, NotFound, Unauthorized
|
||||
|
||||
from controllers.web.error import WebAppAuthAccessDeniedError, WebAppAuthRequiredError
|
||||
from controllers.web.wraps import (
|
||||
_validate_user_accessibility,
|
||||
_validate_webapp_token,
|
||||
decode_jwt_token,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validate_webapp_token
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestValidateWebappToken:
|
||||
def test_enterprise_enabled_and_app_auth_requires_webapp_source(self) -> None:
|
||||
"""When both flags are true, a non-webapp source must raise."""
|
||||
decoded = {"token_source": "other"}
|
||||
with pytest.raises(WebAppAuthRequiredError):
|
||||
_validate_webapp_token(decoded, app_web_auth_enabled=True, system_webapp_auth_enabled=True)
|
||||
|
||||
def test_enterprise_enabled_and_app_auth_accepts_webapp_source(self) -> None:
|
||||
decoded = {"token_source": "webapp"}
|
||||
_validate_webapp_token(decoded, app_web_auth_enabled=True, system_webapp_auth_enabled=True)
|
||||
|
||||
def test_enterprise_enabled_and_app_auth_missing_source_raises(self) -> None:
|
||||
decoded = {}
|
||||
with pytest.raises(WebAppAuthRequiredError):
|
||||
_validate_webapp_token(decoded, app_web_auth_enabled=True, system_webapp_auth_enabled=True)
|
||||
|
||||
def test_public_app_rejects_webapp_source(self) -> None:
|
||||
"""When auth is not required, a webapp-sourced token must be rejected."""
|
||||
decoded = {"token_source": "webapp"}
|
||||
with pytest.raises(Unauthorized):
|
||||
_validate_webapp_token(decoded, app_web_auth_enabled=False, system_webapp_auth_enabled=False)
|
||||
|
||||
def test_public_app_accepts_non_webapp_source(self) -> None:
|
||||
decoded = {"token_source": "other"}
|
||||
_validate_webapp_token(decoded, app_web_auth_enabled=False, system_webapp_auth_enabled=False)
|
||||
|
||||
def test_public_app_accepts_no_source(self) -> None:
|
||||
decoded = {}
|
||||
_validate_webapp_token(decoded, app_web_auth_enabled=False, system_webapp_auth_enabled=False)
|
||||
|
||||
def test_system_enabled_but_app_public(self) -> None:
|
||||
"""system_webapp_auth_enabled=True but app is public — webapp source rejected."""
|
||||
decoded = {"token_source": "webapp"}
|
||||
with pytest.raises(Unauthorized):
|
||||
_validate_webapp_token(decoded, app_web_auth_enabled=False, system_webapp_auth_enabled=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validate_user_accessibility
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestValidateUserAccessibility:
|
||||
def test_skips_when_auth_disabled(self) -> None:
|
||||
"""No checks when system or app auth is disabled."""
|
||||
_validate_user_accessibility(
|
||||
decoded={},
|
||||
app_code="code",
|
||||
app_web_auth_enabled=False,
|
||||
system_webapp_auth_enabled=False,
|
||||
webapp_settings=None,
|
||||
)
|
||||
|
||||
def test_missing_user_id_raises(self) -> None:
|
||||
decoded = {}
|
||||
with pytest.raises(WebAppAuthRequiredError):
|
||||
_validate_user_accessibility(
|
||||
decoded=decoded,
|
||||
app_code="code",
|
||||
app_web_auth_enabled=True,
|
||||
system_webapp_auth_enabled=True,
|
||||
webapp_settings=SimpleNamespace(access_mode="internal"),
|
||||
)
|
||||
|
||||
def test_missing_webapp_settings_raises(self) -> None:
|
||||
decoded = {"user_id": "u1"}
|
||||
with pytest.raises(WebAppAuthRequiredError, match="settings not found"):
|
||||
_validate_user_accessibility(
|
||||
decoded=decoded,
|
||||
app_code="code",
|
||||
app_web_auth_enabled=True,
|
||||
system_webapp_auth_enabled=True,
|
||||
webapp_settings=None,
|
||||
)
|
||||
|
||||
def test_missing_auth_type_raises(self) -> None:
|
||||
decoded = {"user_id": "u1", "granted_at": 1}
|
||||
settings = SimpleNamespace(access_mode="public")
|
||||
with pytest.raises(WebAppAuthAccessDeniedError, match="auth_type"):
|
||||
_validate_user_accessibility(
|
||||
decoded=decoded,
|
||||
app_code="code",
|
||||
app_web_auth_enabled=True,
|
||||
system_webapp_auth_enabled=True,
|
||||
webapp_settings=settings,
|
||||
)
|
||||
|
||||
def test_missing_granted_at_raises(self) -> None:
|
||||
decoded = {"user_id": "u1", "auth_type": "external"}
|
||||
settings = SimpleNamespace(access_mode="public")
|
||||
with pytest.raises(WebAppAuthAccessDeniedError, match="granted_at"):
|
||||
_validate_user_accessibility(
|
||||
decoded=decoded,
|
||||
app_code="code",
|
||||
app_web_auth_enabled=True,
|
||||
system_webapp_auth_enabled=True,
|
||||
webapp_settings=settings,
|
||||
)
|
||||
|
||||
@patch("controllers.web.wraps.EnterpriseService.get_app_sso_settings_last_update_time")
|
||||
@patch("controllers.web.wraps.WebAppAuthService.is_app_require_permission_check", return_value=False)
|
||||
def test_external_auth_type_checks_sso_update_time(
|
||||
self, mock_perm_check: MagicMock, mock_sso_time: MagicMock
|
||||
) -> None:
|
||||
# granted_at is before SSO update time → denied
|
||||
mock_sso_time.return_value = datetime.now(UTC)
|
||||
old_granted = int((datetime.now(UTC) - timedelta(hours=1)).timestamp())
|
||||
decoded = {"user_id": "u1", "auth_type": "external", "granted_at": old_granted}
|
||||
settings = SimpleNamespace(access_mode="public")
|
||||
with pytest.raises(WebAppAuthAccessDeniedError, match="SSO settings"):
|
||||
_validate_user_accessibility(
|
||||
decoded=decoded,
|
||||
app_code="code",
|
||||
app_web_auth_enabled=True,
|
||||
system_webapp_auth_enabled=True,
|
||||
webapp_settings=settings,
|
||||
)
|
||||
|
||||
@patch("controllers.web.wraps.EnterpriseService.get_workspace_sso_settings_last_update_time")
|
||||
@patch("controllers.web.wraps.WebAppAuthService.is_app_require_permission_check", return_value=False)
|
||||
def test_internal_auth_type_checks_workspace_sso_update_time(
|
||||
self, mock_perm_check: MagicMock, mock_workspace_sso: MagicMock
|
||||
) -> None:
|
||||
mock_workspace_sso.return_value = datetime.now(UTC)
|
||||
old_granted = int((datetime.now(UTC) - timedelta(hours=1)).timestamp())
|
||||
decoded = {"user_id": "u1", "auth_type": "internal", "granted_at": old_granted}
|
||||
settings = SimpleNamespace(access_mode="public")
|
||||
with pytest.raises(WebAppAuthAccessDeniedError, match="SSO settings"):
|
||||
_validate_user_accessibility(
|
||||
decoded=decoded,
|
||||
app_code="code",
|
||||
app_web_auth_enabled=True,
|
||||
system_webapp_auth_enabled=True,
|
||||
webapp_settings=settings,
|
||||
)
|
||||
|
||||
@patch("controllers.web.wraps.EnterpriseService.get_app_sso_settings_last_update_time")
|
||||
@patch("controllers.web.wraps.WebAppAuthService.is_app_require_permission_check", return_value=False)
|
||||
def test_external_auth_passes_when_granted_after_sso_update(
|
||||
self, mock_perm_check: MagicMock, mock_sso_time: MagicMock
|
||||
) -> None:
|
||||
mock_sso_time.return_value = datetime.now(UTC) - timedelta(hours=2)
|
||||
recent_granted = int(datetime.now(UTC).timestamp())
|
||||
decoded = {"user_id": "u1", "auth_type": "external", "granted_at": recent_granted}
|
||||
settings = SimpleNamespace(access_mode="public")
|
||||
# Should not raise
|
||||
_validate_user_accessibility(
|
||||
decoded=decoded,
|
||||
app_code="code",
|
||||
app_web_auth_enabled=True,
|
||||
system_webapp_auth_enabled=True,
|
||||
webapp_settings=settings,
|
||||
)
|
||||
|
||||
@patch("controllers.web.wraps.EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp", return_value=False)
|
||||
@patch("controllers.web.wraps.AppService.get_app_id_by_code", return_value="app-id-1")
|
||||
@patch("controllers.web.wraps.WebAppAuthService.is_app_require_permission_check", return_value=True)
|
||||
def test_permission_check_denies_unauthorized_user(
|
||||
self, mock_perm: MagicMock, mock_app_id: MagicMock, mock_allowed: MagicMock
|
||||
) -> None:
|
||||
decoded = {"user_id": "u1", "auth_type": "external", "granted_at": int(datetime.now(UTC).timestamp())}
|
||||
settings = SimpleNamespace(access_mode="internal")
|
||||
with pytest.raises(WebAppAuthAccessDeniedError):
|
||||
_validate_user_accessibility(
|
||||
decoded=decoded,
|
||||
app_code="code",
|
||||
app_web_auth_enabled=True,
|
||||
system_webapp_auth_enabled=True,
|
||||
webapp_settings=settings,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# decode_jwt_token
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestDecodeJwtToken:
|
||||
@patch("controllers.web.wraps._validate_user_accessibility")
|
||||
@patch("controllers.web.wraps._validate_webapp_token")
|
||||
@patch("controllers.web.wraps.EnterpriseService.WebAppAuth.get_app_access_mode_by_id")
|
||||
@patch("controllers.web.wraps.AppService.get_app_id_by_code")
|
||||
@patch("controllers.web.wraps.FeatureService.get_system_features")
|
||||
@patch("controllers.web.wraps.PassportService")
|
||||
@patch("controllers.web.wraps.extract_webapp_passport")
|
||||
@patch("controllers.web.wraps.db")
|
||||
def test_happy_path(
|
||||
self,
|
||||
mock_db: MagicMock,
|
||||
mock_extract: MagicMock,
|
||||
mock_passport_cls: MagicMock,
|
||||
mock_features: MagicMock,
|
||||
mock_app_id: MagicMock,
|
||||
mock_access_mode: MagicMock,
|
||||
mock_validate_token: MagicMock,
|
||||
mock_validate_user: MagicMock,
|
||||
app: Flask,
|
||||
) -> None:
|
||||
mock_extract.return_value = "jwt-token"
|
||||
mock_passport_cls.return_value.verify.return_value = {
|
||||
"app_code": "code1",
|
||||
"app_id": "app-1",
|
||||
"end_user_id": "eu-1",
|
||||
}
|
||||
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
|
||||
|
||||
app_model = SimpleNamespace(id="app-1", enable_site=True)
|
||||
site = SimpleNamespace(code="code1")
|
||||
end_user = SimpleNamespace(id="eu-1", session_id="sess-1")
|
||||
|
||||
# Configure session mock to return correct objects via scalar()
|
||||
session_mock = MagicMock()
|
||||
session_mock.scalar.side_effect = [app_model, site, end_user]
|
||||
session_ctx = MagicMock()
|
||||
session_ctx.__enter__ = MagicMock(return_value=session_mock)
|
||||
session_ctx.__exit__ = MagicMock(return_value=False)
|
||||
mock_db.engine = "engine"
|
||||
|
||||
with patch("controllers.web.wraps.Session", return_value=session_ctx):
|
||||
with app.test_request_context("/", headers={"X-App-Code": "code1"}):
|
||||
result_app, result_user = decode_jwt_token()
|
||||
|
||||
assert result_app.id == "app-1"
|
||||
assert result_user.id == "eu-1"
|
||||
|
||||
@patch("controllers.web.wraps.FeatureService.get_system_features")
|
||||
@patch("controllers.web.wraps.extract_webapp_passport")
|
||||
def test_missing_token_raises_unauthorized(
|
||||
self, mock_extract: MagicMock, mock_features: MagicMock, app: Flask
|
||||
) -> None:
|
||||
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
|
||||
mock_extract.return_value = None
|
||||
|
||||
with app.test_request_context("/", headers={"X-App-Code": "code1"}):
|
||||
with pytest.raises(Unauthorized):
|
||||
decode_jwt_token()
|
||||
|
||||
@patch("controllers.web.wraps.FeatureService.get_system_features")
|
||||
@patch("controllers.web.wraps.PassportService")
|
||||
@patch("controllers.web.wraps.extract_webapp_passport")
|
||||
@patch("controllers.web.wraps.db")
|
||||
def test_missing_app_raises_not_found(
|
||||
self,
|
||||
mock_db: MagicMock,
|
||||
mock_extract: MagicMock,
|
||||
mock_passport_cls: MagicMock,
|
||||
mock_features: MagicMock,
|
||||
app: Flask,
|
||||
) -> None:
|
||||
mock_extract.return_value = "jwt-token"
|
||||
mock_passport_cls.return_value.verify.return_value = {
|
||||
"app_code": "code1",
|
||||
"app_id": "app-1",
|
||||
"end_user_id": "eu-1",
|
||||
}
|
||||
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
|
||||
|
||||
session_mock = MagicMock()
|
||||
session_mock.scalar.return_value = None # No app found
|
||||
session_ctx = MagicMock()
|
||||
session_ctx.__enter__ = MagicMock(return_value=session_mock)
|
||||
session_ctx.__exit__ = MagicMock(return_value=False)
|
||||
mock_db.engine = "engine"
|
||||
|
||||
with patch("controllers.web.wraps.Session", return_value=session_ctx):
|
||||
with app.test_request_context("/", headers={"X-App-Code": "code1"}):
|
||||
with pytest.raises(NotFound):
|
||||
decode_jwt_token()
|
||||
|
||||
@patch("controllers.web.wraps.FeatureService.get_system_features")
|
||||
@patch("controllers.web.wraps.PassportService")
|
||||
@patch("controllers.web.wraps.extract_webapp_passport")
|
||||
@patch("controllers.web.wraps.db")
|
||||
def test_disabled_site_raises_bad_request(
|
||||
self,
|
||||
mock_db: MagicMock,
|
||||
mock_extract: MagicMock,
|
||||
mock_passport_cls: MagicMock,
|
||||
mock_features: MagicMock,
|
||||
app: Flask,
|
||||
) -> None:
|
||||
mock_extract.return_value = "jwt-token"
|
||||
mock_passport_cls.return_value.verify.return_value = {
|
||||
"app_code": "code1",
|
||||
"app_id": "app-1",
|
||||
"end_user_id": "eu-1",
|
||||
}
|
||||
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
|
||||
|
||||
app_model = SimpleNamespace(id="app-1", enable_site=False)
|
||||
|
||||
session_mock = MagicMock()
|
||||
# scalar calls: app_model, site (code found), then end_user
|
||||
session_mock.scalar.side_effect = [app_model, SimpleNamespace(code="code1"), None]
|
||||
session_ctx = MagicMock()
|
||||
session_ctx.__enter__ = MagicMock(return_value=session_mock)
|
||||
session_ctx.__exit__ = MagicMock(return_value=False)
|
||||
mock_db.engine = "engine"
|
||||
|
||||
with patch("controllers.web.wraps.Session", return_value=session_ctx):
|
||||
with app.test_request_context("/", headers={"X-App-Code": "code1"}):
|
||||
with pytest.raises(BadRequest, match="Site is disabled"):
|
||||
decode_jwt_token()
|
||||
|
||||
@patch("controllers.web.wraps.FeatureService.get_system_features")
|
||||
@patch("controllers.web.wraps.PassportService")
|
||||
@patch("controllers.web.wraps.extract_webapp_passport")
|
||||
@patch("controllers.web.wraps.db")
|
||||
def test_missing_end_user_raises_not_found(
|
||||
self,
|
||||
mock_db: MagicMock,
|
||||
mock_extract: MagicMock,
|
||||
mock_passport_cls: MagicMock,
|
||||
mock_features: MagicMock,
|
||||
app: Flask,
|
||||
) -> None:
|
||||
mock_extract.return_value = "jwt-token"
|
||||
mock_passport_cls.return_value.verify.return_value = {
|
||||
"app_code": "code1",
|
||||
"app_id": "app-1",
|
||||
"end_user_id": "eu-1",
|
||||
}
|
||||
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
|
||||
|
||||
app_model = SimpleNamespace(id="app-1", enable_site=True)
|
||||
site = SimpleNamespace(code="code1")
|
||||
|
||||
session_mock = MagicMock()
|
||||
session_mock.scalar.side_effect = [app_model, site, None] # end_user is None
|
||||
session_ctx = MagicMock()
|
||||
session_ctx.__enter__ = MagicMock(return_value=session_mock)
|
||||
session_ctx.__exit__ = MagicMock(return_value=False)
|
||||
mock_db.engine = "engine"
|
||||
|
||||
with patch("controllers.web.wraps.Session", return_value=session_ctx):
|
||||
with app.test_request_context("/", headers={"X-App-Code": "code1"}):
|
||||
with pytest.raises(NotFound):
|
||||
decode_jwt_token()
|
||||
|
||||
@patch("controllers.web.wraps.FeatureService.get_system_features")
|
||||
@patch("controllers.web.wraps.PassportService")
|
||||
@patch("controllers.web.wraps.extract_webapp_passport")
|
||||
@patch("controllers.web.wraps.db")
|
||||
def test_user_id_mismatch_raises_unauthorized(
|
||||
self,
|
||||
mock_db: MagicMock,
|
||||
mock_extract: MagicMock,
|
||||
mock_passport_cls: MagicMock,
|
||||
mock_features: MagicMock,
|
||||
app: Flask,
|
||||
) -> None:
|
||||
mock_extract.return_value = "jwt-token"
|
||||
mock_passport_cls.return_value.verify.return_value = {
|
||||
"app_code": "code1",
|
||||
"app_id": "app-1",
|
||||
"end_user_id": "eu-1",
|
||||
}
|
||||
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
|
||||
|
||||
app_model = SimpleNamespace(id="app-1", enable_site=True)
|
||||
site = SimpleNamespace(code="code1")
|
||||
end_user = SimpleNamespace(id="eu-1", session_id="sess-1")
|
||||
|
||||
session_mock = MagicMock()
|
||||
session_mock.scalar.side_effect = [app_model, site, end_user]
|
||||
session_ctx = MagicMock()
|
||||
session_ctx.__enter__ = MagicMock(return_value=session_mock)
|
||||
session_ctx.__exit__ = MagicMock(return_value=False)
|
||||
mock_db.engine = "engine"
|
||||
|
||||
with patch("controllers.web.wraps.Session", return_value=session_ctx):
|
||||
with app.test_request_context("/", headers={"X-App-Code": "code1"}):
|
||||
with pytest.raises(Unauthorized, match="expired"):
|
||||
decode_jwt_token(user_id="different-user")
|
||||
@ -124,12 +124,12 @@ def test_message_cycle_manager_uses_new_conversation_flag(monkeypatch):
|
||||
def start(self):
|
||||
self.started = True
|
||||
|
||||
def fake_thread(**kwargs):
|
||||
def fake_thread(*args, **kwargs):
|
||||
thread = DummyThread(**kwargs)
|
||||
captured["thread"] = thread
|
||||
return thread
|
||||
|
||||
monkeypatch.setattr(message_cycle_manager, "Thread", fake_thread)
|
||||
monkeypatch.setattr(message_cycle_manager, "Timer", fake_thread)
|
||||
|
||||
manager = MessageCycleManager(application_generate_entity=entity, task_state=MagicMock())
|
||||
thread = manager.generate_conversation_name(conversation_id="existing-conversation-id", query="hello")
|
||||
|
||||
@ -0,0 +1,425 @@
|
||||
"""
|
||||
Unit tests for EasyUIBasedGenerateTaskPipeline._message_end_to_stream_response method.
|
||||
|
||||
This test suite ensures that the files array is correctly populated in the message_end
|
||||
SSE event, which is critical for vision/image chat responses to render correctly.
|
||||
|
||||
Test Coverage:
|
||||
- Files array populated when MessageFile records exist
|
||||
- Files array is None when no MessageFile records exist
|
||||
- Correct signed URL generation for LOCAL_FILE transfer method
|
||||
- Correct URL handling for REMOTE_URL transfer method
|
||||
- Correct URL handling for TOOL_FILE transfer method
|
||||
- Proper file metadata formatting (filename, mime_type, size, extension)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.app.entities.task_entities import MessageEndStreamResponse
|
||||
from core.app.task_pipeline.easy_ui_based_generate_task_pipeline import EasyUIBasedGenerateTaskPipeline
|
||||
from dify_graph.file.enums import FileTransferMethod
|
||||
from models.model import MessageFile, UploadFile
|
||||
|
||||
|
||||
class TestMessageEndStreamResponseFiles:
|
||||
"""Test suite for files array population in message_end SSE event."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pipeline(self):
|
||||
"""Create a mock EasyUIBasedGenerateTaskPipeline instance."""
|
||||
pipeline = Mock(spec=EasyUIBasedGenerateTaskPipeline)
|
||||
pipeline._message_id = str(uuid.uuid4())
|
||||
pipeline._task_state = Mock()
|
||||
pipeline._task_state.metadata = Mock()
|
||||
pipeline._task_state.metadata.model_dump = Mock(return_value={"test": "metadata"})
|
||||
pipeline._task_state.llm_result = Mock()
|
||||
pipeline._task_state.llm_result.usage = Mock()
|
||||
pipeline._application_generate_entity = Mock()
|
||||
pipeline._application_generate_entity.task_id = str(uuid.uuid4())
|
||||
return pipeline
|
||||
|
||||
@pytest.fixture
|
||||
def mock_message_file_local(self):
|
||||
"""Create a mock MessageFile with LOCAL_FILE transfer method."""
|
||||
message_file = Mock(spec=MessageFile)
|
||||
message_file.id = str(uuid.uuid4())
|
||||
message_file.message_id = str(uuid.uuid4())
|
||||
message_file.transfer_method = FileTransferMethod.LOCAL_FILE
|
||||
message_file.upload_file_id = str(uuid.uuid4())
|
||||
message_file.url = None
|
||||
message_file.type = "image"
|
||||
return message_file
|
||||
|
||||
@pytest.fixture
|
||||
def mock_message_file_remote(self):
|
||||
"""Create a mock MessageFile with REMOTE_URL transfer method."""
|
||||
message_file = Mock(spec=MessageFile)
|
||||
message_file.id = str(uuid.uuid4())
|
||||
message_file.message_id = str(uuid.uuid4())
|
||||
message_file.transfer_method = FileTransferMethod.REMOTE_URL
|
||||
message_file.upload_file_id = None
|
||||
message_file.url = "https://example.com/image.jpg"
|
||||
message_file.type = "image"
|
||||
return message_file
|
||||
|
||||
@pytest.fixture
|
||||
def mock_message_file_tool(self):
|
||||
"""Create a mock MessageFile with TOOL_FILE transfer method."""
|
||||
message_file = Mock(spec=MessageFile)
|
||||
message_file.id = str(uuid.uuid4())
|
||||
message_file.message_id = str(uuid.uuid4())
|
||||
message_file.transfer_method = FileTransferMethod.TOOL_FILE
|
||||
message_file.upload_file_id = None
|
||||
message_file.url = "tool_file_123.png"
|
||||
message_file.type = "image"
|
||||
return message_file
|
||||
|
||||
@pytest.fixture
|
||||
def mock_upload_file(self, mock_message_file_local):
|
||||
"""Create a mock UploadFile."""
|
||||
upload_file = Mock(spec=UploadFile)
|
||||
upload_file.id = mock_message_file_local.upload_file_id
|
||||
upload_file.name = "test_image.png"
|
||||
upload_file.mime_type = "image/png"
|
||||
upload_file.size = 1024
|
||||
upload_file.extension = "png"
|
||||
return upload_file
|
||||
|
||||
def test_message_end_with_no_files(self, mock_pipeline):
|
||||
"""Test that files array is None when no MessageFile records exist."""
|
||||
# Arrange
|
||||
with (
|
||||
patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db") as mock_db,
|
||||
patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.Session") as mock_session_class,
|
||||
):
|
||||
mock_engine = MagicMock()
|
||||
mock_db.engine = mock_engine
|
||||
|
||||
mock_session = MagicMock(spec=Session)
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
mock_session.scalars.return_value.all.return_value = []
|
||||
|
||||
# Act
|
||||
result = EasyUIBasedGenerateTaskPipeline._message_end_to_stream_response(mock_pipeline)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, MessageEndStreamResponse)
|
||||
assert result.files is None
|
||||
assert result.id == mock_pipeline._message_id
|
||||
assert result.metadata == {"test": "metadata"}
|
||||
|
||||
def test_message_end_with_local_file(self, mock_pipeline, mock_message_file_local, mock_upload_file):
|
||||
"""Test that files array is populated correctly for LOCAL_FILE transfer method."""
|
||||
# Arrange
|
||||
mock_message_file_local.message_id = mock_pipeline._message_id
|
||||
|
||||
with (
|
||||
patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db") as mock_db,
|
||||
patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.Session") as mock_session_class,
|
||||
patch("core.app.task_pipeline.message_file_utils.file_helpers.get_signed_file_url") as mock_get_url,
|
||||
):
|
||||
mock_engine = MagicMock()
|
||||
mock_db.engine = mock_engine
|
||||
|
||||
mock_session = MagicMock(spec=Session)
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
|
||||
# Mock database queries
|
||||
# First query: MessageFile
|
||||
mock_message_files_result = Mock()
|
||||
mock_message_files_result.all.return_value = [mock_message_file_local]
|
||||
|
||||
# Second query: UploadFile (batch query to avoid N+1)
|
||||
mock_upload_files_result = Mock()
|
||||
mock_upload_files_result.all.return_value = [mock_upload_file]
|
||||
|
||||
# Setup scalars to return different results for different queries
|
||||
call_count = [0] # Use list to allow modification in nested function
|
||||
|
||||
def scalars_side_effect(query):
|
||||
call_count[0] += 1
|
||||
# First call is for MessageFile, second call is for UploadFile
|
||||
if call_count[0] == 1:
|
||||
return mock_message_files_result
|
||||
else:
|
||||
return mock_upload_files_result
|
||||
|
||||
mock_session.scalars.side_effect = scalars_side_effect
|
||||
mock_get_url.return_value = "https://example.com/signed-url?signature=abc123"
|
||||
|
||||
# Act
|
||||
result = EasyUIBasedGenerateTaskPipeline._message_end_to_stream_response(mock_pipeline)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, MessageEndStreamResponse)
|
||||
assert result.files is not None
|
||||
assert len(result.files) == 1
|
||||
|
||||
file_dict = result.files[0]
|
||||
assert file_dict["related_id"] == mock_message_file_local.id
|
||||
assert file_dict["filename"] == "test_image.png"
|
||||
assert file_dict["mime_type"] == "image/png"
|
||||
assert file_dict["size"] == 1024
|
||||
assert file_dict["extension"] == ".png"
|
||||
assert file_dict["type"] == "image"
|
||||
assert file_dict["transfer_method"] == FileTransferMethod.LOCAL_FILE.value
|
||||
assert "https://example.com/signed-url" in file_dict["url"]
|
||||
assert file_dict["upload_file_id"] == mock_message_file_local.upload_file_id
|
||||
assert file_dict["remote_url"] == ""
|
||||
|
||||
# Verify database queries
|
||||
# Should be called twice: once for MessageFile, once for UploadFile
|
||||
assert mock_session.scalars.call_count == 2
|
||||
mock_get_url.assert_called_once_with(upload_file_id=str(mock_upload_file.id))
|
||||
|
||||
def test_message_end_with_remote_url(self, mock_pipeline, mock_message_file_remote):
|
||||
"""Test that files array is populated correctly for REMOTE_URL transfer method."""
|
||||
# Arrange
|
||||
mock_message_file_remote.message_id = mock_pipeline._message_id
|
||||
|
||||
with (
|
||||
patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db") as mock_db,
|
||||
patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.Session") as mock_session_class,
|
||||
):
|
||||
mock_engine = MagicMock()
|
||||
mock_db.engine = mock_engine
|
||||
mock_session = MagicMock(spec=Session)
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
|
||||
# Mock database queries
|
||||
mock_scalars_result = Mock()
|
||||
mock_scalars_result.all.return_value = [mock_message_file_remote]
|
||||
mock_session.scalars.return_value = mock_scalars_result
|
||||
|
||||
# Act
|
||||
result = EasyUIBasedGenerateTaskPipeline._message_end_to_stream_response(mock_pipeline)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, MessageEndStreamResponse)
|
||||
assert result.files is not None
|
||||
assert len(result.files) == 1
|
||||
|
||||
file_dict = result.files[0]
|
||||
assert file_dict["related_id"] == mock_message_file_remote.id
|
||||
assert file_dict["filename"] == "image.jpg"
|
||||
assert file_dict["url"] == "https://example.com/image.jpg"
|
||||
assert file_dict["extension"] == ".jpg"
|
||||
assert file_dict["type"] == "image"
|
||||
assert file_dict["transfer_method"] == FileTransferMethod.REMOTE_URL.value
|
||||
assert file_dict["remote_url"] == "https://example.com/image.jpg"
|
||||
assert file_dict["upload_file_id"] == mock_message_file_remote.id
|
||||
|
||||
# Verify only one query for message_files is made
|
||||
mock_session.scalars.assert_called_once()
|
||||
|
||||
def test_message_end_with_tool_file_http(self, mock_pipeline, mock_message_file_tool):
|
||||
"""Test that files array is populated correctly for TOOL_FILE with HTTP URL."""
|
||||
# Arrange
|
||||
mock_message_file_tool.message_id = mock_pipeline._message_id
|
||||
mock_message_file_tool.url = "https://example.com/tool_file.png"
|
||||
|
||||
with (
|
||||
patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db") as mock_db,
|
||||
patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.Session") as mock_session_class,
|
||||
):
|
||||
mock_engine = MagicMock()
|
||||
mock_db.engine = mock_engine
|
||||
mock_session = MagicMock(spec=Session)
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
|
||||
# Mock database queries
|
||||
mock_scalars_result = Mock()
|
||||
mock_scalars_result.all.return_value = [mock_message_file_tool]
|
||||
mock_session.scalars.return_value = mock_scalars_result
|
||||
|
||||
# Act
|
||||
result = EasyUIBasedGenerateTaskPipeline._message_end_to_stream_response(mock_pipeline)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, MessageEndStreamResponse)
|
||||
assert result.files is not None
|
||||
assert len(result.files) == 1
|
||||
|
||||
file_dict = result.files[0]
|
||||
assert file_dict["url"] == "https://example.com/tool_file.png"
|
||||
assert file_dict["filename"] == "tool_file.png"
|
||||
assert file_dict["extension"] == ".png"
|
||||
assert file_dict["transfer_method"] == FileTransferMethod.TOOL_FILE.value
|
||||
|
||||
def test_message_end_with_tool_file_local(self, mock_pipeline, mock_message_file_tool):
|
||||
"""Test that files array is populated correctly for TOOL_FILE with local path."""
|
||||
# Arrange
|
||||
mock_message_file_tool.message_id = mock_pipeline._message_id
|
||||
mock_message_file_tool.url = "tool_file_123.png"
|
||||
|
||||
with (
|
||||
patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db") as mock_db,
|
||||
patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.Session") as mock_session_class,
|
||||
patch("core.app.task_pipeline.message_file_utils.sign_tool_file") as mock_sign_tool,
|
||||
):
|
||||
mock_engine = MagicMock()
|
||||
mock_db.engine = mock_engine
|
||||
|
||||
mock_session = MagicMock(spec=Session)
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
|
||||
# Mock database queries
|
||||
mock_scalars_result = Mock()
|
||||
mock_scalars_result.all.return_value = [mock_message_file_tool]
|
||||
mock_session.scalars.return_value = mock_scalars_result
|
||||
|
||||
mock_sign_tool.return_value = "https://example.com/signed-tool-file.png?signature=xyz"
|
||||
|
||||
# Act
|
||||
result = EasyUIBasedGenerateTaskPipeline._message_end_to_stream_response(mock_pipeline)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, MessageEndStreamResponse)
|
||||
assert result.files is not None
|
||||
assert len(result.files) == 1
|
||||
|
||||
file_dict = result.files[0]
|
||||
assert "https://example.com/signed-tool-file.png" in file_dict["url"]
|
||||
assert file_dict["filename"] == "tool_file_123.png"
|
||||
assert file_dict["extension"] == ".png"
|
||||
assert file_dict["transfer_method"] == FileTransferMethod.TOOL_FILE.value
|
||||
|
||||
# Verify tool file signing was called
|
||||
mock_sign_tool.assert_called_once_with(tool_file_id="tool_file_123", extension=".png")
|
||||
|
||||
def test_message_end_with_tool_file_long_extension(self, mock_pipeline, mock_message_file_tool):
|
||||
"""Test that TOOL_FILE extensions longer than MAX_TOOL_FILE_EXTENSION_LENGTH fall back to .bin."""
|
||||
mock_message_file_tool.message_id = mock_pipeline._message_id
|
||||
mock_message_file_tool.url = "tool_file_abc.verylongextension"
|
||||
|
||||
with (
|
||||
patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db") as mock_db,
|
||||
patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.Session") as mock_session_class,
|
||||
patch("core.app.task_pipeline.message_file_utils.sign_tool_file") as mock_sign_tool,
|
||||
):
|
||||
mock_engine = MagicMock()
|
||||
mock_db.engine = mock_engine
|
||||
mock_session = MagicMock(spec=Session)
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
mock_scalars_result = Mock()
|
||||
mock_scalars_result.all.return_value = [mock_message_file_tool]
|
||||
mock_session.scalars.return_value = mock_scalars_result
|
||||
mock_sign_tool.return_value = "https://example.com/signed.bin"
|
||||
|
||||
result = EasyUIBasedGenerateTaskPipeline._message_end_to_stream_response(mock_pipeline)
|
||||
|
||||
assert result.files is not None
|
||||
file_dict = result.files[0]
|
||||
assert file_dict["extension"] == ".bin"
|
||||
mock_sign_tool.assert_called_once_with(tool_file_id="tool_file_abc", extension=".bin")
|
||||
|
||||
def test_message_end_with_multiple_files(
|
||||
self, mock_pipeline, mock_message_file_local, mock_message_file_remote, mock_upload_file
|
||||
):
|
||||
"""Test that files array contains all MessageFile records when multiple exist."""
|
||||
# Arrange
|
||||
mock_message_file_local.message_id = mock_pipeline._message_id
|
||||
mock_message_file_remote.message_id = mock_pipeline._message_id
|
||||
|
||||
with (
|
||||
patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db") as mock_db,
|
||||
patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.Session") as mock_session_class,
|
||||
patch("core.app.task_pipeline.message_file_utils.file_helpers.get_signed_file_url") as mock_get_url,
|
||||
):
|
||||
mock_engine = MagicMock()
|
||||
mock_db.engine = mock_engine
|
||||
|
||||
mock_session = MagicMock(spec=Session)
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
|
||||
# Mock database queries
|
||||
# First query: MessageFile
|
||||
mock_message_files_result = Mock()
|
||||
mock_message_files_result.all.return_value = [mock_message_file_local, mock_message_file_remote]
|
||||
|
||||
# Second query: UploadFile (batch query to avoid N+1)
|
||||
mock_upload_files_result = Mock()
|
||||
mock_upload_files_result.all.return_value = [mock_upload_file]
|
||||
|
||||
# Setup scalars to return different results for different queries
|
||||
call_count = [0] # Use list to allow modification in nested function
|
||||
|
||||
def scalars_side_effect(query):
|
||||
call_count[0] += 1
|
||||
# First call is for MessageFile, second call is for UploadFile
|
||||
if call_count[0] == 1:
|
||||
return mock_message_files_result
|
||||
else:
|
||||
return mock_upload_files_result
|
||||
|
||||
mock_session.scalars.side_effect = scalars_side_effect
|
||||
mock_get_url.return_value = "https://example.com/signed-url?signature=abc123"
|
||||
|
||||
# Act
|
||||
result = EasyUIBasedGenerateTaskPipeline._message_end_to_stream_response(mock_pipeline)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, MessageEndStreamResponse)
|
||||
assert result.files is not None
|
||||
assert len(result.files) == 2
|
||||
|
||||
# Verify both files are present
|
||||
file_ids = [f["related_id"] for f in result.files]
|
||||
assert mock_message_file_local.id in file_ids
|
||||
assert mock_message_file_remote.id in file_ids
|
||||
|
||||
def test_message_end_with_local_file_no_upload_file(self, mock_pipeline, mock_message_file_local):
|
||||
"""Test fallback when UploadFile is not found for LOCAL_FILE."""
|
||||
# Arrange
|
||||
mock_message_file_local.message_id = mock_pipeline._message_id
|
||||
|
||||
with (
|
||||
patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db") as mock_db,
|
||||
patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.Session") as mock_session_class,
|
||||
patch("core.app.task_pipeline.message_file_utils.file_helpers.get_signed_file_url") as mock_get_url,
|
||||
):
|
||||
mock_engine = MagicMock()
|
||||
mock_db.engine = mock_engine
|
||||
|
||||
mock_session = MagicMock(spec=Session)
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
|
||||
# Mock database queries
|
||||
# First query: MessageFile
|
||||
mock_message_files_result = Mock()
|
||||
mock_message_files_result.all.return_value = [mock_message_file_local]
|
||||
|
||||
# Second query: UploadFile (batch query) - returns empty list (not found)
|
||||
mock_upload_files_result = Mock()
|
||||
mock_upload_files_result.all.return_value = [] # UploadFile not found
|
||||
|
||||
# Setup scalars to return different results for different queries
|
||||
call_count = [0] # Use list to allow modification in nested function
|
||||
|
||||
def scalars_side_effect(query):
|
||||
call_count[0] += 1
|
||||
# First call is for MessageFile, second call is for UploadFile
|
||||
if call_count[0] == 1:
|
||||
return mock_message_files_result
|
||||
else:
|
||||
return mock_upload_files_result
|
||||
|
||||
mock_session.scalars.side_effect = scalars_side_effect
|
||||
mock_get_url.return_value = "https://example.com/fallback-url?signature=def456"
|
||||
|
||||
# Act
|
||||
result = EasyUIBasedGenerateTaskPipeline._message_end_to_stream_response(mock_pipeline)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, MessageEndStreamResponse)
|
||||
assert result.files is not None
|
||||
assert len(result.files) == 1
|
||||
|
||||
file_dict = result.files[0]
|
||||
assert "https://example.com/fallback-url" in file_dict["url"]
|
||||
# Verify fallback URL was generated using upload_file_id from message_file
|
||||
mock_get_url.assert_called_with(upload_file_id=str(mock_message_file_local.upload_file_id))
|
||||
@ -8,7 +8,9 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from dify_graph.enums import WorkflowNodeExecutionStatus
|
||||
from dify_graph.model_runtime.entities.llm_entities import LLMUsage
|
||||
from dify_graph.nodes.knowledge_retrieval.entities import (
|
||||
Condition,
|
||||
KnowledgeRetrievalNodeData,
|
||||
MetadataFilteringCondition,
|
||||
MultipleRetrievalConfig,
|
||||
RerankingModelConfig,
|
||||
SingleRetrievalConfig,
|
||||
@ -593,3 +595,106 @@ class TestFetchDatasetRetriever:
|
||||
|
||||
# Assert
|
||||
assert version == "1"
|
||||
|
||||
def test_resolve_metadata_filtering_conditions_templates(
|
||||
self,
|
||||
mock_graph_init_params,
|
||||
mock_graph_runtime_state,
|
||||
mock_rag_retrieval,
|
||||
):
|
||||
"""_resolve_metadata_filtering_conditions should expand {{#...#}} and keep numbers/None unchanged."""
|
||||
# Arrange
|
||||
node_id = str(uuid.uuid4())
|
||||
config = {
|
||||
"id": node_id,
|
||||
"data": {
|
||||
"title": "Knowledge Retrieval",
|
||||
"type": "knowledge-retrieval",
|
||||
"dataset_ids": [str(uuid.uuid4())],
|
||||
"retrieval_mode": "multiple",
|
||||
},
|
||||
}
|
||||
# Variable in pool used by template
|
||||
mock_graph_runtime_state.variable_pool.add(["start", "query"], StringSegment(value="readme"))
|
||||
|
||||
node = KnowledgeRetrievalNode(
|
||||
id=node_id,
|
||||
config=config,
|
||||
graph_init_params=mock_graph_init_params,
|
||||
graph_runtime_state=mock_graph_runtime_state,
|
||||
rag_retrieval=mock_rag_retrieval,
|
||||
)
|
||||
|
||||
conditions = MetadataFilteringCondition(
|
||||
logical_operator="and",
|
||||
conditions=[
|
||||
Condition(name="document_name", comparison_operator="is", value="{{#start.query#}}"),
|
||||
Condition(name="tags", comparison_operator="in", value=["x", "{{#start.query#}}"]),
|
||||
Condition(name="year", comparison_operator="=", value=2025),
|
||||
],
|
||||
)
|
||||
|
||||
# Act
|
||||
resolved = node._resolve_metadata_filtering_conditions(conditions)
|
||||
|
||||
# Assert
|
||||
assert resolved.logical_operator == "and"
|
||||
assert resolved.conditions[0].value == "readme"
|
||||
assert isinstance(resolved.conditions[1].value, list)
|
||||
assert resolved.conditions[1].value[1] == "readme"
|
||||
assert resolved.conditions[2].value == 2025
|
||||
|
||||
def test_fetch_passes_resolved_metadata_conditions(
|
||||
self,
|
||||
mock_graph_init_params,
|
||||
mock_graph_runtime_state,
|
||||
mock_rag_retrieval,
|
||||
):
|
||||
"""_fetch_dataset_retriever should pass resolved metadata conditions into request."""
|
||||
# Arrange
|
||||
query = "hi"
|
||||
variables = {"query": query}
|
||||
mock_graph_runtime_state.variable_pool.add(["start", "q"], StringSegment(value="readme"))
|
||||
|
||||
node_data = KnowledgeRetrievalNodeData(
|
||||
title="Knowledge Retrieval",
|
||||
type="knowledge-retrieval",
|
||||
dataset_ids=[str(uuid.uuid4())],
|
||||
retrieval_mode="multiple",
|
||||
multiple_retrieval_config=MultipleRetrievalConfig(
|
||||
top_k=4,
|
||||
score_threshold=0.0,
|
||||
reranking_mode="reranking_model",
|
||||
reranking_enable=True,
|
||||
reranking_model=RerankingModelConfig(provider="cohere", model="rerank-v2"),
|
||||
),
|
||||
metadata_filtering_mode="manual",
|
||||
metadata_filtering_conditions=MetadataFilteringCondition(
|
||||
logical_operator="and",
|
||||
conditions=[
|
||||
Condition(name="document_name", comparison_operator="is", value="{{#start.q#}}"),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
node_id = str(uuid.uuid4())
|
||||
config = {"id": node_id, "data": node_data.model_dump()}
|
||||
node = KnowledgeRetrievalNode(
|
||||
id=node_id,
|
||||
config=config,
|
||||
graph_init_params=mock_graph_init_params,
|
||||
graph_runtime_state=mock_graph_runtime_state,
|
||||
rag_retrieval=mock_rag_retrieval,
|
||||
)
|
||||
|
||||
mock_rag_retrieval.knowledge_retrieval.return_value = []
|
||||
mock_rag_retrieval.llm_usage = LLMUsage.empty_usage()
|
||||
|
||||
# Act
|
||||
node._fetch_dataset_retriever(node_data=node_data, variables=variables)
|
||||
|
||||
# Assert the passed request has resolved value
|
||||
call_args = mock_rag_retrieval.knowledge_retrieval.call_args
|
||||
request = call_args[1]["request"]
|
||||
assert request.metadata_filtering_conditions is not None
|
||||
assert request.metadata_filtering_conditions.conditions[0].value == "readme"
|
||||
|
||||
@ -16,6 +16,7 @@ from dify_graph.nodes.document_extractor.node import (
|
||||
_extract_text_from_excel,
|
||||
_extract_text_from_pdf,
|
||||
_extract_text_from_plain_text,
|
||||
_normalize_docx_zip,
|
||||
)
|
||||
from dify_graph.variables import ArrayFileSegment
|
||||
from dify_graph.variables.segments import ArrayStringSegment
|
||||
@ -86,6 +87,38 @@ def test_run_invalid_variable_type(document_extractor_node, mock_graph_runtime_s
|
||||
assert "is not an ArrayFileSegment" in result.error
|
||||
|
||||
|
||||
def test_run_empty_file_list_returns_succeeded(document_extractor_node, mock_graph_runtime_state):
|
||||
"""Empty file list should return SUCCEEDED with empty documents and ArrayStringSegment([])."""
|
||||
document_extractor_node.graph_runtime_state = mock_graph_runtime_state
|
||||
|
||||
# Provide an actual ArrayFileSegment with an empty list
|
||||
mock_graph_runtime_state.variable_pool.get.return_value = ArrayFileSegment(value=[])
|
||||
|
||||
result = document_extractor_node._run()
|
||||
|
||||
assert isinstance(result, NodeRunResult)
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED, result.error
|
||||
assert result.process_data.get("documents") == []
|
||||
assert result.outputs["text"] == ArrayStringSegment(value=[])
|
||||
|
||||
|
||||
def test_run_none_only_file_list_returns_succeeded(document_extractor_node, mock_graph_runtime_state):
|
||||
"""A file list containing only None (e.g., [None]) should be filtered to [] and succeed."""
|
||||
document_extractor_node.graph_runtime_state = mock_graph_runtime_state
|
||||
|
||||
# Use a Mock to bypass type validation for None entries in the list
|
||||
afs = Mock(spec=ArrayFileSegment)
|
||||
afs.value = [None]
|
||||
mock_graph_runtime_state.variable_pool.get.return_value = afs
|
||||
|
||||
result = document_extractor_node._run()
|
||||
|
||||
assert isinstance(result, NodeRunResult)
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED, result.error
|
||||
assert result.process_data.get("documents") == []
|
||||
assert result.outputs["text"] == ArrayStringSegment(value=[])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mime_type", "file_content", "expected_text", "transfer_method", "extension"),
|
||||
[
|
||||
@ -385,3 +418,58 @@ def test_extract_text_from_excel_numeric_type_column(mock_excel_file):
|
||||
expected_manual = "| 1.0 | 1.1 |\n| --- | --- |\n| Test | Test |\n\n"
|
||||
|
||||
assert expected_manual == result
|
||||
|
||||
|
||||
def _make_docx_zip(use_backslash: bool) -> bytes:
|
||||
"""Helper to build a minimal in-memory DOCX zip.
|
||||
|
||||
When use_backslash=True the ZIP entry names use backslash separators
|
||||
(as produced by Evernote on Windows), otherwise forward slashes are used.
|
||||
"""
|
||||
import zipfile
|
||||
|
||||
sep = "\\" if use_backslash else "/"
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("[Content_Types].xml", b"<Types/>")
|
||||
zf.writestr(f"_rels{sep}.rels", b"<Relationships/>")
|
||||
zf.writestr(f"word{sep}document.xml", b"<w:document/>")
|
||||
zf.writestr(f"word{sep}_rels{sep}document.xml.rels", b"<Relationships/>")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_normalize_docx_zip_replaces_backslashes():
|
||||
"""ZIP entries with backslash separators must be rewritten to forward slashes."""
|
||||
import zipfile
|
||||
|
||||
malformed = _make_docx_zip(use_backslash=True)
|
||||
fixed = _normalize_docx_zip(malformed)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(fixed)) as zf:
|
||||
names = zf.namelist()
|
||||
|
||||
assert "word/document.xml" in names
|
||||
assert "word/_rels/document.xml.rels" in names
|
||||
# No entry should contain a backslash after normalization
|
||||
assert all("\\" not in name for name in names)
|
||||
|
||||
|
||||
def test_normalize_docx_zip_leaves_forward_slash_unchanged():
|
||||
"""ZIP entries that already use forward slashes must not be modified."""
|
||||
import zipfile
|
||||
|
||||
normal = _make_docx_zip(use_backslash=False)
|
||||
fixed = _normalize_docx_zip(normal)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(fixed)) as zf:
|
||||
names = zf.namelist()
|
||||
|
||||
assert "word/document.xml" in names
|
||||
assert "word/_rels/document.xml.rels" in names
|
||||
|
||||
|
||||
def test_normalize_docx_zip_returns_original_on_bad_zip():
|
||||
"""Non-zip bytes must be returned as-is without raising."""
|
||||
garbage = b"not a zip file at all"
|
||||
result = _normalize_docx_zip(garbage)
|
||||
assert result == garbage
|
||||
|
||||
43
api/tests/unit_tests/services/test_export_app_messages.py
Normal file
43
api/tests/unit_tests/services/test_export_app_messages.py
Normal file
@ -0,0 +1,43 @@
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from services.retention.conversation.message_export_service import AppMessageExportService
|
||||
|
||||
|
||||
def test_validate_export_filename_accepts_relative_path():
|
||||
assert AppMessageExportService.validate_export_filename("exports/2026/test01") == "exports/2026/test01"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filename",
|
||||
[
|
||||
"test01.jsonl.gz",
|
||||
"test01.jsonl",
|
||||
"test01.gz",
|
||||
"/tmp/test01",
|
||||
"exports/../test01",
|
||||
"bad\x00name",
|
||||
"bad\tname",
|
||||
"a" * 1025,
|
||||
],
|
||||
)
|
||||
def test_validate_export_filename_rejects_invalid_values(filename: str):
|
||||
with pytest.raises(ValueError):
|
||||
AppMessageExportService.validate_export_filename(filename)
|
||||
|
||||
|
||||
def test_service_derives_output_names_from_filename_base():
|
||||
service = AppMessageExportService(
|
||||
app_id="736b9b03-20f2-4697-91da-8d00f6325900",
|
||||
start_from=None,
|
||||
end_before=datetime.datetime(2026, 3, 1),
|
||||
filename="exports/2026/test01",
|
||||
batch_size=1000,
|
||||
use_cloud_storage=True,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
assert service._filename_base == "exports/2026/test01"
|
||||
assert service.output_gz_name == "exports/2026/test01.jsonl.gz"
|
||||
assert service.output_jsonl_name == "exports/2026/test01.jsonl"
|
||||
@ -554,11 +554,9 @@ class TestMessagesCleanServiceFromDays:
|
||||
MessagesCleanService.from_days(policy=policy, days=-1)
|
||||
|
||||
# Act
|
||||
with patch("services.retention.conversation.messages_clean_service.datetime", autospec=True) as mock_datetime:
|
||||
with patch("services.retention.conversation.messages_clean_service.naive_utc_now") as mock_now:
|
||||
fixed_now = datetime.datetime(2024, 6, 15, 14, 0, 0)
|
||||
mock_datetime.datetime.now.return_value = fixed_now
|
||||
mock_datetime.timedelta = datetime.timedelta
|
||||
|
||||
mock_now.return_value = fixed_now
|
||||
service = MessagesCleanService.from_days(policy=policy, days=0)
|
||||
|
||||
# Assert
|
||||
@ -586,11 +584,9 @@ class TestMessagesCleanServiceFromDays:
|
||||
dry_run = True
|
||||
|
||||
# Act
|
||||
with patch("services.retention.conversation.messages_clean_service.datetime", autospec=True) as mock_datetime:
|
||||
with patch("services.retention.conversation.messages_clean_service.naive_utc_now") as mock_now:
|
||||
fixed_now = datetime.datetime(2024, 6, 15, 10, 30, 0)
|
||||
mock_datetime.datetime.now.return_value = fixed_now
|
||||
mock_datetime.timedelta = datetime.timedelta
|
||||
|
||||
mock_now.return_value = fixed_now
|
||||
service = MessagesCleanService.from_days(
|
||||
policy=policy,
|
||||
days=days,
|
||||
@ -613,11 +609,9 @@ class TestMessagesCleanServiceFromDays:
|
||||
policy = BillingDisabledPolicy()
|
||||
|
||||
# Act
|
||||
with patch("services.retention.conversation.messages_clean_service.datetime", autospec=True) as mock_datetime:
|
||||
with patch("services.retention.conversation.messages_clean_service.naive_utc_now") as mock_now:
|
||||
fixed_now = datetime.datetime(2024, 6, 15, 10, 30, 0)
|
||||
mock_datetime.datetime.now.return_value = fixed_now
|
||||
mock_datetime.timedelta = datetime.timedelta
|
||||
|
||||
mock_now.return_value = fixed_now
|
||||
service = MessagesCleanService.from_days(policy=policy)
|
||||
|
||||
# Assert
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,13 @@ from typing import Any
|
||||
|
||||
|
||||
class ConfigHelper:
|
||||
_LEGACY_SECTION_MAP = {
|
||||
"admin_config": "admin",
|
||||
"token_config": "auth",
|
||||
"app_config": "app",
|
||||
"api_key_config": "api_key",
|
||||
}
|
||||
|
||||
"""Helper class for reading and writing configuration files."""
|
||||
|
||||
def __init__(self, base_dir: Path | None = None):
|
||||
@ -50,14 +57,8 @@ class ConfigHelper:
|
||||
Dictionary containing config data, or None if file doesn't exist
|
||||
"""
|
||||
# Provide backward compatibility for old config names
|
||||
if filename in ["admin_config", "token_config", "app_config", "api_key_config"]:
|
||||
section_map = {
|
||||
"admin_config": "admin",
|
||||
"token_config": "auth",
|
||||
"app_config": "app",
|
||||
"api_key_config": "api_key",
|
||||
}
|
||||
return self.get_state_section(section_map[filename])
|
||||
if filename in self._LEGACY_SECTION_MAP:
|
||||
return self.get_state_section(self._LEGACY_SECTION_MAP[filename])
|
||||
|
||||
config_path = self.get_config_path(filename)
|
||||
|
||||
@ -85,14 +86,11 @@ class ConfigHelper:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
# Provide backward compatibility for old config names
|
||||
if filename in ["admin_config", "token_config", "app_config", "api_key_config"]:
|
||||
section_map = {
|
||||
"admin_config": "admin",
|
||||
"token_config": "auth",
|
||||
"app_config": "app",
|
||||
"api_key_config": "api_key",
|
||||
}
|
||||
return self.update_state_section(section_map[filename], data)
|
||||
if filename in self._LEGACY_SECTION_MAP:
|
||||
return self.update_state_section(
|
||||
self._LEGACY_SECTION_MAP[filename],
|
||||
data,
|
||||
)
|
||||
|
||||
self.ensure_config_dir()
|
||||
config_path = self.get_config_path(filename)
|
||||
|
||||
@ -2,6 +2,12 @@
|
||||
|
||||
- Refer to the `./docs/test.md` and `./docs/lint.md` for detailed frontend workflow instructions.
|
||||
|
||||
## Overlay Components (Mandatory)
|
||||
|
||||
- `./docs/overlay-migration.md` is the source of truth for overlay-related work.
|
||||
- In new or modified code, use only overlay primitives from `@/app/components/base/ui/*`.
|
||||
- Do not introduce deprecated overlay imports from `@/app/components/base/*`; when touching legacy callers, prefer migrating them and keep the allowlist shrinking (never expanding).
|
||||
|
||||
## Automated Test Generation
|
||||
|
||||
- Use `./docs/test.md` as the canonical instruction set for generating frontend automated tests.
|
||||
|
||||
@ -503,7 +503,7 @@ describe('TimePicker', () => {
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(isDayjsObject(emitted)).toBe(true)
|
||||
// 10:30 UTC converted to America/New_York (UTC-5 in Jan) = 05:30
|
||||
expect(emitted.utcOffset()).toBe(dayjs().tz('America/New_York').utcOffset())
|
||||
expect(emitted.utcOffset()).toBe(dayjs.tz('2024-01-01', 'America/New_York').utcOffset())
|
||||
expect(emitted.hour()).toBe(5)
|
||||
expect(emitted.minute()).toBe(30)
|
||||
})
|
||||
|
||||
@ -20,7 +20,7 @@ describe('dayjs utilities', () => {
|
||||
const result = toDayjs('07:15 PM', { timezone: tz })
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.format('HH:mm')).toBe('19:15')
|
||||
expect(result?.utcOffset()).toBe(getDateWithTimezone({ timezone: tz }).utcOffset())
|
||||
expect(result?.utcOffset()).toBe(getDateWithTimezone({ timezone: tz }).startOf('day').utcOffset())
|
||||
})
|
||||
|
||||
it('isDayjsObject detects dayjs instances', () => {
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IToastProps } from './context'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import type { IToastProps } from './context'
|
||||
import { ToastContext, useToastContext } from './context'
|
||||
|
||||
export type ToastHandle = {
|
||||
|
||||
257
web/app/components/base/ui/context-menu/__tests__/index.spec.tsx
Normal file
257
web/app/components/base/ui/context-menu/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,257 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuLinkItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from '../index'
|
||||
|
||||
describe('context-menu wrapper', () => {
|
||||
describe('ContextMenuContent', () => {
|
||||
it('should position content at bottom-start with default placement when props are omitted', () => {
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
|
||||
<ContextMenuContent positionerProps={{ 'role': 'group', 'aria-label': 'content positioner' }}>
|
||||
<ContextMenuItem>Content action</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
const positioner = screen.getByRole('group', { name: 'content positioner' })
|
||||
const popup = screen.getByRole('menu')
|
||||
expect(positioner).toHaveAttribute('data-side', 'bottom')
|
||||
expect(positioner).toHaveAttribute('data-align', 'start')
|
||||
expect(within(popup).getByRole('menuitem', { name: 'Content action' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom placement when custom positioning props are provided', () => {
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
|
||||
<ContextMenuContent
|
||||
placement="top-end"
|
||||
sideOffset={12}
|
||||
alignOffset={-3}
|
||||
positionerProps={{ 'role': 'group', 'aria-label': 'custom content positioner' }}
|
||||
>
|
||||
<ContextMenuItem>Custom content</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
const positioner = screen.getByRole('group', { name: 'custom content positioner' })
|
||||
const popup = screen.getByRole('menu')
|
||||
expect(positioner).toHaveAttribute('data-side', 'top')
|
||||
expect(positioner).toHaveAttribute('data-align', 'end')
|
||||
expect(within(popup).getByRole('menuitem', { name: 'Custom content' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should forward passthrough attributes and handlers when positionerProps and popupProps are provided', () => {
|
||||
const handlePositionerMouseEnter = vi.fn()
|
||||
const handlePopupClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
|
||||
<ContextMenuContent
|
||||
positionerProps={{
|
||||
'role': 'group',
|
||||
'aria-label': 'context content positioner',
|
||||
'id': 'context-content-positioner',
|
||||
'onMouseEnter': handlePositionerMouseEnter,
|
||||
}}
|
||||
popupProps={{
|
||||
role: 'menu',
|
||||
id: 'context-content-popup',
|
||||
onClick: handlePopupClick,
|
||||
}}
|
||||
>
|
||||
<ContextMenuItem>Passthrough content</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
const positioner = screen.getByRole('group', { name: 'context content positioner' })
|
||||
const popup = screen.getByRole('menu')
|
||||
fireEvent.mouseEnter(positioner)
|
||||
fireEvent.click(popup)
|
||||
expect(positioner).toHaveAttribute('id', 'context-content-positioner')
|
||||
expect(popup).toHaveAttribute('id', 'context-content-popup')
|
||||
expect(handlePositionerMouseEnter).toHaveBeenCalledTimes(1)
|
||||
expect(handlePopupClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ContextMenuSubContent', () => {
|
||||
it('should position sub-content at right-start with default placement when props are omitted', () => {
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuSub open>
|
||||
<ContextMenuSubTrigger>More actions</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent positionerProps={{ 'role': 'group', 'aria-label': 'sub positioner' }}>
|
||||
<ContextMenuItem>Sub action</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
const positioner = screen.getByRole('group', { name: 'sub positioner' })
|
||||
expect(positioner).toHaveAttribute('data-side', 'right')
|
||||
expect(positioner).toHaveAttribute('data-align', 'start')
|
||||
expect(screen.getByRole('menuitem', { name: 'Sub action' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('destructive prop behavior', () => {
|
||||
it.each([true, false])('should remain interactive and not leak destructive prop on item when destructive is %s', (destructive) => {
|
||||
const handleClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
destructive={destructive}
|
||||
aria-label="menu action"
|
||||
id={`context-item-${String(destructive)}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Item label
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
const item = screen.getByRole('menuitem', { name: 'menu action' })
|
||||
fireEvent.click(item)
|
||||
expect(item).toHaveAttribute('id', `context-item-${String(destructive)}`)
|
||||
expect(item).not.toHaveAttribute('destructive')
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([true, false])('should remain interactive and not leak destructive prop on submenu trigger when destructive is %s', (destructive) => {
|
||||
const handleClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuSub open>
|
||||
<ContextMenuSubTrigger
|
||||
destructive={destructive}
|
||||
aria-label="submenu action"
|
||||
id={`context-sub-${String(destructive)}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Trigger item
|
||||
</ContextMenuSubTrigger>
|
||||
</ContextMenuSub>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByRole('menuitem', { name: 'submenu action' })
|
||||
fireEvent.click(trigger)
|
||||
expect(trigger).toHaveAttribute('id', `context-sub-${String(destructive)}`)
|
||||
expect(trigger).not.toHaveAttribute('destructive')
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([true, false])('should remain interactive and not leak destructive prop on link item when destructive is %s', (destructive) => {
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuLinkItem
|
||||
destructive={destructive}
|
||||
href="https://example.com/docs"
|
||||
aria-label="context docs link"
|
||||
id={`context-link-${String(destructive)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Docs
|
||||
</ContextMenuLinkItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('menuitem', { name: 'context docs link' })
|
||||
expect(link.tagName.toLowerCase()).toBe('a')
|
||||
expect(link).toHaveAttribute('id', `context-link-${String(destructive)}`)
|
||||
expect(link).not.toHaveAttribute('destructive')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ContextMenuLinkItem close behavior', () => {
|
||||
it('should keep link semantics and not leak closeOnClick prop when closeOnClick is false', () => {
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuLinkItem
|
||||
href="https://example.com/docs"
|
||||
closeOnClick={false}
|
||||
aria-label="docs link"
|
||||
>
|
||||
Docs
|
||||
</ContextMenuLinkItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('menuitem', { name: 'docs link' })
|
||||
expect(link.tagName.toLowerCase()).toBe('a')
|
||||
expect(link).toHaveAttribute('href', 'https://example.com/docs')
|
||||
expect(link).not.toHaveAttribute('closeOnClick')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ContextMenuTrigger interaction', () => {
|
||||
it('should open menu when right-clicking trigger area', () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger aria-label="context trigger area">
|
||||
Trigger area
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Open on right click</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByLabelText('context trigger area')
|
||||
fireEvent.contextMenu(trigger)
|
||||
expect(screen.getByRole('menuitem', { name: 'Open on right click' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ContextMenuSeparator', () => {
|
||||
it('should render separator and keep surrounding rows when separator is between items', () => {
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>First action</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem>Second action</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: 'First action' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Second action' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('separator')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
215
web/app/components/base/ui/context-menu/index.stories.tsx
Normal file
215
web/app/components/base/ui/context-menu/index.stories.tsx
Normal file
@ -0,0 +1,215 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuCheckboxItemIndicator,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuGroupLabel,
|
||||
ContextMenuItem,
|
||||
ContextMenuLinkItem,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuRadioItemIndicator,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from '.'
|
||||
|
||||
const TriggerArea = ({ label = 'Right-click inside this area' }: { label?: string }) => (
|
||||
<ContextMenuTrigger
|
||||
aria-label="context menu trigger area"
|
||||
render={<button type="button" className="flex h-44 w-80 select-none items-center justify-center rounded-xl border border-divider-subtle bg-background-default-subtle px-6 text-center text-sm text-text-tertiary" />}
|
||||
>
|
||||
{label}
|
||||
</ContextMenuTrigger>
|
||||
)
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Navigation/ContextMenu',
|
||||
component: ContextMenu,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compound context menu built on Base UI ContextMenu. Open by right-clicking the trigger area.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ContextMenu>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<ContextMenu>
|
||||
<TriggerArea />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Edit</ContextMenuItem>
|
||||
<ContextMenuItem>Duplicate</ContextMenuItem>
|
||||
<ContextMenuItem>Archive</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithSubmenu: Story = {
|
||||
render: () => (
|
||||
<ContextMenu>
|
||||
<TriggerArea />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Copy</ContextMenuItem>
|
||||
<ContextMenuItem>Paste</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Share</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem>Email</ContextMenuItem>
|
||||
<ContextMenuItem>Slack</ContextMenuItem>
|
||||
<ContextMenuItem>Copy link</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithGroupLabel: Story = {
|
||||
render: () => (
|
||||
<ContextMenu>
|
||||
<TriggerArea />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuGroupLabel>Actions</ContextMenuGroupLabel>
|
||||
<ContextMenuItem>Rename</ContextMenuItem>
|
||||
<ContextMenuItem>Duplicate</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuGroupLabel>Danger Zone</ContextMenuGroupLabel>
|
||||
<ContextMenuItem destructive>Delete</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
),
|
||||
}
|
||||
|
||||
const WithRadioItemsDemo = () => {
|
||||
const [value, setValue] = useState('comfortable')
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<TriggerArea label={`Right-click to set density: ${value}`} />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuRadioGroup value={value} onValueChange={setValue}>
|
||||
<ContextMenuRadioItem value="compact">
|
||||
Compact
|
||||
<ContextMenuRadioItemIndicator />
|
||||
</ContextMenuRadioItem>
|
||||
<ContextMenuRadioItem value="comfortable">
|
||||
Comfortable
|
||||
<ContextMenuRadioItemIndicator />
|
||||
</ContextMenuRadioItem>
|
||||
<ContextMenuRadioItem value="spacious">
|
||||
Spacious
|
||||
<ContextMenuRadioItemIndicator />
|
||||
</ContextMenuRadioItem>
|
||||
</ContextMenuRadioGroup>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithRadioItems: Story = {
|
||||
render: () => <WithRadioItemsDemo />,
|
||||
}
|
||||
|
||||
const WithCheckboxItemsDemo = () => {
|
||||
const [showToolbar, setShowToolbar] = useState(true)
|
||||
const [showSidebar, setShowSidebar] = useState(false)
|
||||
const [showStatusBar, setShowStatusBar] = useState(true)
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<TriggerArea label="Right-click to configure panel visibility" />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuCheckboxItem checked={showToolbar} onCheckedChange={setShowToolbar}>
|
||||
Toolbar
|
||||
<ContextMenuCheckboxItemIndicator />
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem checked={showSidebar} onCheckedChange={setShowSidebar}>
|
||||
Sidebar
|
||||
<ContextMenuCheckboxItemIndicator />
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem checked={showStatusBar} onCheckedChange={setShowStatusBar}>
|
||||
Status bar
|
||||
<ContextMenuCheckboxItemIndicator />
|
||||
</ContextMenuCheckboxItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithCheckboxItems: Story = {
|
||||
render: () => <WithCheckboxItemsDemo />,
|
||||
}
|
||||
|
||||
export const WithLinkItems: Story = {
|
||||
render: () => (
|
||||
<ContextMenu>
|
||||
<TriggerArea label="Right-click to open links" />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuLinkItem href="https://docs.dify.ai" rel="noopener noreferrer" target="_blank">
|
||||
Dify Docs
|
||||
</ContextMenuLinkItem>
|
||||
<ContextMenuLinkItem href="https://roadmap.dify.ai" rel="noopener noreferrer" target="_blank">
|
||||
Product Roadmap
|
||||
</ContextMenuLinkItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuLinkItem destructive href="https://example.com/delete" rel="noopener noreferrer" target="_blank">
|
||||
Dangerous External Action
|
||||
</ContextMenuLinkItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
),
|
||||
}
|
||||
|
||||
export const Complex: Story = {
|
||||
render: () => (
|
||||
<ContextMenu>
|
||||
<TriggerArea label="Right-click to inspect all menu capabilities" />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>
|
||||
<span aria-hidden className="i-ri-pencil-line size-4 shrink-0 text-text-tertiary" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
<span aria-hidden className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" />
|
||||
Duplicate
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<span aria-hidden className="i-ri-share-line size-4 shrink-0 text-text-tertiary" />
|
||||
Share
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem>Email</ContextMenuItem>
|
||||
<ContextMenuItem>Slack</ContextMenuItem>
|
||||
<ContextMenuItem>Copy Link</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem destructive>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
),
|
||||
}
|
||||
302
web/app/components/base/ui/context-menu/index.tsx
Normal file
302
web/app/components/base/ui/context-menu/index.tsx
Normal file
@ -0,0 +1,302 @@
|
||||
'use client'
|
||||
|
||||
import type { Placement } from '@/app/components/base/ui/placement'
|
||||
import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
menuBackdropClassName,
|
||||
menuGroupLabelClassName,
|
||||
menuIndicatorClassName,
|
||||
menuPopupAnimationClassName,
|
||||
menuPopupBaseClassName,
|
||||
menuRowClassName,
|
||||
menuSeparatorClassName,
|
||||
} from '@/app/components/base/ui/menu-shared'
|
||||
import { parsePlacement } from '@/app/components/base/ui/placement'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export const ContextMenu = BaseContextMenu.Root
|
||||
export const ContextMenuTrigger = BaseContextMenu.Trigger
|
||||
export const ContextMenuPortal = BaseContextMenu.Portal
|
||||
export const ContextMenuBackdrop = BaseContextMenu.Backdrop
|
||||
export const ContextMenuSub = BaseContextMenu.SubmenuRoot
|
||||
export const ContextMenuGroup = BaseContextMenu.Group
|
||||
export const ContextMenuRadioGroup = BaseContextMenu.RadioGroup
|
||||
|
||||
type ContextMenuContentProps = {
|
||||
children: React.ReactNode
|
||||
placement?: Placement
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
positionerProps?: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof BaseContextMenu.Positioner>,
|
||||
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
|
||||
>
|
||||
popupProps?: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof BaseContextMenu.Popup>,
|
||||
'children' | 'className'
|
||||
>
|
||||
}
|
||||
|
||||
type ContextMenuPopupRenderProps = Required<Pick<ContextMenuContentProps, 'children'>> & {
|
||||
placement: Placement
|
||||
sideOffset: number
|
||||
alignOffset: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
positionerProps?: ContextMenuContentProps['positionerProps']
|
||||
popupProps?: ContextMenuContentProps['popupProps']
|
||||
withBackdrop?: boolean
|
||||
}
|
||||
|
||||
function renderContextMenuPopup({
|
||||
children,
|
||||
placement,
|
||||
sideOffset,
|
||||
alignOffset,
|
||||
className,
|
||||
popupClassName,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
withBackdrop = false,
|
||||
}: ContextMenuPopupRenderProps) {
|
||||
const { side, align } = parsePlacement(placement)
|
||||
|
||||
return (
|
||||
<BaseContextMenu.Portal>
|
||||
{withBackdrop && (
|
||||
<BaseContextMenu.Backdrop className={menuBackdropClassName} />
|
||||
)}
|
||||
<BaseContextMenu.Positioner
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
className={cn('z-50 outline-none', className)}
|
||||
{...positionerProps}
|
||||
>
|
||||
<BaseContextMenu.Popup
|
||||
className={cn(
|
||||
menuPopupBaseClassName,
|
||||
menuPopupAnimationClassName,
|
||||
popupClassName,
|
||||
)}
|
||||
{...popupProps}
|
||||
>
|
||||
{children}
|
||||
</BaseContextMenu.Popup>
|
||||
</BaseContextMenu.Positioner>
|
||||
</BaseContextMenu.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenuContent({
|
||||
children,
|
||||
placement = 'bottom-start',
|
||||
sideOffset = 0,
|
||||
alignOffset = 0,
|
||||
className,
|
||||
popupClassName,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
}: ContextMenuContentProps) {
|
||||
return renderContextMenuPopup({
|
||||
children,
|
||||
placement,
|
||||
sideOffset,
|
||||
alignOffset,
|
||||
className,
|
||||
popupClassName,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
withBackdrop: true,
|
||||
})
|
||||
}
|
||||
|
||||
type ContextMenuItemProps = React.ComponentPropsWithoutRef<typeof BaseContextMenu.Item> & {
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
export function ContextMenuItem({
|
||||
className,
|
||||
destructive,
|
||||
...props
|
||||
}: ContextMenuItemProps) {
|
||||
return (
|
||||
<BaseContextMenu.Item
|
||||
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ContextMenuLinkItemProps = React.ComponentPropsWithoutRef<typeof BaseContextMenu.LinkItem> & {
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
export function ContextMenuLinkItem({
|
||||
className,
|
||||
destructive,
|
||||
closeOnClick = true,
|
||||
...props
|
||||
}: ContextMenuLinkItemProps) {
|
||||
return (
|
||||
<BaseContextMenu.LinkItem
|
||||
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
|
||||
closeOnClick={closeOnClick}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenuRadioItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItem>) {
|
||||
return (
|
||||
<BaseContextMenu.RadioItem
|
||||
className={cn(menuRowClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenuCheckboxItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItem>) {
|
||||
return (
|
||||
<BaseContextMenu.CheckboxItem
|
||||
className={cn(menuRowClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ContextMenuIndicatorProps = Omit<React.ComponentPropsWithoutRef<'span'>, 'children'> & {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function ContextMenuItemIndicator({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextMenuIndicatorProps) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(menuIndicatorClassName, className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <span aria-hidden className="i-ri-check-line h-4 w-4" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenuCheckboxItemIndicator({
|
||||
className,
|
||||
...props
|
||||
}: Omit<React.ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItemIndicator>, 'children'>) {
|
||||
return (
|
||||
<BaseContextMenu.CheckboxItemIndicator
|
||||
className={cn(menuIndicatorClassName, className)}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4" />
|
||||
</BaseContextMenu.CheckboxItemIndicator>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenuRadioItemIndicator({
|
||||
className,
|
||||
...props
|
||||
}: Omit<React.ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItemIndicator>, 'children'>) {
|
||||
return (
|
||||
<BaseContextMenu.RadioItemIndicator
|
||||
className={cn(menuIndicatorClassName, className)}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4" />
|
||||
</BaseContextMenu.RadioItemIndicator>
|
||||
)
|
||||
}
|
||||
|
||||
type ContextMenuSubTriggerProps = React.ComponentPropsWithoutRef<typeof BaseContextMenu.SubmenuTrigger> & {
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
export function ContextMenuSubTrigger({
|
||||
className,
|
||||
destructive,
|
||||
children,
|
||||
...props
|
||||
}: ContextMenuSubTriggerProps) {
|
||||
return (
|
||||
<BaseContextMenu.SubmenuTrigger
|
||||
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span aria-hidden className="i-ri-arrow-right-s-line ml-auto size-4 shrink-0 text-text-tertiary" />
|
||||
</BaseContextMenu.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
type ContextMenuSubContentProps = {
|
||||
children: React.ReactNode
|
||||
placement?: Placement
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
positionerProps?: ContextMenuContentProps['positionerProps']
|
||||
popupProps?: ContextMenuContentProps['popupProps']
|
||||
}
|
||||
|
||||
export function ContextMenuSubContent({
|
||||
children,
|
||||
placement = 'right-start',
|
||||
sideOffset = 4,
|
||||
alignOffset = 0,
|
||||
className,
|
||||
popupClassName,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
}: ContextMenuSubContentProps) {
|
||||
return renderContextMenuPopup({
|
||||
children,
|
||||
placement,
|
||||
sideOffset,
|
||||
alignOffset,
|
||||
className,
|
||||
popupClassName,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
})
|
||||
}
|
||||
|
||||
export function ContextMenuGroupLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.GroupLabel>) {
|
||||
return (
|
||||
<BaseContextMenu.GroupLabel
|
||||
className={cn(menuGroupLabelClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.Separator>) {
|
||||
return (
|
||||
<BaseContextMenu.Separator
|
||||
className={cn(menuSeparatorClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,13 +1,12 @@
|
||||
import { Menu } from '@base-ui/react/menu'
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import Link from 'next/link'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuLinkItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
@ -15,18 +14,22 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '../index'
|
||||
|
||||
describe('dropdown-menu wrapper', () => {
|
||||
describe('alias exports', () => {
|
||||
it('should map direct aliases to the corresponding Menu primitive when importing menu roots', () => {
|
||||
expect(DropdownMenu).toBe(Menu.Root)
|
||||
expect(DropdownMenuPortal).toBe(Menu.Portal)
|
||||
expect(DropdownMenuTrigger).toBe(Menu.Trigger)
|
||||
expect(DropdownMenuSub).toBe(Menu.SubmenuRoot)
|
||||
expect(DropdownMenuGroup).toBe(Menu.Group)
|
||||
expect(DropdownMenuRadioGroup).toBe(Menu.RadioGroup)
|
||||
})
|
||||
})
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
href: string
|
||||
children?: ReactNode
|
||||
} & Omit<ComponentPropsWithoutRef<'a'>, 'href'>) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('dropdown-menu wrapper', () => {
|
||||
describe('DropdownMenuContent', () => {
|
||||
it('should position content at bottom-end with default placement when props are omitted', () => {
|
||||
render(
|
||||
@ -250,6 +253,99 @@ describe('dropdown-menu wrapper', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('DropdownMenuLinkItem', () => {
|
||||
it('should render as anchor and keep href/target attributes when link props are provided', () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLinkItem href="https://example.com/docs" target="_blank" rel="noopener noreferrer">
|
||||
Docs
|
||||
</DropdownMenuLinkItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('menuitem', { name: 'Docs' })
|
||||
expect(link.tagName.toLowerCase()).toBe('a')
|
||||
expect(link).toHaveAttribute('href', 'https://example.com/docs')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it('should keep link semantics and not leak closeOnClick prop when closeOnClick is false', () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLinkItem
|
||||
href="https://example.com/docs"
|
||||
closeOnClick={false}
|
||||
aria-label="docs link"
|
||||
>
|
||||
Docs
|
||||
</DropdownMenuLinkItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('menuitem', { name: 'docs link' })
|
||||
expect(link.tagName.toLowerCase()).toBe('a')
|
||||
expect(link).toHaveAttribute('href', 'https://example.com/docs')
|
||||
expect(link).not.toHaveAttribute('closeOnClick')
|
||||
})
|
||||
|
||||
it('should preserve link semantics when render prop uses a custom link component', () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLinkItem
|
||||
render={<Link href="/account" />}
|
||||
aria-label="account link"
|
||||
>
|
||||
Account settings
|
||||
</DropdownMenuLinkItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('menuitem', { name: 'account link' })
|
||||
expect(link.tagName.toLowerCase()).toBe('a')
|
||||
expect(link).toHaveAttribute('href', '/account')
|
||||
expect(link).toHaveTextContent('Account settings')
|
||||
})
|
||||
|
||||
it.each([true, false])('should remain interactive and not leak destructive prop when destructive is %s', (destructive) => {
|
||||
const handleClick = vi.fn()
|
||||
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLinkItem
|
||||
destructive={destructive}
|
||||
href="https://example.com/docs"
|
||||
aria-label="docs link"
|
||||
id={`menu-link-${String(destructive)}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Docs
|
||||
</DropdownMenuLinkItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('menuitem', { name: 'docs link' })
|
||||
fireEvent.click(link)
|
||||
|
||||
expect(link.tagName.toLowerCase()).toBe('a')
|
||||
expect(link).toHaveAttribute('id', `menu-link-${String(destructive)}`)
|
||||
expect(link).not.toHaveAttribute('destructive')
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DropdownMenuSeparator', () => {
|
||||
it('should forward passthrough props and handlers when separator props are provided', () => {
|
||||
const handleMouseEnter = vi.fn()
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuGroupLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLinkItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuRadioItemIndicator,
|
||||
@ -234,6 +235,22 @@ export const WithIcons: Story = {
|
||||
),
|
||||
}
|
||||
|
||||
export const WithLinkItems: Story = {
|
||||
render: () => (
|
||||
<DropdownMenu>
|
||||
<TriggerButton label="Open links" />
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLinkItem href="https://docs.dify.ai" rel="noopener noreferrer" target="_blank">
|
||||
Dify Docs
|
||||
</DropdownMenuLinkItem>
|
||||
<DropdownMenuLinkItem href="https://roadmap.dify.ai" rel="noopener noreferrer" target="_blank">
|
||||
Product Roadmap
|
||||
</DropdownMenuLinkItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
}
|
||||
|
||||
const ComplexDemo = () => {
|
||||
const [sortOrder, setSortOrder] = useState('newest')
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
|
||||
@ -3,6 +3,14 @@
|
||||
import type { Placement } from '@/app/components/base/ui/placement'
|
||||
import { Menu } from '@base-ui/react/menu'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
menuGroupLabelClassName,
|
||||
menuIndicatorClassName,
|
||||
menuPopupAnimationClassName,
|
||||
menuPopupBaseClassName,
|
||||
menuRowClassName,
|
||||
menuSeparatorClassName,
|
||||
} from '@/app/components/base/ui/menu-shared'
|
||||
import { parsePlacement } from '@/app/components/base/ui/placement'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
@ -13,20 +21,13 @@ export const DropdownMenuSub = Menu.SubmenuRoot
|
||||
export const DropdownMenuGroup = Menu.Group
|
||||
export const DropdownMenuRadioGroup = Menu.RadioGroup
|
||||
|
||||
const menuRowBaseClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-none'
|
||||
const menuRowStateClassName = 'data-[highlighted]:bg-state-base-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30'
|
||||
|
||||
export function DropdownMenuRadioItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof Menu.RadioItem>) {
|
||||
return (
|
||||
<Menu.RadioItem
|
||||
className={cn(
|
||||
menuRowBaseClassName,
|
||||
menuRowStateClassName,
|
||||
className,
|
||||
)}
|
||||
className={cn(menuRowClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@ -38,10 +39,7 @@ export function DropdownMenuRadioItemIndicator({
|
||||
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.RadioItemIndicator>, 'children'>) {
|
||||
return (
|
||||
<Menu.RadioItemIndicator
|
||||
className={cn(
|
||||
'ml-auto flex shrink-0 items-center text-text-accent',
|
||||
className,
|
||||
)}
|
||||
className={cn(menuIndicatorClassName, className)}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4" />
|
||||
@ -55,11 +53,7 @@ export function DropdownMenuCheckboxItem({
|
||||
}: React.ComponentPropsWithoutRef<typeof Menu.CheckboxItem>) {
|
||||
return (
|
||||
<Menu.CheckboxItem
|
||||
className={cn(
|
||||
menuRowBaseClassName,
|
||||
menuRowStateClassName,
|
||||
className,
|
||||
)}
|
||||
className={cn(menuRowClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@ -71,10 +65,7 @@ export function DropdownMenuCheckboxItemIndicator({
|
||||
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.CheckboxItemIndicator>, 'children'>) {
|
||||
return (
|
||||
<Menu.CheckboxItemIndicator
|
||||
className={cn(
|
||||
'ml-auto flex shrink-0 items-center text-text-accent',
|
||||
className,
|
||||
)}
|
||||
className={cn(menuIndicatorClassName, className)}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4" />
|
||||
@ -88,10 +79,7 @@ export function DropdownMenuGroupLabel({
|
||||
}: React.ComponentPropsWithoutRef<typeof Menu.GroupLabel>) {
|
||||
return (
|
||||
<Menu.GroupLabel
|
||||
className={cn(
|
||||
'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase',
|
||||
className,
|
||||
)}
|
||||
className={cn(menuGroupLabelClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@ -148,8 +136,8 @@ function renderDropdownMenuPopup({
|
||||
>
|
||||
<Menu.Popup
|
||||
className={cn(
|
||||
'max-h-[var(--available-height)] overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg backdrop-blur-[5px]',
|
||||
'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
|
||||
menuPopupBaseClassName,
|
||||
menuPopupAnimationClassName,
|
||||
popupClassName,
|
||||
)}
|
||||
{...popupProps}
|
||||
@ -195,12 +183,7 @@ export function DropdownMenuSubTrigger({
|
||||
}: DropdownMenuSubTriggerProps) {
|
||||
return (
|
||||
<Menu.SubmenuTrigger
|
||||
className={cn(
|
||||
menuRowBaseClassName,
|
||||
menuRowStateClassName,
|
||||
destructive && 'text-text-destructive',
|
||||
className,
|
||||
)}
|
||||
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@ -253,12 +236,26 @@ export function DropdownMenuItem({
|
||||
}: DropdownMenuItemProps) {
|
||||
return (
|
||||
<Menu.Item
|
||||
className={cn(
|
||||
menuRowBaseClassName,
|
||||
menuRowStateClassName,
|
||||
destructive && 'text-text-destructive',
|
||||
className,
|
||||
)}
|
||||
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DropdownMenuLinkItemProps = React.ComponentPropsWithoutRef<typeof Menu.LinkItem> & {
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
export function DropdownMenuLinkItem({
|
||||
className,
|
||||
destructive,
|
||||
closeOnClick = true,
|
||||
...props
|
||||
}: DropdownMenuLinkItemProps) {
|
||||
return (
|
||||
<Menu.LinkItem
|
||||
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
|
||||
closeOnClick={closeOnClick}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@ -270,7 +267,7 @@ export function DropdownMenuSeparator({
|
||||
}: React.ComponentPropsWithoutRef<typeof Menu.Separator>) {
|
||||
return (
|
||||
<Menu.Separator
|
||||
className={cn('my-1 h-px bg-divider-subtle', className)}
|
||||
className={cn(menuSeparatorClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
7
web/app/components/base/ui/menu-shared.ts
Normal file
7
web/app/components/base/ui/menu-shared.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export const menuRowClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-none data-[highlighted]:bg-state-base-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30'
|
||||
export const menuIndicatorClassName = 'ml-auto flex shrink-0 items-center text-text-accent'
|
||||
export const menuGroupLabelClassName = 'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase'
|
||||
export const menuSeparatorClassName = 'my-1 h-px bg-divider-subtle'
|
||||
export const menuPopupBaseClassName = 'max-h-[var(--available-height)] overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg outline-none focus:outline-none focus-visible:outline-none backdrop-blur-[5px]'
|
||||
export const menuPopupAnimationClassName = 'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none'
|
||||
export const menuBackdropClassName = 'fixed inset-0 z-50 bg-transparent transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none'
|
||||
@ -225,5 +225,97 @@ describe('Compliance', () => {
|
||||
payload: ACCOUNT_SETTING_TAB.BILLING,
|
||||
})
|
||||
})
|
||||
|
||||
// isPending branches: spinner visible, disabled class, guard blocks second call
|
||||
it('should show spinner and guard against duplicate download when isPending is true', async () => {
|
||||
// Arrange
|
||||
let resolveDownload: (value: { url: string }) => void
|
||||
vi.mocked(getDocDownloadUrl).mockImplementation(() => new Promise((resolve) => {
|
||||
resolveDownload = resolve
|
||||
}))
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: Plan.team,
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
openMenuAndRender()
|
||||
const downloadButtons = screen.getAllByText('common.operation.download')
|
||||
fireEvent.click(downloadButtons[0])
|
||||
|
||||
// Assert - btn-disabled class and spinner should appear while mutation is pending
|
||||
await waitFor(() => {
|
||||
const menuItem = screen.getByText('common.compliance.soc2Type1').closest('[role="menuitem"]')
|
||||
expect(menuItem).not.toBeNull()
|
||||
const disabledBtn = menuItem!.querySelector('.cursor-not-allowed')
|
||||
expect(disabledBtn).not.toBeNull()
|
||||
}, { timeout: 10000 })
|
||||
|
||||
// Cleanup: resolve the pending promise
|
||||
resolveDownload!({ url: 'http://example.com/doc.pdf' })
|
||||
await waitFor(() => {
|
||||
expect(downloadUrl).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call downloadCompliance again while pending', async () => {
|
||||
let resolveDownload: (value: { url: string }) => void
|
||||
vi.mocked(getDocDownloadUrl).mockImplementation(() => new Promise((resolve) => {
|
||||
resolveDownload = resolve
|
||||
}))
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: Plan.team,
|
||||
},
|
||||
})
|
||||
|
||||
openMenuAndRender()
|
||||
const downloadButtons = screen.getAllByText('common.operation.download')
|
||||
|
||||
// First click starts download
|
||||
fireEvent.click(downloadButtons[0])
|
||||
|
||||
// Wait for mutation to start and React to re-render (isPending=true)
|
||||
await waitFor(() => {
|
||||
const menuItem = screen.getByText('common.compliance.soc2Type1').closest('[role="menuitem"]')
|
||||
const el = menuItem!.querySelector('.cursor-not-allowed')
|
||||
expect(el).not.toBeNull()
|
||||
expect(getDocDownloadUrl).toHaveBeenCalledTimes(1)
|
||||
}, { timeout: 10000 })
|
||||
|
||||
// Second click while pending - should be guarded by isPending check
|
||||
fireEvent.click(downloadButtons[0])
|
||||
|
||||
resolveDownload!({ url: 'http://example.com/doc.pdf' })
|
||||
await waitFor(() => {
|
||||
expect(downloadUrl).toHaveBeenCalledTimes(1)
|
||||
}, { timeout: 10000 })
|
||||
// getDocDownloadUrl should still have only been called once
|
||||
expect(getDocDownloadUrl).toHaveBeenCalledTimes(1)
|
||||
}, 20000)
|
||||
|
||||
// canShowUpgradeTooltip=false: enterprise plan has empty tooltip text → no TooltipContent
|
||||
it('should show upgrade badge with empty tooltip for enterprise plan', () => {
|
||||
// Arrange
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: Plan.enterprise,
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
openMenuAndRender()
|
||||
|
||||
// Assert - enterprise is not in any download list, so upgrade badges should appear
|
||||
// The key branch: upgradeTooltip[Plan.enterprise] = '' → canShowUpgradeTooltip=false
|
||||
expect(screen.getAllByText('billing.upgradeBtn.encourageShort').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -184,7 +184,7 @@ export default function Compliance() {
|
||||
<DropdownMenuSubContent
|
||||
popupClassName="w-[337px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
|
||||
>
|
||||
<DropdownMenuGroup className="p-1">
|
||||
<DropdownMenuGroup className="py-1">
|
||||
<ComplianceDocRowItem
|
||||
icon={<Soc2 aria-hidden className="size-7 shrink-0" />}
|
||||
label={t('compliance.soc2Type1', { ns: 'common' })}
|
||||
|
||||
@ -247,6 +247,23 @@ describe('AccountDropdown', () => {
|
||||
// Assert
|
||||
expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Compound AND middle-false: IS_CLOUD_EDITION=true but isCurrentWorkspaceOwner=false
|
||||
it('should hide Compliance in Cloud Edition when user is not workspace owner', () => {
|
||||
// Arrange
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
...baseAppContextValue,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
renderWithRouter(<AppSelector />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('common.userProfile.compliance')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Actions', () => {
|
||||
|
||||
@ -9,7 +9,7 @@ import { resetUser } from '@/app/components/base/amplitude/utils'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import ThemeSwitcher from '@/app/components/base/theme-switcher'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
@ -41,12 +41,12 @@ function AccountMenuRouteItem({
|
||||
trailing,
|
||||
}: AccountMenuRouteItemProps) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuLinkItem
|
||||
className="justify-between"
|
||||
render={<Link href={href} />}
|
||||
>
|
||||
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuLinkItem>
|
||||
)
|
||||
}
|
||||
|
||||
@ -64,12 +64,14 @@ function AccountMenuExternalItem({
|
||||
trailing,
|
||||
}: AccountMenuExternalItemProps) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuLinkItem
|
||||
className="justify-between"
|
||||
render={<a href={href} rel="noopener noreferrer" target="_blank" />}
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuLinkItem>
|
||||
)
|
||||
}
|
||||
|
||||
@ -101,7 +103,7 @@ type AccountMenuSectionProps = {
|
||||
}
|
||||
|
||||
function AccountMenuSection({ children }: AccountMenuSectionProps) {
|
||||
return <DropdownMenuGroup className="p-1">{children}</DropdownMenuGroup>
|
||||
return <DropdownMenuGroup className="py-1">{children}</DropdownMenuGroup>
|
||||
}
|
||||
|
||||
export default function AppSelector() {
|
||||
@ -144,8 +146,8 @@ export default function AppSelector() {
|
||||
sideOffset={6}
|
||||
popupClassName="w-60 max-w-80 !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
|
||||
>
|
||||
<DropdownMenuGroup className="px-1 py-1">
|
||||
<div className="flex flex-nowrap items-center py-2 pl-3 pr-2">
|
||||
<DropdownMenuGroup className="py-1">
|
||||
<div className="mx-1 flex flex-nowrap items-center py-2 pl-3 pr-2">
|
||||
<div className="grow">
|
||||
<div className="break-all text-text-primary system-md-medium">
|
||||
{userProfile.name}
|
||||
|
||||
@ -36,8 +36,8 @@ vi.mock('@/config', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
IS_CE_EDITION: false,
|
||||
get ZENDESK_WIDGET_KEY() { return mockZendeskKey.value },
|
||||
get SUPPORT_EMAIL_ADDRESS() { return mockSupportEmailKey.value },
|
||||
get ZENDESK_WIDGET_KEY() { return mockZendeskKey.value || '' },
|
||||
get SUPPORT_EMAIL_ADDRESS() { return mockSupportEmailKey.value || '' },
|
||||
}
|
||||
})
|
||||
|
||||
@ -173,25 +173,18 @@ describe('Support', () => {
|
||||
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show email support if specified in the config', () => {
|
||||
// Optional chain null guard: ZENDESK_WIDGET_KEY is null
|
||||
it('should show Email Support when ZENDESK_WIDGET_KEY is null', () => {
|
||||
// Arrange
|
||||
mockZendeskKey.value = ''
|
||||
mockSupportEmailKey.value = 'support@example.com'
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: Plan.sandbox,
|
||||
},
|
||||
})
|
||||
mockZendeskKey.value = null as unknown as string
|
||||
|
||||
// Act
|
||||
renderSupport()
|
||||
fireEvent.click(screen.getByText('common.userProfile.support'))
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('common.userProfile.emailSupport')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.userProfile.emailSupport')?.closest('a')?.getAttribute('href')?.startsWith(`mailto:${mockSupportEmailKey.value}`)).toBe(true)
|
||||
expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
|
||||
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
|
||||
import { toggleZendeskWindow } from '@/app/components/base/zendesk/utils'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { SUPPORT_EMAIL_ADDRESS, ZENDESK_WIDGET_KEY } from '@/config'
|
||||
@ -31,7 +31,7 @@ export default function Support({ closeAccountDropdown }: SupportProps) {
|
||||
<DropdownMenuSubContent
|
||||
popupClassName="w-[216px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
|
||||
>
|
||||
<DropdownMenuGroup className="p-1">
|
||||
<DropdownMenuGroup className="py-1">
|
||||
{hasDedicatedChannel && hasZendeskWidget && (
|
||||
<DropdownMenuItem
|
||||
className="justify-between"
|
||||
@ -47,37 +47,43 @@ export default function Support({ closeAccountDropdown }: SupportProps) {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasDedicatedChannel && !hasZendeskWidget && (
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuLinkItem
|
||||
className="justify-between"
|
||||
render={<a href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version, SUPPORT_EMAIL_ADDRESS)} rel="noopener noreferrer" target="_blank" />}
|
||||
href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version, SUPPORT_EMAIL_ADDRESS)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<MenuItemContent
|
||||
iconClassName="i-ri-mail-send-line"
|
||||
label={t('userProfile.emailSupport', { ns: 'common' })}
|
||||
trailing={<ExternalLinkIndicator />}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuLinkItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuLinkItem
|
||||
className="justify-between"
|
||||
render={<a href="https://forum.dify.ai/" rel="noopener noreferrer" target="_blank" />}
|
||||
href="https://forum.dify.ai/"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<MenuItemContent
|
||||
iconClassName="i-ri-discuss-line"
|
||||
label={t('userProfile.forum', { ns: 'common' })}
|
||||
trailing={<ExternalLinkIndicator />}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
</DropdownMenuLinkItem>
|
||||
<DropdownMenuLinkItem
|
||||
className="justify-between"
|
||||
render={<a href="https://discord.gg/5AEfbxcd9k" rel="noopener noreferrer" target="_blank" />}
|
||||
href="https://discord.gg/5AEfbxcd9k"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<MenuItemContent
|
||||
iconClassName="i-ri-discord-line"
|
||||
label={t('userProfile.community', { ns: 'common' })}
|
||||
trailing={<ExternalLinkIndicator />}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuLinkItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
@ -136,4 +136,32 @@ describe('WorkplaceSelector', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
// find() returns undefined: no workspace with current: true
|
||||
it('should not crash when no workspace has current: true', () => {
|
||||
// Arrange
|
||||
vi.mocked(useWorkspacesContext).mockReturnValue({
|
||||
workspaces: [
|
||||
{ id: '1', name: 'Workspace 1', current: false, plan: 'professional', status: 'normal', created_at: Date.now() },
|
||||
],
|
||||
})
|
||||
|
||||
// Act & Assert - should not throw
|
||||
expect(() => renderComponent()).not.toThrow()
|
||||
})
|
||||
|
||||
// name[0]?.toLocaleUpperCase() undefined: workspace with empty name
|
||||
it('should not crash when workspace name is empty string', () => {
|
||||
// Arrange
|
||||
vi.mocked(useWorkspacesContext).mockReturnValue({
|
||||
workspaces: [
|
||||
{ id: '1', name: '', current: true, plan: 'sandbox', status: 'normal', created_at: Date.now() },
|
||||
],
|
||||
})
|
||||
|
||||
// Act & Assert - should not throw
|
||||
expect(() => renderComponent()).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -388,37 +388,33 @@ describe('DataSourceNotion Component', () => {
|
||||
})
|
||||
|
||||
describe('Additional Action Edge Cases', () => {
|
||||
it('should cover all possible falsy/nullish branches for connection data in handleAuthAgain and useEffect', async () => {
|
||||
it.each([
|
||||
undefined,
|
||||
null,
|
||||
{},
|
||||
{ data: undefined },
|
||||
{ data: null },
|
||||
{ data: '' },
|
||||
{ data: 0 },
|
||||
{ data: false },
|
||||
{ data: 'http' },
|
||||
{ data: 'internal' },
|
||||
{ data: 'unknown' },
|
||||
])('should cover connection data branch: %s', async (val) => {
|
||||
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces }))
|
||||
/* eslint-disable-next-line ts/no-explicit-any */
|
||||
vi.mocked(useNotionConnection).mockReturnValue({ data: val, isSuccess: true } as any)
|
||||
|
||||
render(<DataSourceNotion />)
|
||||
|
||||
const connectionCases = [
|
||||
undefined,
|
||||
null,
|
||||
{},
|
||||
{ data: undefined },
|
||||
{ data: null },
|
||||
{ data: '' },
|
||||
{ data: 0 },
|
||||
{ data: false },
|
||||
{ data: 'http' },
|
||||
{ data: 'internal' },
|
||||
{ data: 'unknown' },
|
||||
]
|
||||
// Trigger handleAuthAgain with these values
|
||||
const workspaceItem = getWorkspaceItem('Workspace 1')
|
||||
const actionBtn = within(workspaceItem).getByRole('button')
|
||||
fireEvent.click(actionBtn)
|
||||
const authAgainBtn = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
|
||||
fireEvent.click(authAgainBtn)
|
||||
|
||||
for (const val of connectionCases) {
|
||||
/* eslint-disable-next-line ts/no-explicit-any */
|
||||
vi.mocked(useNotionConnection).mockReturnValue({ data: val, isSuccess: true } as any)
|
||||
|
||||
// Trigger handleAuthAgain with these values
|
||||
const workspaceItem = getWorkspaceItem('Workspace 1')
|
||||
const actionBtn = within(workspaceItem).getByRole('button')
|
||||
fireEvent.click(actionBtn)
|
||||
const authAgainBtn = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
|
||||
fireEvent.click(authAgainBtn)
|
||||
}
|
||||
|
||||
await waitFor(() => expect(useNotionConnection).toHaveBeenCalled())
|
||||
expect(useNotionConnection).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -134,5 +134,46 @@ describe('ConfigJinaReaderModal Component', () => {
|
||||
resolveSave!({ result: 'success' })
|
||||
await waitFor(() => expect(mockOnSaved).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('should show encryption info and external link in the modal', async () => {
|
||||
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
// Verify PKCS1_OAEP link exists
|
||||
const pkcsLink = screen.getByText('PKCS1_OAEP')
|
||||
expect(pkcsLink.closest('a')).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html')
|
||||
|
||||
// Verify the Jina Reader external link
|
||||
const jinaLink = screen.getByRole('link', { name: /datasetCreation\.jinaReader\.getApiKeyLinkText/i })
|
||||
expect(jinaLink).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
it('should return early when save is clicked while already saving (isSaving guard)', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange - a save that never resolves so isSaving stays true
|
||||
let resolveFirst: (value: { result: 'success' }) => void
|
||||
const neverResolves = new Promise<{ result: 'success' }>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
vi.mocked(createDataSourceApiKeyBinding).mockReturnValue(neverResolves)
|
||||
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder')
|
||||
await user.type(apiKeyInput, 'valid-key')
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /common\.operation\.save/i })
|
||||
// First click - starts saving, isSaving becomes true
|
||||
await user.click(saveBtn)
|
||||
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second click using fireEvent bypasses disabled check - hits isSaving guard
|
||||
const { fireEvent: fe } = await import('@testing-library/react')
|
||||
fe.click(saveBtn)
|
||||
// Still only called once because isSaving=true returns early
|
||||
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Cleanup
|
||||
resolveFirst!({ result: 'success' })
|
||||
await waitFor(() => expect(mockOnSaved).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -195,4 +195,57 @@ describe('DataSourceWebsite Component', () => {
|
||||
expect(removeDataSourceApiKeyBinding).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Firecrawl Save Flow', () => {
|
||||
it('should re-fetch sources after saving Firecrawl configuration', async () => {
|
||||
// Arrange
|
||||
await renderAndWait(DataSourceProvider.fireCrawl)
|
||||
fireEvent.click(screen.getByText('common.dataSource.configure'))
|
||||
expect(screen.getByText('datasetCreation.firecrawl.configFirecrawl')).toBeInTheDocument()
|
||||
vi.mocked(fetchDataSources).mockClear()
|
||||
|
||||
// Act - fill in required API key field and save
|
||||
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder')
|
||||
fireEvent.change(apiKeyInput, { target: { value: 'test-key' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(fetchDataSources).toHaveBeenCalled()
|
||||
expect(screen.queryByText('datasetCreation.firecrawl.configFirecrawl')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cancel Flow', () => {
|
||||
it('should close watercrawl modal when cancel is clicked', async () => {
|
||||
// Arrange
|
||||
await renderAndWait(DataSourceProvider.waterCrawl)
|
||||
fireEvent.click(screen.getByText('common.dataSource.configure'))
|
||||
expect(screen.getByText('datasetCreation.watercrawl.configWatercrawl')).toBeInTheDocument()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
|
||||
|
||||
// Assert - modal closed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('datasetCreation.watercrawl.configWatercrawl')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close jina reader modal when cancel is clicked', async () => {
|
||||
// Arrange
|
||||
await renderAndWait(DataSourceProvider.jinaReader)
|
||||
fireEvent.click(screen.getByText('common.dataSource.configure'))
|
||||
expect(screen.getByText('datasetCreation.jinaReader.configJinaReader')).toBeInTheDocument()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
|
||||
|
||||
// Assert - modal closed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('datasetCreation.jinaReader.configJinaReader')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Operate from './Operate'
|
||||
|
||||
describe('Operate', () => {
|
||||
it('renders cancel and save when editing', () => {
|
||||
it('should render cancel and save when editing is open', () => {
|
||||
render(
|
||||
<Operate
|
||||
isOpen
|
||||
@ -18,7 +19,7 @@ describe('Operate', () => {
|
||||
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows add key prompt when closed', () => {
|
||||
it('should show add-key prompt when closed', () => {
|
||||
render(
|
||||
<Operate
|
||||
isOpen={false}
|
||||
@ -33,7 +34,7 @@ describe('Operate', () => {
|
||||
expect(screen.getByText('common.provider.addKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows invalid state indicator and edit prompt when status is fail', () => {
|
||||
it('should show invalid state and edit prompt when status is fail', () => {
|
||||
render(
|
||||
<Operate
|
||||
isOpen={false}
|
||||
@ -49,7 +50,7 @@ describe('Operate', () => {
|
||||
expect(screen.getByText('common.provider.editKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows edit prompt without error text when status is success', () => {
|
||||
it('should show edit prompt without error text when status is success', () => {
|
||||
render(
|
||||
<Operate
|
||||
isOpen={false}
|
||||
@ -65,11 +66,30 @@ describe('Operate', () => {
|
||||
expect(screen.queryByText('common.provider.invalidApiKey')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no actions for unsupported status', () => {
|
||||
it('should not call onAdd when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onAdd = vi.fn()
|
||||
render(
|
||||
<Operate
|
||||
isOpen={false}
|
||||
status={'unknown' as never}
|
||||
status="add"
|
||||
disabled
|
||||
onAdd={onAdd}
|
||||
onCancel={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
await user.click(screen.getByText('common.provider.addKey'))
|
||||
expect(onAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show no actions when status is unsupported', () => {
|
||||
render(
|
||||
<Operate
|
||||
isOpen={false}
|
||||
// @ts-expect-error intentional invalid status for runtime fallback coverage
|
||||
status="unknown"
|
||||
onAdd={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
|
||||
@ -267,6 +267,99 @@ describe('MembersPage', () => {
|
||||
expect(screen.getByText(/plansCommon\.unlimited/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show non-billing member format for team plan even when billing is enabled', () => {
|
||||
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
|
||||
enableBilling: true,
|
||||
plan: {
|
||||
type: Plan.team,
|
||||
total: { teamMembers: 50 } as unknown as ReturnType<typeof useProviderContext>['plan']['total'],
|
||||
} as unknown as ReturnType<typeof useProviderContext>['plan'],
|
||||
}))
|
||||
|
||||
render(<MembersPage />)
|
||||
|
||||
// Plan.team is an unlimited member plan → isNotUnlimitedMemberPlan=false → non-billing layout
|
||||
expect(screen.getByText(/plansCommon\.memberAfter/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show invite button when user is manager but not owner', () => {
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
userProfile: { email: 'admin@example.com' },
|
||||
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
isCurrentWorkspaceManager: true,
|
||||
} as unknown as AppContextValue)
|
||||
|
||||
render(<MembersPage />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /invite/i })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use created_at as fallback when last_active_at is empty', () => {
|
||||
const memberNoLastActive: Member = {
|
||||
...mockAccounts[1],
|
||||
last_active_at: '',
|
||||
created_at: '1700000000',
|
||||
}
|
||||
vi.mocked(useMembers).mockReturnValue({
|
||||
data: { accounts: [memberNoLastActive] },
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useMembers>)
|
||||
|
||||
render(<MembersPage />)
|
||||
|
||||
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(1700000000000)
|
||||
})
|
||||
|
||||
it('should not show plural s when only one account in billing layout', () => {
|
||||
vi.mocked(useMembers).mockReturnValue({
|
||||
data: { accounts: [mockAccounts[0]] },
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useMembers>)
|
||||
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
|
||||
enableBilling: true,
|
||||
plan: {
|
||||
type: Plan.sandbox,
|
||||
total: { teamMembers: 5 } as unknown as ReturnType<typeof useProviderContext>['plan']['total'],
|
||||
} as unknown as ReturnType<typeof useProviderContext>['plan'],
|
||||
}))
|
||||
|
||||
render(<MembersPage />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.member/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show plural s when only one account in non-billing layout', () => {
|
||||
vi.mocked(useMembers).mockReturnValue({
|
||||
data: { accounts: [mockAccounts[0]] },
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useMembers>)
|
||||
|
||||
render(<MembersPage />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.memberAfter/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show normal role as fallback for unknown role', () => {
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
userProfile: { email: 'admin@example.com' },
|
||||
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
isCurrentWorkspaceManager: false,
|
||||
} as unknown as AppContextValue)
|
||||
vi.mocked(useMembers).mockReturnValue({
|
||||
data: { accounts: [{ ...mockAccounts[1], role: 'unknown_role' as Member['role'] }] },
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useMembers>)
|
||||
|
||||
render(<MembersPage />)
|
||||
|
||||
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show upgrade button when member limit is full', () => {
|
||||
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
|
||||
enableBilling: true,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { InvitationResponse } from '@/models/common'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { vi } from 'vitest'
|
||||
import { ToastContext } from '@/app/components/base/toast/context'
|
||||
@ -171,6 +171,66 @@ describe('InviteModal', () => {
|
||||
expect(screen.queryByText('user@example.com')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show unlimited label when workspace member limit is zero', async () => {
|
||||
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
|
||||
licenseLimit: { workspace_members: { size: 5, limit: 0 } },
|
||||
refreshLicenseLimit: mockRefreshLicenseLimit,
|
||||
} as unknown as Parameters<typeof selector>[0]))
|
||||
|
||||
renderModal()
|
||||
|
||||
expect(await screen.findByText(/license\.unlimited/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should initialize usedSize to zero when workspace_members.size is null', async () => {
|
||||
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
|
||||
licenseLimit: { workspace_members: { size: null, limit: 10 } },
|
||||
refreshLicenseLimit: mockRefreshLicenseLimit,
|
||||
} as unknown as Parameters<typeof selector>[0]))
|
||||
|
||||
renderModal()
|
||||
|
||||
// usedSize starts at 0 (via ?? 0 fallback), no emails added → counter shows 0
|
||||
expect(await screen.findByText('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not call onSend when invite result is not success', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(inviteMember).mockResolvedValue({
|
||||
result: 'error',
|
||||
invitation_results: [],
|
||||
} as unknown as InvitationResponse)
|
||||
|
||||
renderModal()
|
||||
|
||||
await user.type(screen.getByTestId('mock-email-input'), 'user@example.com')
|
||||
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(inviteMember).toHaveBeenCalled()
|
||||
expect(mockOnSend).not.toHaveBeenCalled()
|
||||
expect(mockOnCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show destructive text color when used size exceeds limit', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
|
||||
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
|
||||
refreshLicenseLimit: mockRefreshLicenseLimit,
|
||||
} as unknown as Parameters<typeof selector>[0]))
|
||||
|
||||
renderModal()
|
||||
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
|
||||
// usedSize = 10 + 1 = 11 > limit 10 → destructive color
|
||||
const counter = screen.getByText('11')
|
||||
expect(counter.closest('div')).toHaveClass('text-text-destructive')
|
||||
})
|
||||
|
||||
it('should not submit if already submitting', async () => {
|
||||
const user = userEvent.setup()
|
||||
let resolveInvite: (value: InvitationResponse) => void
|
||||
@ -202,4 +262,72 @@ describe('InviteModal', () => {
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show destructive color and disable send button when limit is exactly met with one email', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
// size=10, limit=10 - adding 1 email makes usedSize=11 > limit=10
|
||||
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
|
||||
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
|
||||
refreshLicenseLimit: mockRefreshLicenseLimit,
|
||||
} as unknown as Parameters<typeof selector>[0]))
|
||||
|
||||
renderModal()
|
||||
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
|
||||
// isLimitExceeded=true → button is disabled, cannot submit
|
||||
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
|
||||
expect(sendBtn).toBeDisabled()
|
||||
expect(inviteMember).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should hit isSubmitting guard inside handleSend when button is force-clicked during submission', async () => {
|
||||
const user = userEvent.setup()
|
||||
let resolveInvite: (value: InvitationResponse) => void
|
||||
const invitePromise = new Promise<InvitationResponse>((resolve) => {
|
||||
resolveInvite = resolve
|
||||
})
|
||||
vi.mocked(inviteMember).mockReturnValue(invitePromise)
|
||||
|
||||
renderModal()
|
||||
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
|
||||
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
|
||||
|
||||
// First click starts submission
|
||||
await user.click(sendBtn)
|
||||
expect(inviteMember).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Force-click bypasses disabled attribute → hits isSubmitting guard in handleSend
|
||||
fireEvent.click(sendBtn)
|
||||
expect(inviteMember).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Cleanup
|
||||
resolveInvite!({ result: 'success', invitation_results: [] })
|
||||
await waitFor(() => {
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not show error text color when isLimited is false even with many emails', async () => {
|
||||
// size=0, limit=0 → isLimited=false, usedSize=emails.length
|
||||
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
|
||||
licenseLimit: { workspace_members: { size: 0, limit: 0 } },
|
||||
refreshLicenseLimit: mockRefreshLicenseLimit,
|
||||
} as unknown as Parameters<typeof selector>[0]))
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderModal()
|
||||
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
|
||||
// isLimited=false → no destructive color
|
||||
const counter = screen.getByText('1')
|
||||
expect(counter.closest('div')).not.toHaveClass('text-text-destructive')
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,8 +2,12 @@ import type { InvitationResult } from '@/models/common'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import InvitedModal from './index'
|
||||
|
||||
const mockConfigState = vi.hoisted(() => ({ isCeEdition: true }))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
IS_CE_EDITION: true,
|
||||
get IS_CE_EDITION() {
|
||||
return mockConfigState.isCeEdition
|
||||
},
|
||||
}))
|
||||
|
||||
describe('InvitedModal', () => {
|
||||
@ -13,6 +17,11 @@ describe('InvitedModal', () => {
|
||||
{ email: 'failed@example.com', status: 'failed', message: 'Error msg' },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockConfigState.isCeEdition = true
|
||||
})
|
||||
|
||||
it('should show success and failed invitation sections', async () => {
|
||||
render(<InvitedModal invitationResults={results} onCancel={mockOnCancel} />)
|
||||
|
||||
@ -21,4 +30,59 @@ describe('InvitedModal', () => {
|
||||
expect(screen.getByText('http://invite.com/1')).toBeInTheDocument()
|
||||
expect(screen.getByText('failed@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide invitation link section when there are no successes', () => {
|
||||
const failedOnly: InvitationResult[] = [
|
||||
{ email: 'fail@example.com', status: 'failed', message: 'Quota exceeded' },
|
||||
]
|
||||
|
||||
render(<InvitedModal invitationResults={failedOnly} onCancel={mockOnCancel} />)
|
||||
|
||||
expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/members\.failedInvitationEmails/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide failed section when there are only successes', () => {
|
||||
const successOnly: InvitationResult[] = [
|
||||
{ email: 'ok@example.com', status: 'success', url: 'http://invite.com/2' },
|
||||
]
|
||||
|
||||
render(<InvitedModal invitationResults={successOnly} onCancel={mockOnCancel} />)
|
||||
|
||||
expect(screen.getByText(/members\.invitationLink/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/members\.failedInvitationEmails/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide both sections when results are empty', () => {
|
||||
render(<InvitedModal invitationResults={[]} onCancel={mockOnCancel} />)
|
||||
|
||||
expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/members\.failedInvitationEmails/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('InvitedModal (non-CE edition)', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockConfigState.isCeEdition = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockConfigState.isCeEdition = true
|
||||
})
|
||||
|
||||
it('should render invitationSentTip without CE edition content when IS_CE_EDITION is false', async () => {
|
||||
const results: InvitationResult[] = [
|
||||
{ email: 'success@example.com', status: 'success', url: 'http://invite.com/1' },
|
||||
]
|
||||
|
||||
render(<InvitedModal invitationResults={results} onCancel={mockOnCancel} />)
|
||||
|
||||
// The !IS_CE_EDITION branch - should show the tip text
|
||||
expect(await screen.findByText(/members\.invitationSentTip/i)).toBeInTheDocument()
|
||||
// CE-only content should not be shown
|
||||
expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -49,13 +49,13 @@ describe('Operation', () => {
|
||||
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: false })
|
||||
})
|
||||
|
||||
it('renders the current role label', () => {
|
||||
it('should render the current role label when member has editor role', () => {
|
||||
renderOperation()
|
||||
|
||||
expect(screen.getByText('common.members.editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows dataset operator option when the feature flag is enabled', async () => {
|
||||
it('should show dataset operator option when feature flag is enabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
|
||||
@ -66,7 +66,7 @@ describe('Operation', () => {
|
||||
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows owner-allowed role options for admin operators', async () => {
|
||||
it('should show owner-allowed role options when operator role is admin', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderOperation({}, 'admin')
|
||||
@ -77,7 +77,7 @@ describe('Operation', () => {
|
||||
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show role options for unsupported operators', async () => {
|
||||
it('should not show role options when operator role is unsupported', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderOperation({}, 'normal')
|
||||
@ -88,7 +88,7 @@ describe('Operation', () => {
|
||||
expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls updateMemberRole and onOperate when selecting another role', async () => {
|
||||
it('should call updateMemberRole and onOperate when selecting another role', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOperate = vi.fn()
|
||||
renderOperation({}, 'owner', onOperate)
|
||||
@ -102,7 +102,24 @@ describe('Operation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('calls deleteMemberOrCancelInvitation when removing the member', async () => {
|
||||
it('should show dataset operator option when operator is admin and feature flag is enabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
|
||||
renderOperation({}, 'admin')
|
||||
|
||||
await user.click(screen.getByText('common.members.editor'))
|
||||
|
||||
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.members.admin')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fall back to normal role label when member role is unknown', () => {
|
||||
renderOperation({ role: 'unknown_role' as Member['role'] })
|
||||
|
||||
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call deleteMemberOrCancelInvitation when removing the member', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOperate = vi.fn()
|
||||
renderOperation({}, 'owner', onOperate)
|
||||
|
||||
@ -13,11 +13,6 @@ vi.mock('@/context/app-context')
|
||||
vi.mock('@/service/common')
|
||||
vi.mock('@/service/use-common')
|
||||
|
||||
// Mock Modal directly to avoid transition/portal issues in tests
|
||||
vi.mock('@/app/components/base/modal', () => ({
|
||||
default: ({ children, isShow }: { children: React.ReactNode, isShow: boolean }) => isShow ? <div data-testid="mock-modal">{children}</div> : null,
|
||||
}))
|
||||
|
||||
vi.mock('./member-selector', () => ({
|
||||
default: ({ onSelect }: { onSelect: (id: string) => void }) => (
|
||||
<button onClick={() => onSelect('new-owner-id')}>Select member</button>
|
||||
@ -40,11 +35,13 @@ describe('TransferOwnershipModal', () => {
|
||||
data: { accounts: [] },
|
||||
} as unknown as ReturnType<typeof useMembers>)
|
||||
|
||||
// Fix Location stubbing for reload
|
||||
// Stub globalThis.location.reload (component calls globalThis.location.reload())
|
||||
const mockReload = vi.fn()
|
||||
vi.stubGlobal('location', {
|
||||
...window.location,
|
||||
reload: mockReload,
|
||||
href: '',
|
||||
assign: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
} as unknown as Location)
|
||||
})
|
||||
|
||||
@ -105,8 +102,8 @@ describe('TransferOwnershipModal', () => {
|
||||
await waitFor(() => {
|
||||
expect(ownershipTransfer).toHaveBeenCalledWith('new-owner-id', { token: 'final-token' })
|
||||
expect(window.location.reload).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
}, { timeout: 10000 })
|
||||
}, 15000)
|
||||
|
||||
it('should handle timer countdown and resend', async () => {
|
||||
vi.useFakeTimers()
|
||||
@ -202,6 +199,70 @@ describe('TransferOwnershipModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle sendOwnerEmail returning null data', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(sendOwnerEmail).mockResolvedValue({
|
||||
data: null,
|
||||
result: 'success',
|
||||
} as unknown as Awaited<ReturnType<typeof sendOwnerEmail>>)
|
||||
|
||||
renderModal()
|
||||
await user.click(screen.getByTestId('transfer-modal-send-code'))
|
||||
|
||||
// Should advance to verify step even with null data
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/members\.transferModal\.verifyEmail/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show fallback error prefix when sendOwnerEmail throws null', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(sendOwnerEmail).mockRejectedValue(null)
|
||||
|
||||
renderModal()
|
||||
await user.click(screen.getByTestId('transfer-modal-send-code'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
message: expect.stringContaining('Error sending verification code:'),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should show fallback error prefix when verifyOwnerEmail throws null', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockEmailVerification()
|
||||
vi.mocked(verifyOwnerEmail).mockRejectedValue(null)
|
||||
|
||||
renderModal()
|
||||
await goToTransferStep(user)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
message: expect.stringContaining('Error verifying email:'),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should show fallback error prefix when ownershipTransfer throws null', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockEmailVerification()
|
||||
vi.mocked(ownershipTransfer).mockRejectedValue(null)
|
||||
|
||||
renderModal()
|
||||
await goToTransferStep(user)
|
||||
await selectNewOwnerAndSubmit(user)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
message: expect.stringContaining('Error ownership transfer:'),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should close when close button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderModal()
|
||||
|
||||
@ -71,9 +71,80 @@ describe('MemberSelector', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should filter list by email when name does not match', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<MemberSelector onSelect={mockOnSelect} />)
|
||||
|
||||
await user.click(screen.getByTestId('member-selector-trigger'))
|
||||
await user.type(screen.getByTestId('member-selector-search'), 'john@')
|
||||
|
||||
const items = screen.getAllByTestId('member-selector-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Jane Smith')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show placeholder when value does not match any account', () => {
|
||||
render(<MemberSelector value="nonexistent-id" onSelect={mockOnSelect} />)
|
||||
|
||||
expect(screen.getByText(/members\.transferModal\.transferPlaceholder/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle missing data gracefully', () => {
|
||||
vi.mocked(useMembers).mockReturnValue({ data: undefined } as unknown as ReturnType<typeof useMembers>)
|
||||
render(<MemberSelector onSelect={mockOnSelect} />)
|
||||
expect(screen.getByText(/members\.transferModal\.transferPlaceholder/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter by email when account name is empty', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(useMembers).mockReturnValue({
|
||||
data: { accounts: [...mockAccounts, { id: '4', name: '', email: 'noname@example.com', avatar_url: '' }] },
|
||||
} as unknown as ReturnType<typeof useMembers>)
|
||||
render(<MemberSelector onSelect={mockOnSelect} />)
|
||||
|
||||
await user.click(screen.getByTestId('member-selector-trigger'))
|
||||
await user.type(screen.getByTestId('member-selector-search'), 'noname@')
|
||||
|
||||
const items = screen.getAllByTestId('member-selector-item')
|
||||
expect(items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should apply hover background class when dropdown is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<MemberSelector onSelect={mockOnSelect} />)
|
||||
|
||||
const trigger = screen.getByTestId('member-selector-trigger')
|
||||
await user.click(trigger)
|
||||
|
||||
expect(trigger).toHaveClass('bg-state-base-hover-alt')
|
||||
})
|
||||
|
||||
it('should not match account when neither name nor email contains search value', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<MemberSelector onSelect={mockOnSelect} />)
|
||||
|
||||
await user.click(screen.getByTestId('member-selector-trigger'))
|
||||
await user.type(screen.getByTestId('member-selector-search'), 'xyz-no-match-xyz')
|
||||
|
||||
expect(screen.queryByTestId('member-selector-item')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fall back to empty string for account with undefined email when searching', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(useMembers).mockReturnValue({
|
||||
data: {
|
||||
accounts: [
|
||||
{ id: '1', name: 'John', email: undefined as unknown as string, avatar_url: '' },
|
||||
],
|
||||
},
|
||||
} as unknown as ReturnType<typeof useMembers>)
|
||||
render(<MemberSelector onSelect={mockOnSelect} />)
|
||||
|
||||
await user.click(screen.getByTestId('member-selector-trigger'))
|
||||
await user.type(screen.getByTestId('member-selector-search'), 'john')
|
||||
|
||||
const items = screen.getAllByTestId('member-selector-item')
|
||||
expect(items).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -433,6 +433,55 @@ describe('hooks', () => {
|
||||
|
||||
expect(result.current.credentials).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not call invalidateQueries when neither predefined nor custom is enabled', () => {
|
||||
const invalidateQueries = vi.fn()
|
||||
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
|
||||
; (useQuery as Mock).mockReturnValue({
|
||||
data: undefined,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
// Both predefinedEnabled and customEnabled are false (no credentialId)
|
||||
const { result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
|
||||
'openai',
|
||||
ConfigurationMethodEnum.predefinedModel,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
))
|
||||
|
||||
act(() => {
|
||||
result.current.mutate()
|
||||
})
|
||||
|
||||
expect(invalidateQueries).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should build URL without credentialId when not provided in predefined queryFn', async () => {
|
||||
// Trigger the queryFn when credentialId is undefined but predefinedEnabled is true
|
||||
; (useQuery as Mock).mockReturnValue({
|
||||
data: { credentials: { api_key: 'k' } },
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
const { result: _result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
|
||||
'openai',
|
||||
ConfigurationMethodEnum.predefinedModel,
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
))
|
||||
|
||||
// Find and invoke the predefined queryFn
|
||||
const queryCall = (useQuery as Mock).mock.calls.find(
|
||||
call => call[0].queryKey?.[1] === 'credentials',
|
||||
)
|
||||
if (queryCall) {
|
||||
await queryCall[0].queryFn()
|
||||
expect(fetchModelProviderCredentials).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('useModelList', () => {
|
||||
@ -1111,6 +1160,26 @@ describe('hooks', () => {
|
||||
expect(result.current.plugins![0].plugin_id).toBe('plugin1')
|
||||
})
|
||||
|
||||
it('should deduplicate plugins that exist in both collections and regular plugins', () => {
|
||||
const duplicatePlugin = { plugin_id: 'shared-plugin', type: 'plugin' }
|
||||
|
||||
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
|
||||
plugins: [duplicatePlugin],
|
||||
isLoading: false,
|
||||
})
|
||||
; (useMarketplacePlugins as Mock).mockReturnValue({
|
||||
plugins: [{ ...duplicatePlugin }, { plugin_id: 'unique-plugin', type: 'plugin' }],
|
||||
queryPlugins: vi.fn(),
|
||||
queryPluginsWithDebounced: vi.fn(),
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
|
||||
|
||||
expect(result.current.plugins).toHaveLength(2)
|
||||
expect(result.current.plugins!.filter(p => p.plugin_id === 'shared-plugin')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle loading states', () => {
|
||||
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
|
||||
plugins: [],
|
||||
@ -1127,6 +1196,45 @@ describe('hooks', () => {
|
||||
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
})
|
||||
|
||||
it('should not crash when plugins is undefined', () => {
|
||||
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
|
||||
plugins: [],
|
||||
isLoading: false,
|
||||
})
|
||||
; (useMarketplacePlugins as Mock).mockReturnValue({
|
||||
plugins: undefined,
|
||||
queryPlugins: vi.fn(),
|
||||
queryPluginsWithDebounced: vi.fn(),
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
|
||||
|
||||
expect(result.current.plugins).toBeDefined()
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should return search plugins (not allPlugins) when searchText is truthy', () => {
|
||||
const searchPlugins = [{ plugin_id: 'search-result', type: 'plugin' }]
|
||||
const collectionPlugins = [{ plugin_id: 'collection-only', type: 'plugin' }]
|
||||
|
||||
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
|
||||
plugins: collectionPlugins,
|
||||
isLoading: false,
|
||||
})
|
||||
; (useMarketplacePlugins as Mock).mockReturnValue({
|
||||
plugins: searchPlugins,
|
||||
queryPlugins: vi.fn(),
|
||||
queryPluginsWithDebounced: vi.fn(),
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceAllPlugins([], 'openai'))
|
||||
|
||||
expect(result.current.plugins).toEqual(searchPlugins)
|
||||
expect(result.current.plugins?.some(p => p.plugin_id === 'collection-only')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useRefreshModel', () => {
|
||||
@ -1234,6 +1342,35 @@ describe('hooks', () => {
|
||||
expect(emit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should emit event and invalidate all supported model types when __model_type is undefined', () => {
|
||||
const invalidateQueries = vi.fn()
|
||||
const emit = vi.fn()
|
||||
|
||||
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
|
||||
; (useEventEmitterContextContext as Mock).mockReturnValue({
|
||||
eventEmitter: { emit },
|
||||
})
|
||||
|
||||
const provider = createMockProvider()
|
||||
const customFields = { __model_name: 'my-model', __model_type: undefined } as unknown as CustomConfigurationModelFixedFields
|
||||
|
||||
const { result } = renderHook(() => useRefreshModel())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshModel(provider, customFields, true)
|
||||
})
|
||||
|
||||
expect(emit).toHaveBeenCalledWith({
|
||||
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
|
||||
payload: 'openai',
|
||||
})
|
||||
// When __model_type is undefined, all supported model types are invalidated
|
||||
const modelListCalls = invalidateQueries.mock.calls.filter(
|
||||
call => call[0]?.queryKey?.[0] === 'model-list',
|
||||
)
|
||||
expect(modelListCalls).toHaveLength(provider.supported_model_types.length)
|
||||
})
|
||||
|
||||
it('should handle provider with single model type', () => {
|
||||
const invalidateQueries = vi.fn()
|
||||
|
||||
|
||||
@ -60,7 +60,15 @@ vi.mock('@/context/provider-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockDefaultModelState = {
|
||||
type MockDefaultModelData = {
|
||||
model: string
|
||||
provider?: { provider: string }
|
||||
} | null
|
||||
|
||||
const mockDefaultModelState: {
|
||||
data: MockDefaultModelData
|
||||
isLoading: boolean
|
||||
} = {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
}
|
||||
@ -196,4 +204,129 @@ describe('ModelProviderPage', () => {
|
||||
])
|
||||
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show not configured alert when all default models are absent', () => {
|
||||
mockDefaultModelState.data = null
|
||||
mockDefaultModelState.isLoading = false
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.notConfigured')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show not configured alert when default model is loading', () => {
|
||||
mockDefaultModelState.data = null
|
||||
mockDefaultModelState.isLoading = true
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter providers by label text', () => {
|
||||
render(<ModelProviderPage searchText="OpenAI" />)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
expect(screen.getByText('openai')).toBeInTheDocument()
|
||||
expect(screen.queryByText('anthropic')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should classify system-enabled providers with matching quota as configured', () => {
|
||||
mockProviders.splice(0, mockProviders.length, {
|
||||
provider: 'sys-provider',
|
||||
label: { en_US: 'System Provider' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
})
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.getByText('sys-provider')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should classify system-enabled provider with no matching quota as not configured', () => {
|
||||
mockProviders.splice(0, mockProviders.length, {
|
||||
provider: 'sys-no-quota',
|
||||
label: { en_US: 'System No Quota' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [],
|
||||
},
|
||||
})
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.getByText('sys-no-quota')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve order of two non-fixed providers (sort returns 0)', () => {
|
||||
mockProviders.splice(0, mockProviders.length, {
|
||||
provider: 'alpha-provider',
|
||||
label: { en_US: 'Alpha Provider' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
}, {
|
||||
provider: 'beta-provider',
|
||||
label: { en_US: 'Beta Provider' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
})
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent)
|
||||
expect(renderedProviders).toEqual(['alpha-provider', 'beta-provider'])
|
||||
})
|
||||
|
||||
it('should not show not configured alert when shared default model mock has data', () => {
|
||||
mockDefaultModelState.data = { model: 'embed-model' }
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show not configured alert when rerankDefaultModel has data', () => {
|
||||
mockDefaultModelState.data = { model: 'rerank-model', provider: { provider: 'cohere' } }
|
||||
mockDefaultModelState.isLoading = false
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show not configured alert when ttsDefaultModel has data', () => {
|
||||
mockDefaultModelState.data = { model: 'tts-model', provider: { provider: 'openai' } }
|
||||
mockDefaultModelState.isLoading = false
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show not configured alert when speech2textDefaultModel has data', () => {
|
||||
mockDefaultModelState.data = { model: 'whisper', provider: { provider: 'openai' } }
|
||||
mockDefaultModelState.isLoading = false
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -96,4 +96,97 @@ describe('AddCredentialInLoadBalancing', () => {
|
||||
|
||||
expect(onSelectCredential).toHaveBeenCalledWith(modelCredential.available_credentials[0])
|
||||
})
|
||||
|
||||
// renderTrigger with open=true: bg-state-base-hover style applied
|
||||
it('should apply hover background when trigger is rendered with open=true', async () => {
|
||||
vi.doMock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({
|
||||
Authorized: ({
|
||||
renderTrigger,
|
||||
}: {
|
||||
renderTrigger: (open?: boolean) => React.ReactNode
|
||||
}) => (
|
||||
<div data-testid="open-trigger">{renderTrigger(true)}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Must invalidate module cache so the component picks up the new mock
|
||||
vi.resetModules()
|
||||
try {
|
||||
const { default: AddCredentialLB } = await import('./add-credential-in-load-balancing')
|
||||
|
||||
const { container } = render(
|
||||
<AddCredentialLB
|
||||
provider={provider}
|
||||
model={model}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
modelCredential={modelCredential}
|
||||
onSelectCredential={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// The trigger div rendered by renderTrigger(true) should have bg-state-base-hover
|
||||
// (the static class applied when open=true via cn())
|
||||
const triggerDiv = container.querySelector('[data-testid="open-trigger"] > div')
|
||||
expect(triggerDiv).toBeInTheDocument()
|
||||
expect(triggerDiv!.className).toContain('bg-state-base-hover')
|
||||
}
|
||||
finally {
|
||||
vi.doUnmock('@/app/components/header/account-setting/model-provider-page/model-auth')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
// customizableModel configuration method: component renders the add credential label
|
||||
it('should render correctly with customizableModel configuration method', () => {
|
||||
render(
|
||||
<AddCredentialInLoadBalancing
|
||||
provider={provider}
|
||||
model={model}
|
||||
configurationMethod={ConfigurationMethodEnum.customizableModel}
|
||||
modelCredential={modelCredential}
|
||||
onSelectCredential={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/modelProvider.auth.addCredential/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined available_credentials gracefully using nullish coalescing', () => {
|
||||
const credentialWithNoAvailable = {
|
||||
available_credentials: undefined,
|
||||
credentials: {},
|
||||
load_balancing: { enabled: false, configs: [] },
|
||||
} as unknown as typeof modelCredential
|
||||
|
||||
render(
|
||||
<AddCredentialInLoadBalancing
|
||||
provider={provider}
|
||||
model={model}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
modelCredential={credentialWithNoAvailable}
|
||||
onSelectCredential={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Component should render without error - the ?? [] fallback is used
|
||||
expect(screen.getByText(/modelProvider.auth.addCredential/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not throw when update action fires without onUpdate prop', () => {
|
||||
// Arrange - no onUpdate prop
|
||||
render(
|
||||
<AddCredentialInLoadBalancing
|
||||
provider={provider}
|
||||
model={model}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
modelCredential={modelCredential}
|
||||
onSelectCredential={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act - trigger the update without onUpdate being set (should not throw)
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Run update' }))
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@ -85,4 +85,69 @@ describe('CredentialItem', () => {
|
||||
|
||||
expect(onDelete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// All disable flags true → no action buttons rendered
|
||||
it('should hide all action buttons when disableRename, disableEdit, and disableDelete are all true', () => {
|
||||
// Act
|
||||
render(
|
||||
<CredentialItem
|
||||
credential={credential}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
disableRename
|
||||
disableEdit
|
||||
disableDelete
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('edit-icon')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('delete-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// disabled=true guards: clicks on the item row and on delete should both be no-ops
|
||||
it('should not call onItemClick when disabled=true and item is clicked', () => {
|
||||
const onItemClick = vi.fn()
|
||||
|
||||
render(<CredentialItem credential={credential} disabled onItemClick={onItemClick} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Test API Key'))
|
||||
|
||||
expect(onItemClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onDelete when disabled=true and delete button is clicked', () => {
|
||||
const onDelete = vi.fn()
|
||||
|
||||
render(<CredentialItem credential={credential} disabled onDelete={onDelete} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
|
||||
|
||||
expect(onDelete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// showSelectedIcon=true: check icon area is always rendered; check icon only appears when IDs match
|
||||
it('should render check icon area when showSelectedIcon=true and selectedCredentialId matches', () => {
|
||||
render(
|
||||
<CredentialItem
|
||||
credential={credential}
|
||||
showSelectedIcon
|
||||
selectedCredentialId="cred-1"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('check-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render check icon when showSelectedIcon=true but selectedCredentialId does not match', () => {
|
||||
render(
|
||||
<CredentialItem
|
||||
credential={credential}
|
||||
showSelectedIcon
|
||||
selectedCredentialId="other-cred"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('check-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -24,36 +24,6 @@ vi.mock('../hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
let mockPortalOpen = false
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => {
|
||||
mockPortalOpen = open
|
||||
return <div data-testid="portal" data-open={open}>{children}</div>
|
||||
},
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
|
||||
if (!mockPortalOpen)
|
||||
return null
|
||||
return <div data-testid="portal-content">{children}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({ isShow, onCancel, onConfirm }: { isShow: boolean, onCancel: () => void, onConfirm: () => void }) => {
|
||||
if (!isShow)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="confirm-dialog">
|
||||
<button onClick={onCancel}>Cancel</button>
|
||||
<button onClick={onConfirm}>Confirm</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./authorized-item', () => ({
|
||||
default: ({ credentials, model, onEdit, onDelete, onItemClick }: {
|
||||
credentials: Credential[]
|
||||
@ -105,382 +75,127 @@ describe('Authorized', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPortalOpen = false
|
||||
mockDeleteCredentialId = null
|
||||
mockDoingAction = false
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render trigger button', () => {
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
/>,
|
||||
)
|
||||
it('should render trigger and open popup when trigger is clicked', () => {
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/Trigger/)).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
|
||||
expect(screen.getByTestId('authorized-item')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /addApiKey/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render portal content when open', () => {
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
isOpen
|
||||
/>,
|
||||
)
|
||||
it('should call handleOpenModal when triggerOnlyOpenModal is true', () => {
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
triggerOnlyOpenModal
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('authorized-item')).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
|
||||
expect(mockHandleOpenModal).toHaveBeenCalled()
|
||||
expect(screen.queryByTestId('authorized-item')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render portal content when closed', () => {
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
/>,
|
||||
)
|
||||
it('should call onItemClick when credential is selected', () => {
|
||||
const onItemClick = vi.fn()
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0])
|
||||
|
||||
it('should render Add API Key button when not model credential', () => {
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
isOpen
|
||||
/>,
|
||||
)
|
||||
expect(onItemClick).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
|
||||
})
|
||||
|
||||
expect(screen.getByText(/addApiKey/)).toBeInTheDocument()
|
||||
})
|
||||
it('should call handleActiveCredential when onItemClick is not provided', () => {
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
/>,
|
||||
)
|
||||
|
||||
it('should render Add Model Credential button when is model credential', () => {
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
authParams={{ isModelCredential: true }}
|
||||
isOpen
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0])
|
||||
|
||||
expect(screen.getByText(/addModelCredential/)).toBeInTheDocument()
|
||||
})
|
||||
expect(mockHandleActiveCredential).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
|
||||
})
|
||||
|
||||
it('should not render add action when hideAddAction is true', () => {
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
hideAddAction
|
||||
isOpen
|
||||
/>,
|
||||
)
|
||||
it('should call handleOpenModal with fixed model fields when adding model credential', () => {
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.customizableModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
authParams={{ isModelCredential: true }}
|
||||
currentCustomConfigurationModelFixedFields={{
|
||||
__model_name: 'gpt-4',
|
||||
__model_type: ModelTypeEnum.textGeneration,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/addApiKey/)).not.toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
|
||||
fireEvent.click(screen.getByText(/addModelCredential/))
|
||||
|
||||
it('should render popup title when provided', () => {
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
popupTitle="Select Credential"
|
||||
isOpen
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Select Credential')).toBeInTheDocument()
|
||||
expect(mockHandleOpenModal).toHaveBeenCalledWith(undefined, {
|
||||
model: 'gpt-4',
|
||||
model_type: ModelTypeEnum.textGeneration,
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onOpenChange when trigger is clicked in controlled mode', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
it('should not render add action when hideAddAction is true', () => {
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
hideAddAction
|
||||
/>,
|
||||
)
|
||||
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
isOpen={false}
|
||||
onOpenChange={onOpenChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should toggle portal on trigger click', () => {
|
||||
const { rerender } = render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
rerender(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
isOpen
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open modal when triggerOnlyOpenModal is true', () => {
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
triggerOnlyOpenModal
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
expect(mockHandleOpenModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call handleOpenModal when Add API Key is clicked', () => {
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
isOpen
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/addApiKey/))
|
||||
|
||||
expect(mockHandleOpenModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call handleOpenModal with credential and model when edit is clicked', () => {
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
isOpen
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getAllByText('Edit')[0])
|
||||
|
||||
expect(mockHandleOpenModal).toHaveBeenCalledWith(
|
||||
mockCredentials[0],
|
||||
mockItems[0].model,
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass current model fields when adding model credential', () => {
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.customizableModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
authParams={{ isModelCredential: true }}
|
||||
currentCustomConfigurationModelFixedFields={{
|
||||
__model_name: 'gpt-4',
|
||||
__model_type: ModelTypeEnum.textGeneration,
|
||||
}}
|
||||
isOpen
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/addModelCredential/))
|
||||
|
||||
expect(mockHandleOpenModal).toHaveBeenCalledWith(undefined, {
|
||||
model: 'gpt-4',
|
||||
model_type: ModelTypeEnum.textGeneration,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onItemClick when credential is selected', () => {
|
||||
const onItemClick = vi.fn()
|
||||
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
onItemClick={onItemClick}
|
||||
isOpen
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getAllByText('Select')[0])
|
||||
|
||||
expect(onItemClick).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
|
||||
})
|
||||
|
||||
it('should call handleActiveCredential when onItemClick is not provided', () => {
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
isOpen
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getAllByText('Select')[0])
|
||||
|
||||
expect(mockHandleActiveCredential).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
|
||||
})
|
||||
|
||||
it('should not call onItemClick when disableItemClick is true', () => {
|
||||
const onItemClick = vi.fn()
|
||||
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
onItemClick={onItemClick}
|
||||
disableItemClick
|
||||
isOpen
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getAllByText('Select')[0])
|
||||
|
||||
expect(onItemClick).not.toHaveBeenCalled()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
|
||||
expect(screen.queryByRole('button', { name: /addApiKey/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Delete Confirmation', () => {
|
||||
it('should show confirm dialog when deleteCredentialId is set', () => {
|
||||
mockDeleteCredentialId = 'cred-1'
|
||||
it('should show confirm dialog and call confirm handler when delete is confirmed', () => {
|
||||
mockDeleteCredentialId = 'cred-1'
|
||||
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
/>,
|
||||
)
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show confirm dialog when deleteCredentialId is null', () => {
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call closeConfirmDelete when cancel is clicked', () => {
|
||||
mockDeleteCredentialId = 'cred-1'
|
||||
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
|
||||
expect(mockCloseConfirmDelete).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call handleConfirmDelete when confirm is clicked', () => {
|
||||
mockDeleteCredentialId = 'cred-1'
|
||||
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Confirm'))
|
||||
|
||||
expect(mockHandleConfirmDelete).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty items array', () => {
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={[]}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
isOpen
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('authorized-item')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render add action when provider does not allow custom token', () => {
|
||||
const restrictedProvider = { ...mockProvider, allow_custom_token: false }
|
||||
|
||||
render(
|
||||
<Authorized
|
||||
provider={restrictedProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
isOpen
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/addApiKey/)).not.toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /common.operation.confirm/i }))
|
||||
expect(mockHandleConfirmDelete).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ConfigProvider from './config-provider'
|
||||
|
||||
const mockUseCredentialStatus = vi.fn()
|
||||
@ -54,7 +55,8 @@ describe('ConfigProvider', () => {
|
||||
expect(screen.getByText(/operation.config/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should still render setup label when custom credentials are not allowed', () => {
|
||||
it('should show setup label and unavailable tooltip when custom credentials are not allowed and no credential exists', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockUseCredentialStatus.mockReturnValue({
|
||||
hasCredential: false,
|
||||
authorized: false,
|
||||
@ -65,6 +67,50 @@ describe('ConfigProvider', () => {
|
||||
|
||||
render(<ConfigProvider provider={{ ...baseProvider, allow_custom_token: false }} />)
|
||||
|
||||
expect(screen.getByText(/operation.setup/i)).toBeInTheDocument()
|
||||
await user.hover(screen.getByText(/operation.setup/i))
|
||||
expect(await screen.findByText(/auth\.credentialUnavailable/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show config label when hasCredential but not authorized', () => {
|
||||
mockUseCredentialStatus.mockReturnValue({
|
||||
hasCredential: true,
|
||||
authorized: false,
|
||||
current_credential_id: 'cred-1',
|
||||
current_credential_name: 'Key 1',
|
||||
available_credentials: [],
|
||||
})
|
||||
|
||||
render(<ConfigProvider provider={baseProvider} />)
|
||||
|
||||
expect(screen.getByText(/operation.config/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show config label when custom credentials are not allowed but credential exists', () => {
|
||||
mockUseCredentialStatus.mockReturnValue({
|
||||
hasCredential: true,
|
||||
authorized: true,
|
||||
current_credential_id: 'cred-1',
|
||||
current_credential_name: 'Key 1',
|
||||
available_credentials: [],
|
||||
})
|
||||
|
||||
render(<ConfigProvider provider={{ ...baseProvider, allow_custom_token: false }} />)
|
||||
|
||||
expect(screen.getByText(/operation.config/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle nullish credential values with fallbacks', () => {
|
||||
mockUseCredentialStatus.mockReturnValue({
|
||||
hasCredential: false,
|
||||
authorized: false,
|
||||
current_credential_id: null,
|
||||
current_credential_name: null,
|
||||
available_credentials: null,
|
||||
})
|
||||
|
||||
render(<ConfigProvider provider={baseProvider} />)
|
||||
|
||||
expect(screen.getByText(/operation.setup/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import CredentialSelector from './credential-selector'
|
||||
|
||||
// Mock components
|
||||
vi.mock('./authorized/credential-item', () => ({
|
||||
default: ({ credential, onItemClick }: { credential: { credential_name: string }, onItemClick: (c: unknown) => void }) => (
|
||||
<div data-testid="credential-item" onClick={() => onItemClick(credential)}>
|
||||
default: ({ credential, onItemClick }: { credential: { credential_name: string }, onItemClick?: (c: unknown) => void }) => (
|
||||
<button type="button" onClick={() => onItemClick?.(credential)}>
|
||||
{credential.credential_name}
|
||||
</div>
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -19,22 +19,6 @@ vi.mock('@remixicon/react', () => ({
|
||||
RiArrowDownSLine: () => <div data-testid="arrow-icon" />,
|
||||
}))
|
||||
|
||||
// Mock portal components
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
|
||||
<div data-testid="portal" data-open={open}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode, open?: boolean }) => {
|
||||
// We should only render children if open or if we want to test they are hidden
|
||||
// The real component might handle this with CSS or conditional rendering.
|
||||
// Let's use conditional rendering in the mock to avoid "multiple elements" errors.
|
||||
return <div data-testid="portal-content">{children}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
describe('CredentialSelector', () => {
|
||||
const mockCredentials = [
|
||||
{ credential_id: 'cred-1', credential_name: 'Key 1' },
|
||||
@ -46,7 +30,7 @@ describe('CredentialSelector', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render selected credential name', () => {
|
||||
it('should render selected credential name when selectedCredential is provided', () => {
|
||||
render(
|
||||
<CredentialSelector
|
||||
selectedCredential={mockCredentials[0]}
|
||||
@ -55,12 +39,11 @@ describe('CredentialSelector', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Use getAllByText and take the first one (the one in the trigger)
|
||||
expect(screen.getAllByText('Key 1')[0]).toBeInTheDocument()
|
||||
expect(screen.getByText('Key 1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('indicator')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render placeholder when no credential selected', () => {
|
||||
it('should render placeholder when selectedCredential is missing', () => {
|
||||
render(
|
||||
<CredentialSelector
|
||||
credentials={mockCredentials}
|
||||
@ -71,7 +54,8 @@ describe('CredentialSelector', () => {
|
||||
expect(screen.getByText(/modelProvider.auth.selectModelCredential/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open portal on click', () => {
|
||||
it('should call onSelect when a credential item is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<CredentialSelector
|
||||
credentials={mockCredentials}
|
||||
@ -79,26 +63,14 @@ describe('CredentialSelector', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true')
|
||||
expect(screen.getAllByTestId('credential-item')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should call onSelect when a credential is clicked', () => {
|
||||
render(
|
||||
<CredentialSelector
|
||||
credentials={mockCredentials}
|
||||
onSelect={mockOnSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByText('Key 2'))
|
||||
await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/))
|
||||
await user.click(screen.getByRole('button', { name: 'Key 2' }))
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(mockCredentials[1])
|
||||
})
|
||||
|
||||
it('should call onSelect with add new credential data when clicking add button', () => {
|
||||
it('should call onSelect with add-new payload when add action is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<CredentialSelector
|
||||
credentials={mockCredentials}
|
||||
@ -106,8 +78,8 @@ describe('CredentialSelector', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByText(/modelProvider.auth.addNewModelCredential/))
|
||||
await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/))
|
||||
await user.click(screen.getByText(/modelProvider.auth.addNewModelCredential/))
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({
|
||||
credential_id: '__add_new_credential',
|
||||
@ -115,7 +87,8 @@ describe('CredentialSelector', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('should not open portal when disabled', () => {
|
||||
it('should not open options when disabled is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<CredentialSelector
|
||||
disabled
|
||||
@ -124,7 +97,7 @@ describe('CredentialSelector', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
|
||||
await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/))
|
||||
expect(screen.queryByRole('button', { name: 'Key 1' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
Credential,
|
||||
CustomModel,
|
||||
ModelProvider,
|
||||
} from '../../declarations'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { ToastContext } from '@/app/components/base/toast/context'
|
||||
import { ConfigurationMethodEnum, ModelModalModeEnum, ModelTypeEnum } from '../../declarations'
|
||||
import { useAuth } from './use-auth'
|
||||
|
||||
@ -20,9 +22,13 @@ const mockAddModelCredential = vi.fn()
|
||||
const mockEditProviderCredential = vi.fn()
|
||||
const mockEditModelCredential = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: () => ({ notify: mockNotify }),
|
||||
}))
|
||||
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
|
||||
return {
|
||||
...actual,
|
||||
useToastContext: () => ({ notify: mockNotify }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelModalHandler: () => mockOpenModelModal,
|
||||
@ -66,6 +72,12 @@ describe('useAuth', () => {
|
||||
model_type: ModelTypeEnum.textGeneration,
|
||||
}
|
||||
|
||||
const createWrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
|
||||
{children}
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDeleteModelService.mockResolvedValue({ result: 'success' })
|
||||
@ -80,7 +92,7 @@ describe('useAuth', () => {
|
||||
})
|
||||
|
||||
it('should open and close delete confirmation state', () => {
|
||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
|
||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
|
||||
|
||||
act(() => {
|
||||
result.current.openConfirmDelete(credential, model)
|
||||
@ -100,7 +112,7 @@ describe('useAuth', () => {
|
||||
})
|
||||
|
||||
it('should activate credential, notify success, and refresh models', async () => {
|
||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel))
|
||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel), { wrapper: createWrapper })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleActiveCredential(credential, model)
|
||||
@ -120,7 +132,7 @@ describe('useAuth', () => {
|
||||
})
|
||||
|
||||
it('should close delete dialog without calling services when nothing is pending', async () => {
|
||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
|
||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleConfirmDelete()
|
||||
@ -137,7 +149,7 @@ describe('useAuth', () => {
|
||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel, undefined, {
|
||||
isModelCredential: false,
|
||||
onRemove,
|
||||
}))
|
||||
}), { wrapper: createWrapper })
|
||||
|
||||
act(() => {
|
||||
result.current.openConfirmDelete(credential, model)
|
||||
@ -161,7 +173,7 @@ describe('useAuth', () => {
|
||||
const onRemove = vi.fn()
|
||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel, undefined, {
|
||||
onRemove,
|
||||
}))
|
||||
}), { wrapper: createWrapper })
|
||||
|
||||
act(() => {
|
||||
result.current.openConfirmDelete(undefined, model)
|
||||
@ -179,7 +191,7 @@ describe('useAuth', () => {
|
||||
})
|
||||
|
||||
it('should add or edit credentials and refresh on successful save', async () => {
|
||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
|
||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSaveCredential({ api_key: 'new-key' })
|
||||
@ -200,7 +212,7 @@ describe('useAuth', () => {
|
||||
const deferred = createDeferred<{ result: string }>()
|
||||
mockAddProviderCredential.mockReturnValueOnce(deferred.promise)
|
||||
|
||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
|
||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
|
||||
|
||||
let first!: Promise<void>
|
||||
let second!: Promise<void>
|
||||
@ -226,7 +238,7 @@ describe('useAuth', () => {
|
||||
isModelCredential: true,
|
||||
onUpdate,
|
||||
mode: ModelModalModeEnum.configModelCredential,
|
||||
}))
|
||||
}), { wrapper: createWrapper })
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpenModal(credential, model)
|
||||
@ -244,4 +256,90 @@ describe('useAuth', () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not notify or refresh when handleSaveCredential returns non-success result', async () => {
|
||||
mockAddProviderCredential.mockResolvedValue({ result: 'error' })
|
||||
|
||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSaveCredential({ api_key: 'some-key' })
|
||||
})
|
||||
|
||||
expect(mockAddProviderCredential).toHaveBeenCalledWith({ api_key: 'some-key' })
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
expect(mockHandleRefreshModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should pass undefined model and model_type when handleActiveCredential is called without a model parameter', async () => {
|
||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleActiveCredential(credential)
|
||||
})
|
||||
|
||||
expect(mockActiveProviderCredential).toHaveBeenCalledWith({
|
||||
credential_id: 'cred-1',
|
||||
model: undefined,
|
||||
model_type: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
// openConfirmDelete with credential only (no model): deleteCredentialId set, deleteModel stays null
|
||||
it('should only set deleteCredentialId when openConfirmDelete is called without a model', () => {
|
||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
|
||||
|
||||
act(() => {
|
||||
result.current.openConfirmDelete(credential, undefined)
|
||||
})
|
||||
|
||||
expect(result.current.deleteCredentialId).toBe('cred-1')
|
||||
expect(result.current.deleteModel).toBeNull()
|
||||
expect(result.current.pendingOperationCredentialId.current).toBe('cred-1')
|
||||
expect(result.current.pendingOperationModel.current).toBeNull()
|
||||
})
|
||||
|
||||
// doingActionRef guard: second handleConfirmDelete call while first is in progress is a no-op
|
||||
it('should ignore a second handleConfirmDelete call while the first is still in progress', async () => {
|
||||
const deferred = createDeferred<{ result: string }>()
|
||||
mockDeleteProviderCredential.mockReturnValueOnce(deferred.promise)
|
||||
|
||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
|
||||
|
||||
act(() => {
|
||||
result.current.openConfirmDelete(credential, model)
|
||||
})
|
||||
|
||||
let first!: Promise<void>
|
||||
let second!: Promise<void>
|
||||
|
||||
await act(async () => {
|
||||
first = result.current.handleConfirmDelete()
|
||||
second = result.current.handleConfirmDelete()
|
||||
deferred.resolve({ result: 'success' })
|
||||
await Promise.all([first, second])
|
||||
})
|
||||
|
||||
expect(mockDeleteProviderCredential).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// doingActionRef guard: second handleActiveCredential call while first is in progress is a no-op
|
||||
it('should ignore a second handleActiveCredential call while the first is still in progress', async () => {
|
||||
const deferred = createDeferred<{ result: string }>()
|
||||
mockActiveProviderCredential.mockReturnValueOnce(deferred.promise)
|
||||
|
||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
|
||||
|
||||
let first!: Promise<void>
|
||||
let second!: Promise<void>
|
||||
|
||||
await act(async () => {
|
||||
first = result.current.handleActiveCredential(credential)
|
||||
second = result.current.handleActiveCredential(credential)
|
||||
deferred.resolve({ result: 'success' })
|
||||
await Promise.all([first, second])
|
||||
})
|
||||
|
||||
expect(mockActiveProviderCredential).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -13,11 +13,13 @@ vi.mock('./hooks', () => ({
|
||||
|
||||
// Mock Authorized
|
||||
vi.mock('./authorized', () => ({
|
||||
default: ({ renderTrigger, items, popupTitle }: { renderTrigger: (o?: boolean) => React.ReactNode, items: { length: number }, popupTitle: string }) => (
|
||||
default: ({ renderTrigger, items, popupTitle }: { renderTrigger: (o?: boolean) => React.ReactNode, items: Array<{ selectedCredential?: unknown }>, popupTitle: string }) => (
|
||||
<div data-testid="authorized-mock">
|
||||
<div data-testid="trigger-container">{renderTrigger()}</div>
|
||||
<div data-testid="trigger-closed">{renderTrigger()}</div>
|
||||
<div data-testid="trigger-open">{renderTrigger(true)}</div>
|
||||
<div data-testid="popup-title">{popupTitle}</div>
|
||||
<div data-testid="items-count">{items.length}</div>
|
||||
<div data-testid="items-selected">{items.map((it, i) => <span key={i} data-testid={`selected-${i}`}>{it.selectedCredential ? 'has-cred' : 'no-cred'}</span>)}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@ -55,8 +57,41 @@ describe('ManageCustomModelCredentials', () => {
|
||||
render(<ManageCustomModelCredentials provider={mockProvider} />)
|
||||
|
||||
expect(screen.getByTestId('authorized-mock')).toBeInTheDocument()
|
||||
expect(screen.getByText(/modelProvider.auth.manageCredentials/)).toBeInTheDocument()
|
||||
expect(screen.getAllByText(/modelProvider.auth.manageCredentials/).length).toBeGreaterThan(0)
|
||||
expect(screen.getByTestId('items-count')).toHaveTextContent('2')
|
||||
expect(screen.getByTestId('popup-title')).toHaveTextContent('modelProvider.auth.customModelCredentials')
|
||||
})
|
||||
|
||||
it('should render trigger in both open and closed states', () => {
|
||||
const mockModels = [
|
||||
{
|
||||
model: 'gpt-4',
|
||||
available_model_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
|
||||
current_credential_id: 'c1',
|
||||
current_credential_name: 'Key 1',
|
||||
},
|
||||
]
|
||||
mockUseCustomModels.mockReturnValue(mockModels)
|
||||
|
||||
render(<ManageCustomModelCredentials provider={mockProvider} />)
|
||||
|
||||
expect(screen.getByTestId('trigger-closed')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('trigger-open')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass undefined selectedCredential when model has no current_credential_id', () => {
|
||||
const mockModels = [
|
||||
{
|
||||
model: 'gpt-3.5',
|
||||
available_model_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
|
||||
current_credential_id: '',
|
||||
current_credential_name: '',
|
||||
},
|
||||
]
|
||||
mockUseCustomModels.mockReturnValue(mockModels)
|
||||
|
||||
render(<ManageCustomModelCredentials provider={mockProvider} />)
|
||||
|
||||
expect(screen.getByTestId('selected-0')).toHaveTextContent('no-cred')
|
||||
})
|
||||
})
|
||||
|
||||
@ -18,15 +18,6 @@ vi.mock('@/app/components/header/indicator', () => ({
|
||||
default: ({ color }: { color: string }) => <div data-testid={`indicator-${color}`} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
|
||||
<div data-testid="tooltip-mock">
|
||||
{children}
|
||||
<div>{popupContent}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiArrowDownSLine: () => <div data-testid="arrow-icon" />,
|
||||
}))
|
||||
@ -125,6 +116,131 @@ describe('SwitchCredentialInLoadBalancing', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.mouseEnter(screen.getByText(/auth.credentialUnavailableInButton/))
|
||||
expect(screen.getByText('plugin.auth.credentialUnavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Empty credentials with allowed custom: no tooltip but still shows unavailable text
|
||||
it('should show unavailable status without tooltip when custom credentials are allowed', () => {
|
||||
// Act
|
||||
render(
|
||||
<SwitchCredentialInLoadBalancing
|
||||
provider={mockProvider}
|
||||
model={mockModel}
|
||||
credentials={[]}
|
||||
customModelCredential={undefined}
|
||||
setCustomModelCredential={mockSetCustomModelCredential}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('plugin.auth.credentialUnavailable')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// not_allowed_to_use=true: indicator is red and destructive button text is shown
|
||||
it('should show red indicator and unavailable button text when credential has not_allowed_to_use=true', () => {
|
||||
const unavailableCredential = { credential_id: 'cred-1', credential_name: 'Key 1', not_allowed_to_use: true }
|
||||
|
||||
render(
|
||||
<SwitchCredentialInLoadBalancing
|
||||
provider={mockProvider}
|
||||
model={mockModel}
|
||||
credentials={[unavailableCredential]}
|
||||
customModelCredential={unavailableCredential}
|
||||
setCustomModelCredential={mockSetCustomModelCredential}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('indicator-red')).toBeInTheDocument()
|
||||
expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// from_enterprise=true on the selected credential: Enterprise badge appears in the trigger
|
||||
it('should show Enterprise badge when selected credential has from_enterprise=true', () => {
|
||||
const enterpriseCredential = { credential_id: 'cred-1', credential_name: 'Enterprise Key', from_enterprise: true }
|
||||
|
||||
render(
|
||||
<SwitchCredentialInLoadBalancing
|
||||
provider={mockProvider}
|
||||
model={mockModel}
|
||||
credentials={[enterpriseCredential]}
|
||||
customModelCredential={enterpriseCredential}
|
||||
setCustomModelCredential={mockSetCustomModelCredential}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Enterprise')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// non-empty credentials with allow_custom_token=false: no tooltip (tooltip only for empty+notAllowCustom)
|
||||
it('should not show unavailable tooltip when credentials are non-empty and allow_custom_token=false', () => {
|
||||
const restrictedProvider = { ...mockProvider, allow_custom_token: false }
|
||||
|
||||
render(
|
||||
<SwitchCredentialInLoadBalancing
|
||||
provider={restrictedProvider}
|
||||
model={mockModel}
|
||||
credentials={mockCredentials}
|
||||
customModelCredential={mockCredentials[0]}
|
||||
setCustomModelCredential={mockSetCustomModelCredential}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.mouseEnter(screen.getByText('Key 1'))
|
||||
expect(screen.queryByText('plugin.auth.credentialUnavailable')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Key 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass undefined currentCustomConfigurationModelFixedFields when model is undefined', () => {
|
||||
render(
|
||||
<SwitchCredentialInLoadBalancing
|
||||
provider={mockProvider}
|
||||
// @ts-expect-error testing runtime handling when model is omitted
|
||||
model={undefined}
|
||||
credentials={mockCredentials}
|
||||
customModelCredential={mockCredentials[0]}
|
||||
setCustomModelCredential={mockSetCustomModelCredential}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Component still renders (Authorized receives undefined currentCustomConfigurationModelFixedFields)
|
||||
expect(screen.getByTestId('authorized-mock')).toBeInTheDocument()
|
||||
expect(screen.getByText('Key 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should treat undefined credentials as empty list', () => {
|
||||
render(
|
||||
<SwitchCredentialInLoadBalancing
|
||||
provider={mockProvider}
|
||||
model={mockModel}
|
||||
credentials={undefined}
|
||||
customModelCredential={undefined}
|
||||
setCustomModelCredential={mockSetCustomModelCredential}
|
||||
/>,
|
||||
)
|
||||
|
||||
// credentials is undefined → empty=true → unavailable text shown
|
||||
expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument()
|
||||
expect(screen.queryByTestId(/indicator-/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render nothing for credential_name when it is empty string', () => {
|
||||
const credWithEmptyName = { credential_id: 'cred-1', credential_name: '' }
|
||||
|
||||
render(
|
||||
<SwitchCredentialInLoadBalancing
|
||||
provider={mockProvider}
|
||||
model={mockModel}
|
||||
credentials={[credWithEmptyName]}
|
||||
customModelCredential={credWithEmptyName}
|
||||
setCustomModelCredential={mockSetCustomModelCredential}
|
||||
/>,
|
||||
)
|
||||
|
||||
// indicator-green shown (not authRemoved, not unavailable, not empty)
|
||||
expect(screen.getByTestId('indicator-green')).toBeInTheDocument()
|
||||
// credential_name is empty so nothing printed for name
|
||||
expect(screen.queryByText('Key 1')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -24,10 +24,6 @@ vi.mock('../hooks', () => ({
|
||||
useLanguage: () => mockLanguage,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/public/llm', () => ({
|
||||
OpenaiYellow: () => <svg data-testid="openai-yellow-icon" />,
|
||||
}))
|
||||
|
||||
const createI18nText = (value: string): I18nText => ({
|
||||
en_US: value,
|
||||
zh_Hans: value,
|
||||
@ -92,10 +88,10 @@ describe('ModelIcon', () => {
|
||||
icon_small: createI18nText('openai.png'),
|
||||
})
|
||||
|
||||
render(<ModelIcon provider={provider} modelName="o1" />)
|
||||
const { container } = render(<ModelIcon provider={provider} modelName="o1" />)
|
||||
|
||||
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('openai-yellow-icon')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Edge case
|
||||
@ -105,4 +101,25 @@ describe('ModelIcon', () => {
|
||||
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
|
||||
expect(container.firstChild).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should render OpenAI Yellow icon for langgenius/openai/openai provider with model starting with o', () => {
|
||||
const provider = createModel({
|
||||
provider: 'langgenius/openai/openai',
|
||||
icon_small: createI18nText('openai.png'),
|
||||
})
|
||||
|
||||
const { container } = render(<ModelIcon provider={provider} modelName="o3" />)
|
||||
|
||||
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply opacity-50 when isDeprecated is true', () => {
|
||||
const provider = createModel()
|
||||
|
||||
const { container } = render(<ModelIcon provider={provider} isDeprecated={true} />)
|
||||
|
||||
const wrapper = container.querySelector('.opacity-50')
|
||||
expect(wrapper).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -161,7 +161,7 @@ function Form<
|
||||
const disabled = readonly || (isEditMode && (variable === '__model_type' || variable === '__model_name'))
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
||||
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
|
||||
{label[language] || label.en_US}
|
||||
{required && (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
@ -204,13 +204,14 @@ function Form<
|
||||
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
||||
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
|
||||
{label[language] || label.en_US}
|
||||
{required && (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
)}
|
||||
{tooltipContent}
|
||||
</div>
|
||||
{/* eslint-disable-next-line tailwindcss/no-unknown-classes */}
|
||||
<div className={cn('grid gap-3', `grid-cols-${options?.length}`)}>
|
||||
{options.filter((option) => {
|
||||
if (option.show_on.length)
|
||||
@ -229,7 +230,7 @@ function Form<
|
||||
>
|
||||
<RadioE isChecked={value[variable] === option.value} />
|
||||
|
||||
<div className="system-sm-regular text-text-secondary">{option.label[language] || option.label.en_US}</div>
|
||||
<div className="text-text-secondary system-sm-regular">{option.label[language] || option.label.en_US}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -254,7 +255,7 @@ function Form<
|
||||
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
||||
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
|
||||
{label[language] || label.en_US}
|
||||
|
||||
{required && (
|
||||
@ -295,9 +296,9 @@ function Form<
|
||||
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className="system-sm-semibold flex items-center justify-between py-2 text-text-secondary">
|
||||
<div className="flex items-center justify-between py-2 text-text-secondary system-sm-semibold">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>{label[language] || label.en_US}</span>
|
||||
<span className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>{label[language] || label.en_US}</span>
|
||||
{required && (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
)}
|
||||
@ -326,7 +327,7 @@ function Form<
|
||||
} = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput)
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
||||
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
|
||||
{label[language] || label.en_US}
|
||||
{required && (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
@ -358,7 +359,7 @@ function Form<
|
||||
} = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput)
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
||||
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
|
||||
{label[language] || label.en_US}
|
||||
{required && (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
@ -422,7 +423,7 @@ function Form<
|
||||
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
||||
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
|
||||
{label[language] || label.en_US}
|
||||
{required && (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
@ -451,7 +452,7 @@ function Form<
|
||||
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
||||
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
|
||||
{label[language] || label.en_US}
|
||||
{required && (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
|
||||
@ -93,4 +93,88 @@ describe('Input', () => {
|
||||
expect(onChange).not.toHaveBeenCalledWith('2')
|
||||
expect(onChange).not.toHaveBeenCalledWith('6')
|
||||
})
|
||||
|
||||
it('should not clamp when min and max are not provided', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Input
|
||||
placeholder="Free"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('Free')
|
||||
fireEvent.change(input, { target: { value: '999' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
// onChange only called from change event, not from blur clamping
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onChange).toHaveBeenCalledWith('999')
|
||||
})
|
||||
|
||||
it('should show check circle icon when validated is true', () => {
|
||||
const { container } = render(
|
||||
<Input
|
||||
placeholder="Key"
|
||||
onChange={vi.fn()}
|
||||
validated
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByPlaceholderText('Key')).toBeInTheDocument()
|
||||
expect(container.querySelector('.absolute.right-2\\.5.top-2\\.5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show check circle icon when validated is false', () => {
|
||||
const { container } = render(
|
||||
<Input
|
||||
placeholder="Key"
|
||||
onChange={vi.fn()}
|
||||
validated={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByPlaceholderText('Key')).toBeInTheDocument()
|
||||
expect(container.querySelector('.absolute.right-2\\.5.top-2\\.5')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply disabled attribute when disabled prop is true', () => {
|
||||
render(
|
||||
<Input
|
||||
placeholder="Disabled"
|
||||
onChange={vi.fn()}
|
||||
disabled
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByPlaceholderText('Disabled')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should call onFocus when input receives focus', () => {
|
||||
const onFocus = vi.fn()
|
||||
|
||||
render(
|
||||
<Input
|
||||
placeholder="Focus"
|
||||
onChange={vi.fn()}
|
||||
onFocus={onFocus}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.focus(screen.getByPlaceholderText('Focus'))
|
||||
expect(onFocus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render with custom className', () => {
|
||||
render(
|
||||
<Input
|
||||
placeholder="Styled"
|
||||
onChange={vi.fn()}
|
||||
className="custom-class"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByPlaceholderText('Styled')).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user