Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot] 85396a2af2 Initial plan 2026-03-09 08:05:17 +00:00
6c19e75969 test: improve unit tests for controllers.web (#32150)
Co-authored-by: Rajat Agarwal <rajat.agarwal@infocusp.com>
2026-03-09 15:58:34 +08:00
wangxiaoleiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
9970f4449a refactor: reuse redis connection instead of create new one (#32678)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-09 15:53:21 +08:00
cbb19cce39 docs: use docker compose command consistently in README (#33077)
Co-authored-by: Contributor <contributor@example.com>
2026-03-09 15:02:30 +08:00
hj24andGitHub 0aef09d630 feat: support relative mode for message clean command (#32834) 2026-03-09 14:32:35 +08:00
wangxiaoleiandGitHub d2208ad43e fix: fix allow handle value is none (#33031) 2026-03-09 14:20:44 +08:00
非法操作andGitHub 4a2ba058bb feat: when copy/paste multi nodes not require reconnect them (#32631) 2026-03-09 13:55:12 +08:00
非法操作andGitHub 654e41d47f fix: workflow_as_tool not work with json input (#32554) 2026-03-09 13:54:54 +08:00
非法操作andGitHub ec5409756e feat: keep connections when change node (#31982) 2026-03-09 13:54:10 +08:00
Olexandr88andGitHub 8b1ea3a8f5 refactor: deduplicate legacy section mapping in ConfigHelper (#32715) 2026-03-09 13:43:06 +08:00
yyhandGitHub f2d3feca66 fix(web): fix tool item text not vertically centered in block selector (#33148) 2026-03-09 13:38:11 +08:00
yyhandGitHub 0590b09958 feat(web): add context menu primitive and dropdown link item (#33125) 2026-03-09 12:05:38 +08:00
wangxiaoleiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
66f9fde2fe fix: fix metadata filter condition not extract from {{}} (#33141)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-09 11:51:08 +08:00
Stephen ZhouandGitHub 1811a855ab chore: update vinext, agentation, remove Prism in lexical (#33142) 2026-03-09 11:40:04 +08:00
Jiaquan YiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
322cd37de1 fix: handle backslash path separators in DOCX ZIP entries exported on…(#33129) (#33131)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-09 10:49:42 +08:00
rajatagarwal-ossandGitHub 2cc0de9c1b test: unit test case for controllers.common module (#32056) 2026-03-09 09:45:42 +08:00
wangxiaoleiandGitHub 46098b2be6 refactor: use thread.Timer instead of time.sleep (#33121) 2026-03-09 09:38:16 +08:00
7dcf94f48f test: remaining header component and increase branch coverage (#33052)
Co-authored-by: sahil <sahil@infocusp.com>
2026-03-09 09:18:11 +08:00
yyhandGitHub 7869551afd fix(web): stabilize dayjs timezone tests against DST transitions (#33134) 2026-03-09 09:16:45 +08:00
CoralGarden52andGitHub c925d17e8f chore: add TypedDict related prompt to api/AGENTS.md (#33116) 2026-03-08 07:03:52 +09:00
AngelGitHubfateleiangel.kautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Copilot
dc2a53d834 feat: add files to message end pr32019 (#32242)
Co-authored-by: fatelei <fatelei@gmail.com>
Co-authored-by: angel.k <angel.kolev@solaredge.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-07 20:01:12 +08:00
hj24andGitHub 05ab107e73 feat: add export app messages (#32990) 2026-03-07 11:27:15 +08:00
pepsiGitHubpepsigemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
c016793efb refactor: pass KnowledgeConfiguration directly instead of dict (#32732)
Co-authored-by: pepsi <pepsi@pepsidexuniji.local>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-06 22:15:32 +09:00
a5bcbaebb7 feat(toast): add IToastProps type import to enhance type safety (#33096)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-03-06 19:22:55 +08:00
+42 f50e44b24a test: improve coverage for some test files (#32916)
Signed-off-by: edvatar <88481784+toroleapinc@users.noreply.github.com>
Signed-off-by: -LAN- <laipz8200@outlook.com>
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: majiayu000 <1835304752@qq.com>
Co-authored-by: Poojan <poojan@infocusp.com>
Co-authored-by: sahil-infocusp <73810410+sahil-infocusp@users.noreply.github.com>
Co-authored-by: 非法操作 <hjlarry@163.com>
Co-authored-by: Pandaaaa906 <ye.pandaaaa906@gmail.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: heyszt <270985384@qq.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Ijas <ijas.ahmd.ap@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: 木之本澪 <kinomotomiovo@gmail.com>
Co-authored-by: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com>
Co-authored-by: 不做了睡大觉 <64798754+stakeswky@users.noreply.github.com>
Co-authored-by: User <user@example.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: edvatar <88481784+toroleapinc@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: Leilei <138381132+Inlei@users.noreply.github.com>
Co-authored-by: HaKu <104669497+haku-ink@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: wangxiaolei <fatelei@gmail.com>
Co-authored-by: Varun Chawla <34209028+veeceey@users.noreply.github.com>
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: tda <95275462+tda1017@users.noreply.github.com>
Co-authored-by: root <root@DESKTOP-KQLO90N>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Co-authored-by: Niels Kaspers <153818647+nielskaspers@users.noreply.github.com>
Co-authored-by: hj24 <mambahj24@gmail.com>
Co-authored-by: Tyson Cung <45380903+tysoncung@users.noreply.github.com>
Co-authored-by: Stephen Zhou <hi@hyoban.cc>
Co-authored-by: FFXN <31929997+FFXN@users.noreply.github.com>
Co-authored-by: slegarraga <64795732+slegarraga@users.noreply.github.com>
Co-authored-by: 99 <wh2099@pm.me>
Co-authored-by: Br1an <932039080@qq.com>
Co-authored-by: L1nSn0w <l1nsn0w@qq.com>
Co-authored-by: Yunlu Wen <yunlu.wen@dify.ai>
Co-authored-by: akkoaya <151345394+akkoaya@users.noreply.github.com>
Co-authored-by: 盐粒 Yanli <yanli@dify.ai>
Co-authored-by: lif <1835304752@qq.com>
Co-authored-by: weiguang li <codingpunk@gmail.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: HanWenbo <124024253+hwb96@users.noreply.github.com>
Co-authored-by: Coding On Star <447357187@qq.com>
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: Stable Genius <stablegenius043@gmail.com>
Co-authored-by: Stable Genius <259448942+stablegenius49@users.noreply.github.com>
Co-authored-by: ふるい <46769295+Echo0ff@users.noreply.github.com>
Co-authored-by: Xiyuan Chen <52963600+GareArc@users.noreply.github.com>
2026-03-06 18:59:16 +08:00
Nite KniteandGitHub 09347d5e8b chore: fix account dropdown test (#33093) 2026-03-06 18:19:02 +08:00
299a893ac5 chore: bring back code-inspector-plugin and agentation (#33088)
Co-authored-by: zhsama <zhsama@users.noreply.github.com>
2026-03-06 17:01:18 +08:00
Junyan ChinandGitHub c477571553 perf: no longer record install count for auto upgrade (#33086) 2026-03-06 16:19:30 +08:00
QuantumGhostGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
d01acfc490 fix(api): fix the issue that workflow_runs.started_at is overwritten while resuming (#32851)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-06 15:41:30 +08:00
211 changed files with 26105 additions and 2506 deletions
+6
View File
@@ -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/*"
+1 -1
View File
@@ -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
+16
View File
@@ -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 repos 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 theres 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
+154 -11
View File
@@ -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
+1 -1
View File
@@ -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,
@@ -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,
}
@@ -194,6 +194,13 @@ class SQLAlchemyWorkflowExecutionRepository(WorkflowExecutionRepository):
# Create a new database session
with self._session_factory() as session:
existing_model = session.get(WorkflowRun, db_model.id)
if existing_model:
if existing_model.tenant_id != self._tenant_id:
raise ValueError("Unauthorized access to workflow run")
# Preserve the original start time for pause/resume flows.
db_model.created_at = existing_model.created_at
# SQLAlchemy merge intelligently handles both insert and update operations
# based on the presence of the primary key
session.merge(db_model)
@@ -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,
+2
View File
@@ -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)
+1
View File
@@ -66,6 +66,7 @@ def run_migrations_offline():
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
logger.info("Generating offline migration SQL with url: %s", url)
with context.begin_transaction():
context.run_migrations()
+3 -2
View File
@@ -1,5 +1,6 @@
[pytest]
addopts = --cov=./api --cov-report=json
pythonpath = .
addopts = --cov=./api --cov-report=json --import-mode=importlib
env =
ANTHROPIC_API_KEY = sk-ant-api11-IamNotARealKeyJustForMockTestKawaiiiiiiiiii-NotBaka-ASkksz
AZURE_OPENAI_API_BASE = https://difyai-openai.openai.azure.com
@@ -19,7 +20,7 @@ env =
GOOGLE_API_KEY = abcdefghijklmnopqrstuvwxyz
HUGGINGFACE_API_KEY = hf-awuwuwuwuwuwuwuwuwuwuwuwuwuwuwuwuwu
HUGGINGFACE_EMBEDDINGS_ENDPOINT_URL = c
HUGGINGFACE_TEXT2TEXT_GEN_ENDPOINT_URL = b
HUGGINGFACE_TEXT2TEXT_GEN_ENDPOINT_URL = b
HUGGINGFACE_TEXT_GEN_ENDPOINT_URL = a
MIXEDBREAD_API_KEY = mk-aaaaaaaaaaaaaaaaaaaa
MOCK_SWITCH = true
+4
View File
@@ -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__)
+13 -7
View File
@@ -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)
+15 -19
View File
@@ -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
@@ -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",
+25 -14
View File
@@ -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()
@@ -6,7 +6,6 @@ import typing
import click
from celery import shared_task
from core.helper.marketplace import record_install_plugin_event
from core.plugin.entities.marketplace import MarketplacePluginSnapshot
from core.plugin.entities.plugin import PluginInstallationSource
from core.plugin.impl.plugin import PluginInstaller
@@ -166,7 +165,6 @@ def process_tenant_plugin_autoupgrade_check_task(
# execute upgrade
new_unique_identifier = manifest.latest_package_identifier
record_install_plugin_event(new_unique_identifier)
click.echo(
click.style(
f"Upgrade plugin: {original_unique_identifier} -> {new_unique_identifier}",
+26 -12
View File
@@ -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()
@@ -5,14 +5,10 @@ This test module validates the 400-character limit enforcement
for App descriptions across all creation and editing endpoints.
"""
import os
import sys
import pytest
# Add the API root to Python path for imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
class TestAppDescriptionValidationUnit:
"""Unit tests for description validation function"""
@@ -10,8 +10,11 @@ more reliable and realistic test scenarios.
import logging
import os
from collections.abc import Generator
from contextlib import contextmanager
from pathlib import Path
from typing import Protocol, TypeVar
import psycopg2
import pytest
from flask import Flask
from flask.testing import FlaskClient
@@ -31,6 +34,25 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(level
logger = logging.getLogger(__name__)
class _CloserProtocol(Protocol):
"""_Closer is any type which implement the close() method."""
def close(self):
"""close the current object, release any external resouece (file, transaction, connection etc.)
associated with it.
"""
pass
_Closer = TypeVar("_Closer", bound=_CloserProtocol)
@contextmanager
def _auto_close(closer: _Closer) -> Generator[_Closer, None, None]:
yield closer
closer.close()
class DifyTestContainers:
"""
Manages all test containers required for Dify integration tests.
@@ -97,45 +119,28 @@ class DifyTestContainers:
wait_for_logs(self.postgres, "is ready to accept connections", timeout=30)
logger.info("PostgreSQL container is ready and accepting connections")
# Install uuid-ossp extension for UUID generation
logger.info("Installing uuid-ossp extension...")
try:
import psycopg2
conn = psycopg2.connect(
host=db_host,
port=db_port,
user=self.postgres.username,
password=self.postgres.password,
database=self.postgres.dbname,
)
conn.autocommit = True
cursor = conn.cursor()
cursor.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";')
cursor.close()
conn.close()
conn = psycopg2.connect(
host=db_host,
port=db_port,
user=self.postgres.username,
password=self.postgres.password,
database=self.postgres.dbname,
)
conn.autocommit = True
with _auto_close(conn):
with conn.cursor() as cursor:
# Install uuid-ossp extension for UUID generation
logger.info("Installing uuid-ossp extension...")
cursor.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";')
logger.info("uuid-ossp extension installed successfully")
except Exception as e:
logger.warning("Failed to install uuid-ossp extension: %s", e)
# Create plugin database for dify-plugin-daemon
logger.info("Creating plugin database...")
try:
conn = psycopg2.connect(
host=db_host,
port=db_port,
user=self.postgres.username,
password=self.postgres.password,
database=self.postgres.dbname,
)
conn.autocommit = True
cursor = conn.cursor()
cursor.execute("CREATE DATABASE dify_plugin;")
cursor.close()
conn.close()
# NOTE: We cannot use `with conn.cursor() as cursor:` as it will wrap the statement
# inside a transaction. However, the `CREATE DATABASE` statement cannot run inside a transaction block.
with _auto_close(conn.cursor()) as cursor:
# Create plugin database for dify-plugin-daemon
logger.info("Creating plugin database...")
cursor.execute("CREATE DATABASE dify_plugin;")
logger.info("Plugin database created successfully")
except Exception as e:
logger.warning("Failed to create plugin database: %s", e)
# Set up storage environment variables
os.environ.setdefault("STORAGE_TYPE", "opendal")
@@ -258,23 +263,16 @@ class DifyTestContainers:
containers = [self.redis, self.postgres, self.dify_sandbox, self.dify_plugin_daemon]
for container in containers:
if container:
try:
container_name = container.image
logger.info("Stopping container: %s", container_name)
container.stop()
logger.info("Successfully stopped container: %s", container_name)
except Exception as e:
# Log error but don't fail the test cleanup
logger.warning("Failed to stop container %s: %s", container, e)
container_name = container.image
logger.info("Stopping container: %s", container_name)
container.stop()
logger.info("Successfully stopped container: %s", container_name)
# Stop and remove the network
if self.network:
try:
logger.info("Removing Docker network...")
self.network.remove()
logger.info("Successfully removed Docker network")
except Exception as e:
logger.warning("Failed to remove Docker network: %s", e)
logger.info("Removing Docker network...")
self.network.remove()
logger.info("Successfully removed Docker network")
self._containers_started = False
logger.info("All test containers stopped and cleaned up successfully")
@@ -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:
@@ -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,
)
-5
View File
@@ -32,11 +32,6 @@ os.environ.setdefault("OPENDAL_SCHEME", "fs")
os.environ.setdefault("OPENDAL_FS_ROOT", "/tmp/dify-storage")
os.environ.setdefault("STORAGE_TYPE", "opendal")
# Add the API directory to Python path to ensure proper imports
import sys
sys.path.insert(0, PROJECT_DIR)
from core.db.session_factory import configure_session_factory, session_factory
from extensions import ext_redis
@@ -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(
@@ -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
@@ -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,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,
)
@@ -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()
@@ -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())
@@ -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"}
@@ -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"
@@ -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"
@@ -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)
@@ -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())
@@ -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)
@@ -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
@@ -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="")
@@ -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())
@@ -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"
@@ -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()
@@ -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()
@@ -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")
@@ -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"
@@ -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")
@@ -1,13 +1,8 @@
import sys
import time
from pathlib import Path
from types import ModuleType, SimpleNamespace
from typing import Any
API_DIR = str(Path(__file__).resolve().parents[5])
if API_DIR not in sys.path:
sys.path.insert(0, API_DIR)
import dify_graph.nodes.human_input.entities # noqa: F401
from core.app.apps.advanced_chat import app_generator as adv_app_gen_module
from core.app.apps.workflow import app_generator as wf_app_gen_module
@@ -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))
@@ -0,0 +1,84 @@
from datetime import datetime
from unittest.mock import MagicMock
from uuid import uuid4
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository
from dify_graph.entities.workflow_execution import WorkflowExecution, WorkflowType
from models import Account, WorkflowRun
from models.enums import WorkflowRunTriggeredFrom
def _build_repository_with_mocked_session(session: MagicMock) -> SQLAlchemyWorkflowExecutionRepository:
engine = create_engine("sqlite:///:memory:")
real_session_factory = sessionmaker(bind=engine, expire_on_commit=False)
user = MagicMock(spec=Account)
user.id = str(uuid4())
user.current_tenant_id = str(uuid4())
repository = SQLAlchemyWorkflowExecutionRepository(
session_factory=real_session_factory,
user=user,
app_id="app-id",
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
)
session_context = MagicMock()
session_context.__enter__.return_value = session
session_context.__exit__.return_value = False
repository._session_factory = MagicMock(return_value=session_context)
return repository
def _build_execution(*, execution_id: str, started_at: datetime) -> WorkflowExecution:
return WorkflowExecution.new(
id_=execution_id,
workflow_id="workflow-id",
workflow_type=WorkflowType.WORKFLOW,
workflow_version="1.0.0",
graph={"nodes": [], "edges": []},
inputs={"query": "hello"},
started_at=started_at,
)
def test_save_uses_execution_started_at_when_record_does_not_exist():
session = MagicMock()
session.get.return_value = None
repository = _build_repository_with_mocked_session(session)
started_at = datetime(2026, 1, 1, 12, 0, 0)
execution = _build_execution(execution_id=str(uuid4()), started_at=started_at)
repository.save(execution)
saved_model = session.merge.call_args.args[0]
assert saved_model.created_at == started_at
session.commit.assert_called_once()
def test_save_preserves_existing_created_at_when_record_already_exists():
session = MagicMock()
repository = _build_repository_with_mocked_session(session)
execution_id = str(uuid4())
existing_created_at = datetime(2026, 1, 1, 12, 0, 0)
existing_run = WorkflowRun()
existing_run.id = execution_id
existing_run.tenant_id = repository._tenant_id
existing_run.created_at = existing_created_at
session.get.return_value = existing_run
execution = _build_execution(
execution_id=execution_id,
started_at=datetime(2026, 1, 1, 12, 30, 0),
)
repository.save(execution)
saved_model = session.merge.call_args.args[0]
assert saved_model.created_at == existing_created_at
session.commit.assert_called_once()
@@ -2,15 +2,7 @@
Simple test to verify MockNodeFactory works with iteration nodes.
"""
import sys
from pathlib import Path
from dify_graph.entities.graph_init_params import DIFY_RUN_CONTEXT_KEY
# Add api directory to path
api_dir = Path(__file__).parent.parent.parent.parent.parent.parent
sys.path.insert(0, str(api_dir))
from dify_graph.enums import NodeType
from tests.unit_tests.core.workflow.graph_engine.test_mock_config import MockConfigBuilder
from tests.unit_tests.core.workflow.graph_engine.test_mock_factory import MockNodeFactory
@@ -3,14 +3,8 @@ Simple test to validate the auto-mock system without external dependencies.
"""
import sys
from pathlib import Path
from dify_graph.entities.graph_init_params import DIFY_RUN_CONTEXT_KEY
# Add api directory to path
api_dir = Path(__file__).parent.parent.parent.parent.parent.parent
sys.path.insert(0, str(api_dir))
from dify_graph.enums import NodeType
from tests.unit_tests.core.workflow.graph_engine.test_mock_config import MockConfig, MockConfigBuilder, NodeMockConfig
from tests.unit_tests.core.workflow.graph_engine.test_mock_factory import MockNodeFactory
@@ -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
@@ -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
+14 -16
View File
@@ -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)
+6
View File
@@ -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.
@@ -0,0 +1,139 @@
import * as amplitude from '@amplitude/analytics-browser'
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AmplitudeProvider, { isAmplitudeEnabled } from './AmplitudeProvider'
const mockConfig = vi.hoisted(() => ({
AMPLITUDE_API_KEY: 'test-api-key',
IS_CLOUD_EDITION: true,
}))
vi.mock('@/config', () => mockConfig)
vi.mock('@amplitude/analytics-browser', () => ({
init: vi.fn(),
add: vi.fn(),
}))
vi.mock('@amplitude/plugin-session-replay-browser', () => ({
sessionReplayPlugin: vi.fn(() => ({ name: 'session-replay' })),
}))
describe('AmplitudeProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
mockConfig.IS_CLOUD_EDITION = true
})
describe('isAmplitudeEnabled', () => {
it('returns true when cloud edition and api key present', () => {
expect(isAmplitudeEnabled()).toBe(true)
})
it('returns false when cloud edition but no api key', () => {
mockConfig.AMPLITUDE_API_KEY = ''
expect(isAmplitudeEnabled()).toBe(false)
})
it('returns false when not cloud edition', () => {
mockConfig.IS_CLOUD_EDITION = false
expect(isAmplitudeEnabled()).toBe(false)
})
})
describe('Component', () => {
it('initializes amplitude when enabled', () => {
render(<AmplitudeProvider sessionReplaySampleRate={0.8} />)
expect(amplitude.init).toHaveBeenCalledWith('test-api-key', expect.any(Object))
expect(sessionReplayPlugin).toHaveBeenCalledWith({ sampleRate: 0.8 })
expect(amplitude.add).toHaveBeenCalledTimes(2)
})
it('does not initialize amplitude when disabled', () => {
mockConfig.AMPLITUDE_API_KEY = ''
render(<AmplitudeProvider />)
expect(amplitude.init).not.toHaveBeenCalled()
expect(amplitude.add).not.toHaveBeenCalled()
})
it('pageNameEnrichmentPlugin logic works as expected', async () => {
render(<AmplitudeProvider />)
const plugin = vi.mocked(amplitude.add).mock.calls[0]?.[0] as amplitude.Types.EnrichmentPlugin | undefined
expect(plugin).toBeDefined()
if (!plugin?.execute || !plugin.setup)
throw new Error('Expected page-name-enrichment plugin with setup/execute')
expect(plugin.name).toBe('page-name-enrichment')
const execute = plugin.execute
const setup = plugin.setup
type SetupFn = NonNullable<amplitude.Types.EnrichmentPlugin['setup']>
const getPageTitle = (evt: amplitude.Types.Event | null | undefined) =>
(evt?.event_properties as Record<string, unknown> | undefined)?.['[Amplitude] Page Title']
await setup(
{} as Parameters<SetupFn>[0],
{} as Parameters<SetupFn>[1],
)
const originalWindowLocation = window.location
try {
Object.defineProperty(window, 'location', {
value: { pathname: '/datasets' },
writable: true,
})
const event: amplitude.Types.Event = {
event_type: '[Amplitude] Page Viewed',
event_properties: {},
}
const result = await execute(event)
expect(getPageTitle(result)).toBe('Knowledge')
window.location.pathname = '/'
await execute(event)
expect(getPageTitle(event)).toBe('Home')
window.location.pathname = '/apps'
await execute(event)
expect(getPageTitle(event)).toBe('Studio')
window.location.pathname = '/explore'
await execute(event)
expect(getPageTitle(event)).toBe('Explore')
window.location.pathname = '/tools'
await execute(event)
expect(getPageTitle(event)).toBe('Tools')
window.location.pathname = '/account'
await execute(event)
expect(getPageTitle(event)).toBe('Account')
window.location.pathname = '/signin'
await execute(event)
expect(getPageTitle(event)).toBe('Sign In')
window.location.pathname = '/signup'
await execute(event)
expect(getPageTitle(event)).toBe('Sign Up')
window.location.pathname = '/unknown'
await execute(event)
expect(getPageTitle(event)).toBe('Unknown')
const otherEvent = {
event_type: 'Button Clicked',
event_properties: {},
} as amplitude.Types.Event
const otherResult = await execute(otherEvent)
expect(getPageTitle(otherResult)).toBeUndefined()
const noPropsEvent = {
event_type: '[Amplitude] Page Viewed',
} as amplitude.Types.Event
const noPropsResult = await execute(noPropsEvent)
expect(noPropsResult?.event_properties).toBeUndefined()
}
finally {
Object.defineProperty(window, 'location', {
value: originalWindowLocation,
writable: true,
})
}
})
})
})
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import AmplitudeProvider, { isAmplitudeEnabled } from './AmplitudeProvider'
import indexDefault, {
isAmplitudeEnabled as indexIsAmplitudeEnabled,
resetUser,
setUserId,
setUserProperties,
trackEvent,
} from './index'
import {
resetUser as utilsResetUser,
setUserId as utilsSetUserId,
setUserProperties as utilsSetUserProperties,
trackEvent as utilsTrackEvent,
} from './utils'
describe('Amplitude index exports', () => {
it('exports AmplitudeProvider as default', () => {
expect(indexDefault).toBe(AmplitudeProvider)
})
it('exports isAmplitudeEnabled', () => {
expect(indexIsAmplitudeEnabled).toBe(isAmplitudeEnabled)
})
it('exports utils', () => {
expect(resetUser).toBe(utilsResetUser)
expect(setUserId).toBe(utilsSetUserId)
expect(setUserProperties).toBe(utilsSetUserProperties)
expect(trackEvent).toBe(utilsTrackEvent)
})
})
@@ -0,0 +1,119 @@
import { resetUser, setUserId, setUserProperties, trackEvent } from './utils'
const mockState = vi.hoisted(() => ({
enabled: true,
}))
const mockTrack = vi.hoisted(() => vi.fn())
const mockSetUserId = vi.hoisted(() => vi.fn())
const mockIdentify = vi.hoisted(() => vi.fn())
const mockReset = vi.hoisted(() => vi.fn())
const MockIdentify = vi.hoisted(() =>
class {
setCalls: Array<[string, unknown]> = []
set(key: string, value: unknown) {
this.setCalls.push([key, value])
return this
}
},
)
vi.mock('./AmplitudeProvider', () => ({
isAmplitudeEnabled: () => mockState.enabled,
}))
vi.mock('@amplitude/analytics-browser', () => ({
track: (...args: unknown[]) => mockTrack(...args),
setUserId: (...args: unknown[]) => mockSetUserId(...args),
identify: (...args: unknown[]) => mockIdentify(...args),
reset: (...args: unknown[]) => mockReset(...args),
Identify: MockIdentify,
}))
describe('amplitude utils', () => {
beforeEach(() => {
vi.clearAllMocks()
mockState.enabled = true
})
describe('trackEvent', () => {
it('should call amplitude.track when amplitude is enabled', () => {
trackEvent('dataset_created', { source: 'wizard' })
expect(mockTrack).toHaveBeenCalledTimes(1)
expect(mockTrack).toHaveBeenCalledWith('dataset_created', { source: 'wizard' })
})
it('should not call amplitude.track when amplitude is disabled', () => {
mockState.enabled = false
trackEvent('dataset_created', { source: 'wizard' })
expect(mockTrack).not.toHaveBeenCalled()
})
})
describe('setUserId', () => {
it('should call amplitude.setUserId when amplitude is enabled', () => {
setUserId('user-123')
expect(mockSetUserId).toHaveBeenCalledTimes(1)
expect(mockSetUserId).toHaveBeenCalledWith('user-123')
})
it('should not call amplitude.setUserId when amplitude is disabled', () => {
mockState.enabled = false
setUserId('user-123')
expect(mockSetUserId).not.toHaveBeenCalled()
})
})
describe('setUserProperties', () => {
it('should build identify event and call amplitude.identify when amplitude is enabled', () => {
const properties: Record<string, unknown> = {
role: 'owner',
seats: 3,
verified: true,
}
setUserProperties(properties)
expect(mockIdentify).toHaveBeenCalledTimes(1)
const identifyArg = mockIdentify.mock.calls[0][0] as InstanceType<typeof MockIdentify>
expect(identifyArg).toBeInstanceOf(MockIdentify)
expect(identifyArg.setCalls).toEqual([
['role', 'owner'],
['seats', 3],
['verified', true],
])
})
it('should not call amplitude.identify when amplitude is disabled', () => {
mockState.enabled = false
setUserProperties({ role: 'owner' })
expect(mockIdentify).not.toHaveBeenCalled()
})
})
describe('resetUser', () => {
it('should call amplitude.reset when amplitude is enabled', () => {
resetUser()
expect(mockReset).toHaveBeenCalledTimes(1)
})
it('should not call amplitude.reset when amplitude is disabled', () => {
mockState.enabled = false
resetUser()
expect(mockReset).not.toHaveBeenCalled()
})
})
})
@@ -0,0 +1,148 @@
import { AudioPlayerManager } from '../audio.player.manager'
type AudioCallback = ((event: string) => void) | null
type AudioPlayerCtorArgs = [
string,
boolean,
string | undefined,
string | null | undefined,
string | undefined,
AudioCallback,
]
type MockAudioPlayerInstance = {
setCallback: ReturnType<typeof vi.fn>
pauseAudio: ReturnType<typeof vi.fn>
resetMsgId: ReturnType<typeof vi.fn>
cacheBuffers: Array<ArrayBuffer>
sourceBuffer: {
abort: ReturnType<typeof vi.fn>
} | undefined
}
const mockState = vi.hoisted(() => ({
instances: [] as MockAudioPlayerInstance[],
}))
const mockAudioPlayerConstructor = vi.hoisted(() => vi.fn())
const MockAudioPlayer = vi.hoisted(() => {
return class MockAudioPlayerClass {
setCallback = vi.fn()
pauseAudio = vi.fn()
resetMsgId = vi.fn()
cacheBuffers = [new ArrayBuffer(1)]
sourceBuffer = { abort: vi.fn() }
constructor(...args: AudioPlayerCtorArgs) {
mockAudioPlayerConstructor(...args)
mockState.instances.push(this as unknown as MockAudioPlayerInstance)
}
}
})
vi.mock('@/app/components/base/audio-btn/audio', () => ({
default: MockAudioPlayer,
}))
describe('AudioPlayerManager', () => {
beforeEach(() => {
vi.clearAllMocks()
mockState.instances = []
Reflect.set(AudioPlayerManager, 'instance', undefined)
})
describe('getInstance', () => {
it('should return the same singleton instance across calls', () => {
const first = AudioPlayerManager.getInstance()
const second = AudioPlayerManager.getInstance()
expect(first).toBe(second)
})
})
describe('getAudioPlayer', () => {
it('should create a new audio player when no existing player is cached', () => {
const manager = AudioPlayerManager.getInstance()
const callback = vi.fn()
const result = manager.getAudioPlayer('/text-to-audio', false, 'msg-1', 'hello', 'en-US', callback)
expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(1)
expect(mockAudioPlayerConstructor).toHaveBeenCalledWith(
'/text-to-audio',
false,
'msg-1',
'hello',
'en-US',
callback,
)
expect(result).toBe(mockState.instances[0])
})
it('should reuse existing player and update callback when msg id is unchanged', () => {
const manager = AudioPlayerManager.getInstance()
const firstCallback = vi.fn()
const secondCallback = vi.fn()
const first = manager.getAudioPlayer('/text-to-audio', false, 'msg-1', 'hello', 'en-US', firstCallback)
const second = manager.getAudioPlayer('/ignored', true, 'msg-1', 'ignored', 'fr-FR', secondCallback)
expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(1)
expect(first).toBe(second)
expect(mockState.instances[0].setCallback).toHaveBeenCalledTimes(1)
expect(mockState.instances[0].setCallback).toHaveBeenCalledWith(secondCallback)
})
it('should cleanup existing player and create a new one when msg id changes', () => {
const manager = AudioPlayerManager.getInstance()
const callback = vi.fn()
manager.getAudioPlayer('/text-to-audio', false, 'msg-1', 'hello', 'en-US', callback)
const previous = mockState.instances[0]
const next = manager.getAudioPlayer('/apps/1/text-to-audio', false, 'msg-2', 'world', 'en-US', callback)
expect(previous.pauseAudio).toHaveBeenCalledTimes(1)
expect(previous.cacheBuffers).toEqual([])
expect(previous.sourceBuffer?.abort).toHaveBeenCalledTimes(1)
expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(2)
expect(next).toBe(mockState.instances[1])
})
it('should swallow cleanup errors and still create a new player', () => {
const manager = AudioPlayerManager.getInstance()
const callback = vi.fn()
manager.getAudioPlayer('/text-to-audio', false, 'msg-1', 'hello', 'en-US', callback)
const previous = mockState.instances[0]
previous.pauseAudio.mockImplementation(() => {
throw new Error('cleanup failure')
})
expect(() => {
manager.getAudioPlayer('/apps/1/text-to-audio', false, 'msg-2', 'world', 'en-US', callback)
}).not.toThrow()
expect(previous.pauseAudio).toHaveBeenCalledTimes(1)
expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(2)
})
})
describe('resetMsgId', () => {
it('should forward reset message id to the cached audio player when present', () => {
const manager = AudioPlayerManager.getInstance()
const callback = vi.fn()
manager.getAudioPlayer('/text-to-audio', false, 'msg-1', 'hello', 'en-US', callback)
manager.resetMsgId('msg-updated')
expect(mockState.instances[0].resetMsgId).toHaveBeenCalledTimes(1)
expect(mockState.instances[0].resetMsgId).toHaveBeenCalledWith('msg-updated')
})
it('should not throw when resetting message id without an audio player', () => {
const manager = AudioPlayerManager.getInstance()
expect(() => manager.resetMsgId('msg-updated')).not.toThrow()
})
})
})
@@ -0,0 +1,610 @@
import { Buffer } from 'node:buffer'
import { waitFor } from '@testing-library/react'
import { AppSourceType } from '@/service/share'
import AudioPlayer from '../audio'
const mockToastNotify = vi.hoisted(() => vi.fn())
const mockTextToAudioStream = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (...args: unknown[]) => mockToastNotify(...args),
},
}))
vi.mock('@/service/share', () => ({
AppSourceType: {
webApp: 'webApp',
installedApp: 'installedApp',
},
textToAudioStream: (...args: unknown[]) => mockTextToAudioStream(...args),
}))
type AudioEventName = 'ended' | 'paused' | 'loaded' | 'play' | 'timeupdate' | 'loadeddate' | 'canplay' | 'error' | 'sourceopen'
type AudioEventListener = () => void
type ReaderResult = {
value: Uint8Array | undefined
done: boolean
}
type Reader = {
read: () => Promise<ReaderResult>
}
type AudioResponse = {
status: number
body: {
getReader: () => Reader
}
}
class MockSourceBuffer {
updating = false
appendBuffer = vi.fn((_buffer: ArrayBuffer) => undefined)
abort = vi.fn(() => undefined)
}
class MockMediaSource {
readyState: 'open' | 'closed' = 'open'
sourceBuffer = new MockSourceBuffer()
private listeners: Partial<Record<AudioEventName, AudioEventListener[]>> = {}
addEventListener = vi.fn((event: AudioEventName, listener: AudioEventListener) => {
const listeners = this.listeners[event] || []
listeners.push(listener)
this.listeners[event] = listeners
})
addSourceBuffer = vi.fn((_contentType: string) => this.sourceBuffer)
endOfStream = vi.fn(() => undefined)
emit(event: AudioEventName) {
const listeners = this.listeners[event] || []
listeners.forEach((listener) => {
listener()
})
}
}
class MockAudio {
src = ''
autoplay = false
disableRemotePlayback = false
controls = false
paused = true
ended = false
played: unknown = null
private listeners: Partial<Record<AudioEventName, AudioEventListener[]>> = {}
addEventListener = vi.fn((event: AudioEventName, listener: AudioEventListener) => {
const listeners = this.listeners[event] || []
listeners.push(listener)
this.listeners[event] = listeners
})
play = vi.fn(async () => {
this.paused = false
})
pause = vi.fn(() => {
this.paused = true
})
emit(event: AudioEventName) {
const listeners = this.listeners[event] || []
listeners.forEach((listener) => {
listener()
})
}
}
class MockAudioContext {
state: 'running' | 'suspended' = 'running'
destination = {}
connect = vi.fn(() => undefined)
createMediaElementSource = vi.fn((_audio: MockAudio) => ({
connect: this.connect,
}))
resume = vi.fn(async () => {
this.state = 'running'
})
suspend = vi.fn(() => {
this.state = 'suspended'
})
}
const testState = {
mediaSources: [] as MockMediaSource[],
audios: [] as MockAudio[],
audioContexts: [] as MockAudioContext[],
}
class MockMediaSourceCtor extends MockMediaSource {
constructor() {
super()
testState.mediaSources.push(this)
}
}
class MockAudioCtor extends MockAudio {
constructor() {
super()
testState.audios.push(this)
}
}
class MockAudioContextCtor extends MockAudioContext {
constructor() {
super()
testState.audioContexts.push(this)
}
}
const originalAudio = globalThis.Audio
const originalAudioContext = globalThis.AudioContext
const originalCreateObjectURL = globalThis.URL.createObjectURL
const originalMediaSource = window.MediaSource
const originalManagedMediaSource = window.ManagedMediaSource
const setMediaSourceSupport = (options: { mediaSource: boolean, managedMediaSource: boolean }) => {
Object.defineProperty(window, 'MediaSource', {
configurable: true,
writable: true,
value: options.mediaSource ? MockMediaSourceCtor : undefined,
})
Object.defineProperty(window, 'ManagedMediaSource', {
configurable: true,
writable: true,
value: options.managedMediaSource ? MockMediaSourceCtor : undefined,
})
}
const makeAudioResponse = (status: number, reads: ReaderResult[]): AudioResponse => {
const read = vi.fn<() => Promise<ReaderResult>>()
reads.forEach((result) => {
read.mockResolvedValueOnce(result)
})
return {
status,
body: {
getReader: () => ({ read }),
},
}
}
describe('AudioPlayer', () => {
beforeEach(() => {
vi.clearAllMocks()
testState.mediaSources = []
testState.audios = []
testState.audioContexts = []
Object.defineProperty(globalThis, 'Audio', {
configurable: true,
writable: true,
value: MockAudioCtor,
})
Object.defineProperty(globalThis, 'AudioContext', {
configurable: true,
writable: true,
value: MockAudioContextCtor,
})
Object.defineProperty(globalThis.URL, 'createObjectURL', {
configurable: true,
writable: true,
value: vi.fn(() => 'blob:mock-url'),
})
setMediaSourceSupport({ mediaSource: true, managedMediaSource: false })
})
afterAll(() => {
Object.defineProperty(globalThis, 'Audio', {
configurable: true,
writable: true,
value: originalAudio,
})
Object.defineProperty(globalThis, 'AudioContext', {
configurable: true,
writable: true,
value: originalAudioContext,
})
Object.defineProperty(globalThis.URL, 'createObjectURL', {
configurable: true,
writable: true,
value: originalCreateObjectURL,
})
Object.defineProperty(window, 'MediaSource', {
configurable: true,
writable: true,
value: originalMediaSource,
})
Object.defineProperty(window, 'ManagedMediaSource', {
configurable: true,
writable: true,
value: originalManagedMediaSource,
})
})
describe('constructor behavior', () => {
it('should initialize media source, audio, and media element source when MediaSource exists', () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
const mediaSource = testState.mediaSources[0]
expect(player.mediaSource).toBe(mediaSource as unknown as MediaSource)
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(1)
expect(audio.src).toBe('blob:mock-url')
expect(audio.autoplay).toBe(true)
expect(audioContext.createMediaElementSource).toHaveBeenCalledWith(audio)
expect(audioContext.connect).toHaveBeenCalledTimes(1)
})
it('should notify unsupported browser when no MediaSource implementation exists', () => {
setMediaSourceSupport({ mediaSource: false, managedMediaSource: false })
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const audio = testState.audios[0]
expect(player.mediaSource).toBeNull()
expect(audio.src).toBe('')
expect(mockToastNotify).toHaveBeenCalledTimes(1)
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
})
it('should configure fallback audio controls when ManagedMediaSource is used', () => {
setMediaSourceSupport({ mediaSource: false, managedMediaSource: true })
// Create with callback to ensure constructor path completes with fallback source.
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, vi.fn())
const audio = testState.audios[0]
expect(player.mediaSource).not.toBeNull()
expect(audio.disableRemotePlayback).toBe(true)
expect(audio.controls).toBe(true)
})
})
describe('event wiring', () => {
it('should forward registered audio events to callback', () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const audio = testState.audios[0]
audio.emit('play')
audio.emit('ended')
audio.emit('error')
audio.emit('paused')
audio.emit('loaded')
audio.emit('timeupdate')
audio.emit('loadeddate')
audio.emit('canplay')
expect(player.callback).toBe(callback)
expect(callback).toHaveBeenCalledWith('play')
expect(callback).toHaveBeenCalledWith('ended')
expect(callback).toHaveBeenCalledWith('error')
expect(callback).toHaveBeenCalledWith('paused')
expect(callback).toHaveBeenCalledWith('loaded')
expect(callback).toHaveBeenCalledWith('timeupdate')
expect(callback).toHaveBeenCalledWith('loadeddate')
expect(callback).toHaveBeenCalledWith('canplay')
})
it('should initialize source buffer only once when sourceopen fires multiple times', () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', vi.fn())
const mediaSource = testState.mediaSources[0]
mediaSource.emit('sourceopen')
mediaSource.emit('sourceopen')
expect(mediaSource.addSourceBuffer).toHaveBeenCalledTimes(1)
expect(player.sourceBuffer).toBe(mediaSource.sourceBuffer)
})
})
describe('playback control', () => {
it('should request streaming audio when playAudio is called before loading', async () => {
mockTextToAudioStream.mockResolvedValue(
makeAudioResponse(200, [
{ value: new Uint8Array([4, 5]), done: false },
{ value: new Uint8Array([1, 2, 3]), done: true },
]),
)
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', vi.fn())
player.playAudio()
await waitFor(() => {
expect(mockTextToAudioStream).toHaveBeenCalledTimes(1)
})
expect(mockTextToAudioStream).toHaveBeenCalledWith(
'/text-to-audio',
AppSourceType.webApp,
{ content_type: 'audio/mpeg' },
{
message_id: 'msg-1',
streaming: true,
voice: 'en-US',
text: 'hello',
},
)
expect(player.isLoadData).toBe(true)
})
it('should emit error callback and reset load flag when stream response status is not 200', async () => {
const callback = vi.fn()
mockTextToAudioStream.mockResolvedValue(
makeAudioResponse(500, [{ value: new Uint8Array([1]), done: true }]),
)
const player = new AudioPlayer('/text-to-audio', false, 'msg-2', 'world', undefined, callback)
player.playAudio()
await waitFor(() => {
expect(callback).toHaveBeenCalledWith('error')
})
expect(player.isLoadData).toBe(false)
})
it('should resume and play immediately when playAudio is called in suspended loaded state', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
player.isLoadData = true
audioContext.state = 'suspended'
player.playAudio()
await Promise.resolve()
expect(audioContext.resume).toHaveBeenCalledTimes(1)
expect(audio.play).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
})
it('should play ended audio when data is already loaded', () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
player.isLoadData = true
audioContext.state = 'running'
audio.ended = true
player.playAudio()
expect(audio.play).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
})
it('should only emit play callback without replaying when loaded audio is already playing', () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
player.isLoadData = true
audioContext.state = 'running'
audio.ended = false
player.playAudio()
expect(audio.play).not.toHaveBeenCalled()
expect(callback).toHaveBeenCalledWith('play')
})
it('should emit error callback when stream request throws', async () => {
const callback = vi.fn()
mockTextToAudioStream.mockRejectedValue(new Error('network failed'))
const player = new AudioPlayer('/text-to-audio', false, 'msg-2', 'world', undefined, callback)
player.playAudio()
await waitFor(() => {
expect(callback).toHaveBeenCalledWith('error')
})
expect(player.isLoadData).toBe(false)
})
it('should call pause flow and notify paused event when pauseAudio is invoked', () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
player.pauseAudio()
expect(callback).toHaveBeenCalledWith('paused')
expect(audio.pause).toHaveBeenCalledTimes(1)
expect(audioContext.suspend).toHaveBeenCalledTimes(1)
})
})
describe('message and direct-audio helpers', () => {
it('should update message id through resetMsgId', () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
player.resetMsgId('msg-2')
expect(player.msgId).toBe('msg-2')
})
it('should end stream without playback when playAudioWithAudio receives empty content', async () => {
vi.useFakeTimers()
try {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const mediaSource = testState.mediaSources[0]
await player.playAudioWithAudio('', true)
await vi.advanceTimersByTimeAsync(40)
expect(player.isLoadData).toBe(false)
expect(player.cacheBuffers).toHaveLength(0)
expect(mediaSource.endOfStream).toHaveBeenCalledTimes(1)
expect(callback).not.toHaveBeenCalledWith('play')
}
finally {
vi.useRealTimers()
}
})
it('should decode base64 and start playback when playAudioWithAudio is called with playable content', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
const mediaSource = testState.mediaSources[0]
const audioBase64 = Buffer.from('hello').toString('base64')
mediaSource.emit('sourceopen')
audio.paused = true
await player.playAudioWithAudio(audioBase64, true)
await Promise.resolve()
expect(player.isLoadData).toBe(true)
expect(player.cacheBuffers).toHaveLength(0)
expect(mediaSource.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
const appendedAudioData = mediaSource.sourceBuffer.appendBuffer.mock.calls[0][0]
expect(appendedAudioData).toBeInstanceOf(ArrayBuffer)
expect(appendedAudioData.byteLength).toBeGreaterThan(0)
expect(audioContext.resume).toHaveBeenCalledTimes(1)
expect(audio.play).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
})
it('should skip playback when playAudioWithAudio is called with play=false', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), false)
expect(player.isLoadData).toBe(false)
expect(audioContext.resume).not.toHaveBeenCalled()
expect(audio.play).not.toHaveBeenCalled()
expect(callback).not.toHaveBeenCalledWith('play')
})
it('should play immediately for ended audio in playAudioWithAudio', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const audio = testState.audios[0]
audio.paused = false
audio.ended = true
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true)
expect(audio.play).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
})
it('should not replay when played list exists in playAudioWithAudio', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const audio = testState.audios[0]
audio.paused = false
audio.ended = false
audio.played = {}
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true)
expect(audio.play).not.toHaveBeenCalled()
expect(callback).not.toHaveBeenCalledWith('play')
})
it('should replay when paused is false and played list is empty in playAudioWithAudio', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const audio = testState.audios[0]
audio.paused = false
audio.ended = false
audio.played = null
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true)
expect(audio.play).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
})
})
describe('buffering internals', () => {
it('should finish stream when receiveAudioData gets an undefined chunk', () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const finishStream = vi
.spyOn(player as unknown as { finishStream: () => void }, 'finishStream')
.mockImplementation(() => { })
; (player as unknown as { receiveAudioData: (data: Uint8Array | undefined) => void }).receiveAudioData(undefined)
expect(finishStream).toHaveBeenCalledTimes(1)
})
it('should finish stream when receiveAudioData gets empty bytes while source is open', () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const finishStream = vi
.spyOn(player as unknown as { finishStream: () => void }, 'finishStream')
.mockImplementation(() => { })
; (player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(new Uint8Array(0))
expect(finishStream).toHaveBeenCalledTimes(1)
})
it('should queue incoming buffer when source buffer is updating', () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const mediaSource = testState.mediaSources[0]
mediaSource.emit('sourceopen')
mediaSource.sourceBuffer.updating = true
; (player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(new Uint8Array([1, 2, 3]))
expect(player.cacheBuffers.length).toBe(1)
})
it('should append previously queued buffer before new one when source buffer is idle', () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const mediaSource = testState.mediaSources[0]
mediaSource.emit('sourceopen')
const existingBuffer = new ArrayBuffer(2)
player.cacheBuffers = [existingBuffer]
mediaSource.sourceBuffer.updating = false
; (player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(new Uint8Array([9]))
expect(mediaSource.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
expect(mediaSource.sourceBuffer.appendBuffer).toHaveBeenCalledWith(existingBuffer)
expect(player.cacheBuffers.length).toBe(1)
})
it('should append cache chunks and end stream when finishStream drains buffers', () => {
vi.useFakeTimers()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const mediaSource = testState.mediaSources[0]
mediaSource.emit('sourceopen')
mediaSource.sourceBuffer.updating = false
player.cacheBuffers = [new ArrayBuffer(3)]
; (player as unknown as { finishStream: () => void }).finishStream()
vi.advanceTimersByTime(50)
expect(mediaSource.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
expect(mediaSource.endOfStream).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
})
})
@@ -26,6 +26,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
useEffect(() => {
const audio = audioRef.current
/* v8 ignore next 2 - @preserve */
if (!audio)
return
@@ -217,6 +218,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
const drawWaveform = useCallback(() => {
const canvas = canvasRef.current
/* v8 ignore next 2 - @preserve */
if (!canvas)
return
@@ -268,14 +270,20 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
drawWaveform()
}, [drawWaveform, bufferedTime, hasStartedPlaying])
const handleMouseMove = useCallback((e: React.MouseEvent) => {
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current
const audio = audioRef.current
if (!canvas || !audio)
return
const clientX = 'touches' in e
? e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX
: e.clientX
if (clientX === undefined)
return
const rect = canvas.getBoundingClientRect()
const percent = Math.min(Math.max(0, e.clientX - rect.left), rect.width) / rect.width
const percent = Math.min(Math.max(0, clientX - rect.left), rect.width) / rect.width
const time = percent * duration
// Check if the hovered position is within a buffered range before updating hoverTime
@@ -289,7 +297,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
return (
<div className="flex h-9 min-w-[240px] max-w-[420px] items-center gap-2 rounded-[10px] border border-components-panel-border-subtle bg-components-chat-input-audio-bg-alt p-2 shadow-xs backdrop-blur-sm">
<audio ref={audioRef} src={src} preload="auto">
<audio ref={audioRef} src={src} preload="auto" data-testid="audio-player">
{/* If srcs array is provided, render multiple source elements */}
{srcs && srcs.map((srcUrl, index) => (
<source key={index} src={srcUrl} />
@@ -297,12 +305,8 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
</audio>
<button type="button" data-testid="play-pause-btn" className="inline-flex shrink-0 cursor-pointer items-center justify-center border-none text-text-accent transition-all hover:text-text-accent-secondary disabled:text-components-button-primary-bg-disabled" onClick={togglePlay} disabled={!isAudioAvailable}>
{isPlaying
? (
<div className="i-ri-pause-circle-fill h-5 w-5" />
)
: (
<div className="i-ri-play-large-fill h-5 w-5" />
)}
? (<div className="i-ri-pause-circle-fill h-5 w-5" />)
: (<div className="i-ri-play-large-fill h-5 w-5" />)}
</button>
<div className={cn(isAudioAvailable && 'grow')} hidden={!isAudioAvailable}>
<div className="flex h-8 items-center justify-center">
@@ -313,6 +317,8 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
onClick={handleCanvasInteraction}
onMouseMove={handleMouseMove}
onMouseDown={handleCanvasInteraction}
onTouchMove={handleMouseMove}
onTouchStart={handleCanvasInteraction}
/>
<div className="inline-flex min-w-[50px] items-center justify-center text-text-accent-secondary system-xs-medium">
<span className="rounded-[10px] px-0.5 py-1">{formatTime(duration)}</span>
@@ -1,8 +1,7 @@
import type { ToastHandle } from '@/app/components/base/toast'
import { act, fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import useThemeMock from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import AudioPlayer from '../AudioPlayer'
@@ -45,6 +44,13 @@ async function advanceWaveformTimer() {
})
}
// eslint-disable-next-line ts/no-explicit-any
type ReactEventHandler = ((...args: any[]) => void) | undefined
function getReactProps<T extends Element>(el: T): Record<string, ReactEventHandler> {
const key = Object.keys(el).find(k => k.startsWith('__reactProps$'))
return key ? (el as unknown as Record<string, Record<string, ReactEventHandler>>)[key] : {}
}
// ─── Setup / teardown ─────────────────────────────────────────────────────────
beforeEach(() => {
@@ -56,8 +62,12 @@ beforeEach(() => {
HTMLMediaElement.prototype.load = vi.fn()
})
afterEach(() => {
vi.runOnlyPendingTimers()
afterEach(async () => {
await act(async () => {
vi.runOnlyPendingTimers()
await Promise.resolve()
await Promise.resolve()
})
vi.useRealTimers()
vi.unstubAllGlobals()
})
@@ -300,36 +310,47 @@ describe('AudioPlayer — waveform generation', () => {
expect(screen.getByTestId('waveform-canvas')).toBeInTheDocument()
})
it('should use webkitAudioContext when AudioContext is unavailable', async () => {
vi.stubGlobal('AudioContext', undefined)
vi.stubGlobal('webkitAudioContext', buildAudioContext(320))
stubFetchOk(256)
render(<AudioPlayer src="https://cdn.example/audio.mp3" />)
await advanceWaveformTimer()
expect(screen.getByTestId('waveform-canvas')).toBeInTheDocument()
})
})
// ─── Canvas interactions ──────────────────────────────────────────────────────
async function renderWithDuration(src = 'https://example.com/audio.mp3', durationVal = 120) {
vi.stubGlobal('AudioContext', buildAudioContext(300))
stubFetchOk(128)
render(<AudioPlayer src={src} />)
const audio = document.querySelector('audio') as HTMLAudioElement
Object.defineProperty(audio, 'duration', { value: durationVal, configurable: true })
Object.defineProperty(audio, 'buffered', {
value: { length: 1, start: () => 0, end: () => durationVal },
configurable: true,
})
await act(async () => {
audio.dispatchEvent(new Event('loadedmetadata'))
})
await advanceWaveformTimer()
const canvas = screen.getByTestId('waveform-canvas') as HTMLCanvasElement
canvas.getBoundingClientRect = () =>
({ left: 0, width: 200, top: 0, height: 10, right: 200, bottom: 10 }) as DOMRect
return { audio, canvas }
}
describe('AudioPlayer — canvas seek interactions', () => {
async function renderWithDuration(src = 'https://example.com/audio.mp3', durationVal = 120) {
vi.stubGlobal('AudioContext', buildAudioContext(300))
stubFetchOk(128)
render(<AudioPlayer src={src} />)
const audio = document.querySelector('audio') as HTMLAudioElement
Object.defineProperty(audio, 'duration', { value: durationVal, configurable: true })
Object.defineProperty(audio, 'buffered', {
value: { length: 1, start: () => 0, end: () => durationVal },
configurable: true,
})
await act(async () => {
audio.dispatchEvent(new Event('loadedmetadata'))
})
await advanceWaveformTimer()
const canvas = screen.getByTestId('waveform-canvas') as HTMLCanvasElement
canvas.getBoundingClientRect = () =>
({ left: 0, width: 200, top: 0, height: 10, right: 200, bottom: 10 }) as DOMRect
return { audio, canvas }
}
it('should seek to clicked position and start playback', async () => {
const { audio, canvas } = await renderWithDuration()
@@ -392,3 +413,309 @@ describe('AudioPlayer — canvas seek interactions', () => {
})
})
})
// ─── Missing coverage tests ───────────────────────────────────────────────────
describe('AudioPlayer — missing coverage', () => {
it('should handle unmounting without crashing (clears timeout)', () => {
const { unmount } = render(<AudioPlayer src="https://example.com/a.mp3" />)
unmount()
// Timer is cleared, no state update should happen after unmount
})
it('should handle getContext returning null safely', () => {
const originalGetContext = HTMLCanvasElement.prototype.getContext
HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue(null)
render(<AudioPlayer src="https://example.com/audio.mp3" />)
expect(screen.getByTestId('waveform-canvas')).toBeInTheDocument()
HTMLCanvasElement.prototype.getContext = originalGetContext
})
it('should fallback to fillRect when roundRect is missing in drawWaveform', async () => {
// Note: React 18 / testing-library wraps updates automatically, but we still wait for advanceWaveformTimer
const originalGetContext = HTMLCanvasElement.prototype.getContext
let fillRectCalled = false
HTMLCanvasElement.prototype.getContext = function (this: HTMLCanvasElement, ...args: Parameters<typeof HTMLCanvasElement.prototype.getContext>) {
const ctx = originalGetContext.apply(this, args) as CanvasRenderingContext2D | null
if (ctx) {
Object.defineProperty(ctx, 'roundRect', { value: undefined, configurable: true })
const origFillRect = ctx.fillRect
ctx.fillRect = function (...fArgs: Parameters<CanvasRenderingContext2D['fillRect']>) {
fillRectCalled = true
return origFillRect.apply(this, fArgs)
}
}
return ctx as CanvasRenderingContext2D
} as typeof HTMLCanvasElement.prototype.getContext
vi.stubGlobal('AudioContext', buildAudioContext(300))
stubFetchOk(128)
render(<AudioPlayer src="https://example.com/audio.mp3" />)
await advanceWaveformTimer()
expect(fillRectCalled).toBe(true)
HTMLCanvasElement.prototype.getContext = originalGetContext
})
it('should handle play error gracefully when togglePlay is clicked', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
vi.spyOn(HTMLMediaElement.prototype, 'play').mockRejectedValue(new Error('play failed'))
render(<AudioPlayer src="https://example.com/audio.mp3" />)
const btn = screen.getByTestId('play-pause-btn')
await act(async () => {
fireEvent.click(btn)
})
expect(errorSpy).toHaveBeenCalled()
errorSpy.mockRestore()
})
it('should notify error when audio.play() fails during canvas seek', async () => {
vi.stubGlobal('AudioContext', buildAudioContext(300))
stubFetchOk(128)
render(<AudioPlayer src="https://example.com/audio.mp3" />)
await advanceWaveformTimer()
const canvas = screen.getByTestId('waveform-canvas') as HTMLCanvasElement
const audio = document.querySelector('audio') as HTMLAudioElement
Object.defineProperty(audio, 'duration', { value: 120, configurable: true })
canvas.getBoundingClientRect = () => ({ left: 0, width: 200, top: 0, height: 10, right: 200, bottom: 10 }) as DOMRect
vi.spyOn(HTMLMediaElement.prototype, 'play').mockRejectedValue(new Error('play failed'))
await act(async () => {
fireEvent.click(canvas, { clientX: 100 })
})
// We can observe the error by checking document body for toast if Toast acts synchronously
// Or we just ensure the execution branched into catch naturally.
expect(HTMLMediaElement.prototype.play).toHaveBeenCalled()
})
it('should support touch events on canvas', async () => {
vi.stubGlobal('AudioContext', buildAudioContext(300))
stubFetchOk(128)
render(<AudioPlayer src="https://example.com/audio.mp3" />)
await advanceWaveformTimer()
const canvas = screen.getByTestId('waveform-canvas') as HTMLCanvasElement
const audio = document.querySelector('audio') as HTMLAudioElement
Object.defineProperty(audio, 'duration', { value: 120, configurable: true })
canvas.getBoundingClientRect = () => ({ left: 0, width: 200, top: 0, height: 10, right: 200, bottom: 10 }) as DOMRect
await act(async () => {
// Use touch events
fireEvent.touchStart(canvas, {
touches: [{ clientX: 50 }],
})
})
expect(HTMLMediaElement.prototype.play).toHaveBeenCalled()
})
it('should gracefully handle interaction when canvas/audio refs are null', async () => {
const { unmount } = render(<AudioPlayer src="https://example.com/audio.mp3" />)
const canvas = screen.getByTestId('waveform-canvas')
unmount()
expect(canvas).toBeTruthy()
})
it('should keep play button disabled when source is unavailable', async () => {
vi.stubGlobal('AudioContext', buildAudioContext(300))
const toastSpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({} as unknown as ToastHandle))
render(<AudioPlayer src="blob:https://example.com" />)
await advanceWaveformTimer() // sets isAudioAvailable to false (invalid protocol)
const btn = screen.getByTestId('play-pause-btn')
await act(async () => {
fireEvent.click(btn)
})
expect(btn).toBeDisabled()
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled()
expect(toastSpy).not.toHaveBeenCalled()
toastSpy.mockRestore()
})
it('should notify when toggle is invoked while audio is unavailable', async () => {
const toastSpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({} as unknown as ToastHandle))
render(<AudioPlayer src="https://example.com/a.mp3" />)
const audio = document.querySelector('audio') as HTMLAudioElement
await act(async () => {
audio.dispatchEvent(new Event('error'))
})
const btn = screen.getByTestId('play-pause-btn')
const props = getReactProps(btn)
await act(async () => {
props.onClick?.()
})
expect(toastSpy).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'Audio element not found',
}))
toastSpy.mockRestore()
})
})
describe('AudioPlayer — additional branch coverage', () => {
it('should render multiple source elements when srcs is provided', () => {
render(<AudioPlayer srcs={['a.mp3', 'b.ogg']} />)
const audio = screen.getByTestId('audio-player')
const sources = audio.querySelectorAll('source')
expect(sources).toHaveLength(2)
})
it('should handle handleMouseMove with empty touch list', async () => {
vi.stubGlobal('AudioContext', buildAudioContext(300))
stubFetchOk(128)
render(<AudioPlayer src="https://example.com/a.mp3" />)
await advanceWaveformTimer()
const canvas = screen.getByTestId('waveform-canvas')
await act(async () => {
fireEvent.touchMove(canvas, {
touches: [],
changedTouches: [{ clientX: 50 }],
})
})
})
it('should handle handleMouseMove with missing clientX', async () => {
vi.stubGlobal('AudioContext', buildAudioContext(300))
stubFetchOk(128)
render(<AudioPlayer src="https://example.com/a.mp3" />)
await advanceWaveformTimer()
const canvas = screen.getByTestId('waveform-canvas')
await act(async () => {
fireEvent.touchMove(canvas, {
touches: [{}] as unknown as TouchList,
})
})
})
it('should render "Audio source unavailable" when isAudioAvailable is false', async () => {
render(<AudioPlayer src="https://example.com/a.mp3" />)
const audio = document.querySelector('audio') as HTMLAudioElement
await act(async () => {
audio.dispatchEvent(new Event('error'))
})
expect(screen.queryByTestId('play-pause-btn')).toBeDisabled()
})
it('should update current time on timeupdate event', async () => {
render(<AudioPlayer src="https://example.com/a.mp3" />)
const audio = document.querySelector('audio') as HTMLAudioElement
Object.defineProperty(audio, 'currentTime', { value: 10, configurable: true })
await act(async () => {
audio.dispatchEvent(new Event('timeupdate'))
})
})
it('should ignore toggle click after audio error marks source unavailable', async () => {
const toastSpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({} as unknown as ToastHandle))
render(<AudioPlayer src="https://example.com/a.mp3" />)
const audio = document.querySelector('audio') as HTMLAudioElement
await act(async () => {
audio.dispatchEvent(new Event('error'))
})
const btn = screen.getByTestId('play-pause-btn')
await act(async () => {
fireEvent.click(btn)
})
expect(btn).toBeDisabled()
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled()
expect(toastSpy).not.toHaveBeenCalled()
toastSpy.mockRestore()
})
it('should cover Dark theme waveform states', async () => {
; (useThemeMock as ReturnType<typeof vi.fn>).mockReturnValue({ theme: Theme.dark })
vi.stubGlobal('AudioContext', buildAudioContext(300))
stubFetchOk(128)
render(<AudioPlayer src="https://example.com/audio.mp3" />)
const audio = document.querySelector('audio') as HTMLAudioElement
Object.defineProperty(audio, 'duration', { value: 100, configurable: true })
Object.defineProperty(audio, 'currentTime', { value: 50, configurable: true })
await act(async () => {
audio.dispatchEvent(new Event('loadedmetadata'))
audio.dispatchEvent(new Event('timeupdate'))
})
await advanceWaveformTimer()
expect(screen.getByTestId('waveform-canvas')).toBeInTheDocument()
})
it('should handle missing canvas/audio in handleCanvasInteraction/handleMouseMove', async () => {
const { unmount } = render(<AudioPlayer src="https://example.com/a.mp3" />)
const canvas = screen.getByTestId('waveform-canvas')
unmount()
fireEvent.click(canvas)
fireEvent.mouseMove(canvas)
})
it('should cover waveform branches for hover and played states', async () => {
const { audio, canvas } = await renderWithDuration('https://example.com/a.mp3', 100)
// Set some progress
Object.defineProperty(audio, 'currentTime', { value: 20, configurable: true })
// Trigger hover on a buffered range
Object.defineProperty(audio, 'buffered', {
value: { length: 1, start: () => 0, end: () => 100 },
configurable: true,
})
await act(async () => {
fireEvent.mouseMove(canvas, { clientX: 50 }) // 50s hover
audio.dispatchEvent(new Event('timeupdate'))
})
expect(canvas).toBeInTheDocument()
})
it('should hit null-ref guards in canvas handlers after unmount', async () => {
const { unmount } = render(<AudioPlayer src="https://example.com/a.mp3" />)
const canvas = screen.getByTestId('waveform-canvas')
const props = getReactProps(canvas)
unmount()
await act(async () => {
props.onClick?.({ preventDefault: vi.fn(), clientX: 10 })
props.onMouseMove?.({ clientX: 10 })
})
})
it('should execute non-matching buffered branch in hover loop', async () => {
const { audio, canvas } = await renderWithDuration('https://example.com/a.mp3', 100)
Object.defineProperty(audio, 'buffered', {
value: { length: 1, start: () => 0, end: () => 10 },
configurable: true,
})
await act(async () => {
fireEvent.mouseMove(canvas, { clientX: 180 }) // time near 90, outside 0-10
})
expect(canvas).toBeInTheDocument()
})
})
@@ -1,24 +1,9 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
// AudioGallery.spec.tsx
import { describe, expect, it, vi } from 'vitest'
import AudioGallery from '../index'
// Mock AudioPlayer so we only assert prop forwarding
const audioPlayerMock = vi.fn()
vi.mock('../AudioPlayer', () => ({
default: (props: { srcs: string[] }) => {
audioPlayerMock(props)
return <div data-testid="audio-player" />
},
}))
describe('AudioGallery', () => {
afterEach(() => {
audioPlayerMock.mockClear()
vi.resetModules()
beforeEach(() => {
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => { })
})
it('returns null when srcs array is empty', () => {
@@ -33,11 +18,15 @@ describe('AudioGallery', () => {
expect(screen.queryByTestId('audio-player')).toBeNull()
})
it('filters out falsy srcs and passes valid srcs to AudioPlayer', () => {
it('filters out falsy srcs and renders only valid sources in AudioPlayer', () => {
render(<AudioGallery srcs={['a.mp3', '', 'b.mp3']} />)
expect(screen.getByTestId('audio-player')).toBeInTheDocument()
expect(audioPlayerMock).toHaveBeenCalledTimes(1)
expect(audioPlayerMock).toHaveBeenCalledWith({ srcs: ['a.mp3', 'b.mp3'] })
const audio = screen.getByTestId('audio-player')
const sources = audio.querySelectorAll('source')
expect(audio).toBeInTheDocument()
expect(sources).toHaveLength(2)
expect(sources[0]?.getAttribute('src')).toBe('a.mp3')
expect(sources[1]?.getAttribute('src')).toBe('b.mp3')
})
it('wraps AudioPlayer inside container with expected class', () => {
@@ -45,5 +34,6 @@ describe('AudioGallery', () => {
const root = container.firstChild as HTMLElement
expect(root).toBeTruthy()
expect(root.className).toContain('my-3')
expect(screen.getByTestId('audio-player')).toBeInTheDocument()
})
})
@@ -1,6 +1,18 @@
import type { ChatItemInTree } from '../types'
import type { IChatItem } from '../chat/type'
import type { ChatItem, ChatItemInTree } from '../types'
import { get } from 'es-toolkit/compat'
import { buildChatItemTree, getThreadMessages } from '../utils'
import { UUID_NIL } from '../constants'
import {
buildChatItemTree,
getLastAnswer,
getProcessedInputsFromUrlParams,
getProcessedSystemVariablesFromUrlParams,
getProcessedUserVariablesFromUrlParams,
getRawInputsFromUrlParams,
getRawUserVariablesFromUrlParams,
getThreadMessages,
isValidGeneratedAnswer,
} from '../utils'
import branchedTestMessages from './branchedTestMessages.json'
import legacyTestMessages from './legacyTestMessages.json'
import mixedTestMessages from './mixedTestMessages.json'
@@ -13,6 +25,15 @@ function visitNode(tree: ChatItemInTree | ChatItemInTree[], path: string): ChatI
return get(tree, path)
}
class MockDecompressionStream {
readable: unknown
writable: unknown
constructor() {
this.readable = {}
this.writable = {}
}
}
describe('build chat item tree and get thread messages', () => {
const tree1 = buildChatItemTree(branchedTestMessages as ChatItemInTree[])
@@ -247,12 +268,12 @@ describe('build chat item tree and get thread messages', () => {
expect(tree6).toMatchSnapshot()
})
it ('should get thread messages from tree6, using the last message as target', () => {
it('should get thread messages from tree6, using the last message as target', () => {
const threadMessages6_1 = getThreadMessages(tree6)
expect(threadMessages6_1).toMatchSnapshot()
})
it ('should get thread messages from tree6, using specified message as target', () => {
it('should get thread messages from tree6, using specified message as target', () => {
const threadMessages6_2 = getThreadMessages(tree6, 'ff4c2b43-48a5-47ad-9dc5-08b34ddba61b')
expect(threadMessages6_2).toMatchSnapshot()
})
@@ -269,3 +290,285 @@ describe('build chat item tree and get thread messages', () => {
expect(tree8).toMatchSnapshot()
})
})
describe('chat utils - url params and answer helpers', () => {
const setSearch = (search: string) => {
window.history.replaceState({}, '', `${window.location.pathname}${search}`)
}
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('DecompressionStream', MockDecompressionStream)
vi.stubGlobal('TextDecoder', class {
decode() { return 'decompressed_text' }
})
const mockPipeThrough = vi.fn().mockReturnValue({})
vi.stubGlobal('Response', class {
body = { pipeThrough: mockPipeThrough }
arrayBuffer = vi.fn().mockResolvedValue(new ArrayBuffer(8))
})
setSearch('')
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('URL Parameter Extractors', () => {
it('getRawInputsFromUrlParams extracts inputs except sys. and user.', async () => {
setSearch('?custom=123&sys.param=456&user.param=789&encoded=a%20b')
const res = await getRawInputsFromUrlParams()
expect(res).toEqual({ custom: '123', encoded: 'a b' })
})
it('getRawUserVariablesFromUrlParams extracts only user. prefixed params', async () => {
setSearch('?custom=123&sys.param=456&user.param=789&user.encoded=a%20b')
const res = await getRawUserVariablesFromUrlParams()
expect(res).toEqual({ param: '789', encoded: 'a b' })
})
it('getProcessedInputsFromUrlParams decompresses base64 inputs', async () => {
setSearch('?custom=123&sys.param=456&user.param=789')
const res = await getProcessedInputsFromUrlParams()
expect(res).toEqual({ custom: 'decompressed_text' })
})
it('getProcessedSystemVariablesFromUrlParams decompresses sys. prefixed params', async () => {
setSearch('?custom=123&sys.param=456&user.param=789')
const res = await getProcessedSystemVariablesFromUrlParams()
expect(res).toEqual({ param: 'decompressed_text' })
})
it('getProcessedSystemVariablesFromUrlParams parses redirect_url without query string', async () => {
setSearch(`?redirect_url=${encodeURIComponent('http://example.com')}&sys.param=456`)
const res = await getProcessedSystemVariablesFromUrlParams()
expect(res).toEqual({ param: 'decompressed_text' })
})
it('getProcessedSystemVariablesFromUrlParams parses redirect_url', async () => {
setSearch(`?redirect_url=${encodeURIComponent('http://example.com?sys.redirected=abc')}&sys.param=456`)
const res = await getProcessedSystemVariablesFromUrlParams()
expect(res).toEqual({ param: 'decompressed_text', redirected: 'decompressed_text' })
})
it('getProcessedUserVariablesFromUrlParams decompresses user. prefixed params', async () => {
setSearch('?custom=123&sys.param=456&user.param=789')
const res = await getProcessedUserVariablesFromUrlParams()
expect(res).toEqual({ param: 'decompressed_text' })
})
it('decodeBase64AndDecompress failure returns undefined softly', async () => {
vi.stubGlobal('atob', () => {
throw new Error('invalid')
})
setSearch('?custom=invalid_base64')
const res = await getProcessedInputsFromUrlParams()
expect(res).toEqual({ custom: undefined })
})
})
describe('Answer Validation', () => {
it('isValidGeneratedAnswer returns true for typical answers', () => {
expect(isValidGeneratedAnswer({ isAnswer: true, id: '123', isOpeningStatement: false } as ChatItem)).toBe(true)
})
it('isValidGeneratedAnswer returns false for placeholders', () => {
expect(isValidGeneratedAnswer({ isAnswer: true, id: 'answer-placeholder-123', isOpeningStatement: false } as ChatItem)).toBe(false)
})
it('isValidGeneratedAnswer returns false for opening statements', () => {
expect(isValidGeneratedAnswer({ isAnswer: true, id: '123', isOpeningStatement: true } as ChatItem)).toBe(false)
})
it('isValidGeneratedAnswer returns false for questions', () => {
expect(isValidGeneratedAnswer({ isAnswer: false, id: '123', isOpeningStatement: false } as ChatItem)).toBe(false)
})
it('isValidGeneratedAnswer returns false for falsy items', () => {
expect(isValidGeneratedAnswer(undefined)).toBe(false)
})
it('getLastAnswer returns the last valid answer from a list', () => {
const list = [
{ isAnswer: false, id: 'q1', isOpeningStatement: false },
{ isAnswer: true, id: 'a1', isOpeningStatement: false },
{ isAnswer: false, id: 'q2', isOpeningStatement: false },
{ isAnswer: true, id: 'answer-placeholder-2', isOpeningStatement: false },
] as ChatItem[]
expect(getLastAnswer(list)?.id).toBe('a1')
})
it('getLastAnswer returns null if no valid answer', () => {
const list = [
{ isAnswer: false, id: 'q1', isOpeningStatement: false },
{ isAnswer: true, id: 'answer-placeholder-2', isOpeningStatement: false },
] as ChatItem[]
expect(getLastAnswer(list)).toBeNull()
})
})
describe('ChatItem Tree Builders', () => {
it('buildChatItemTree builds a flat tree for legacy messages (parentMessageId = UUID_NIL)', () => {
const list: IChatItem[] = [
{ id: 'q1', isAnswer: false, parentMessageId: UUID_NIL } as IChatItem,
{ id: 'a1', isAnswer: true, parentMessageId: UUID_NIL } as IChatItem,
{ id: 'q2', isAnswer: false, parentMessageId: UUID_NIL } as IChatItem,
{ id: 'a2', isAnswer: true, parentMessageId: UUID_NIL } as IChatItem,
]
const tree = buildChatItemTree(list)
expect(tree.length).toBe(1)
expect(tree[0].id).toBe('q1')
expect(tree[0].children?.[0].id).toBe('a1')
expect(tree[0].children?.[0].children?.[0].id).toBe('q2')
expect(tree[0].children?.[0].children?.[0].children?.[0].id).toBe('a2')
expect(tree[0].children?.[0].children?.[0].children?.[0].siblingIndex).toBe(0)
})
it('buildChatItemTree builds nested tree based on parentMessageId', () => {
const list: IChatItem[] = [
{ id: 'q1', isAnswer: false, parentMessageId: null } as IChatItem,
{ id: 'a1', isAnswer: true } as IChatItem,
{ id: 'q2', isAnswer: false, parentMessageId: 'a1' } as IChatItem,
{ id: 'a2', isAnswer: true } as IChatItem,
{ id: 'q3', isAnswer: false, parentMessageId: 'a1' } as IChatItem,
{ id: 'a3', isAnswer: true } as IChatItem,
{ id: 'q4', isAnswer: false, parentMessageId: 'missing-parent' } as IChatItem,
{ id: 'a4', isAnswer: true } as IChatItem,
]
const tree = buildChatItemTree(list)
expect(tree.length).toBe(2)
expect(tree[0].id).toBe('q1')
expect(tree[1].id).toBe('q4')
const a1 = tree[0].children![0]
expect(a1.id).toBe('a1')
expect(a1.children?.length).toBe(2)
expect(a1.children![0].id).toBe('q2')
expect(a1.children![1].id).toBe('q3')
expect(a1.children![0].children![0].siblingIndex).toBe(0)
expect(a1.children![1].children![0].siblingIndex).toBe(1)
})
it('getThreadMessages node without children', () => {
const tree = [{ id: 'q1', isAnswer: false }]
const thread = getThreadMessages(tree as unknown as ChatItemInTree[], 'q1')
expect(thread.length).toBe(1)
expect(thread[0].id).toBe('q1')
})
it('getThreadMessages target not found', () => {
const tree = [{ id: 'q1', isAnswer: false, children: [] }]
const thread = getThreadMessages(tree as unknown as ChatItemInTree[], 'missing')
expect(thread.length).toBe(0)
})
it('getThreadMessages target not found with undefined children', () => {
const tree = [{ id: 'q1', isAnswer: false }]
const thread = getThreadMessages(tree as unknown as ChatItemInTree[], 'missing')
expect(thread.length).toBe(0)
})
it('getThreadMessages flat path logic', () => {
const tree = [{
id: 'q1',
isAnswer: false,
children: [{
id: 'a1',
isAnswer: true,
siblingIndex: 0,
children: [{
id: 'q2',
isAnswer: false,
children: [{
id: 'a2',
isAnswer: true,
siblingIndex: 0,
children: [],
}],
}],
}],
}]
const thread = getThreadMessages(tree as unknown as ChatItemInTree[])
expect(thread.length).toBe(4)
expect(thread.map(t => t.id)).toEqual(['q1', 'a1', 'q2', 'a2'])
expect(thread[1].siblingCount).toBe(1)
expect(thread[3].siblingCount).toBe(1)
})
it('getThreadMessages to specific target', () => {
const tree = [{
id: 'q1',
isAnswer: false,
children: [{
id: 'a1',
isAnswer: true,
siblingIndex: 0,
children: [{
id: 'q2',
isAnswer: false,
children: [{
id: 'a2',
isAnswer: true,
siblingIndex: 0,
children: [],
}],
}, {
id: 'q3',
isAnswer: false,
children: [{
id: 'a3',
isAnswer: true,
siblingIndex: 1,
children: [],
}],
}],
}],
}]
const thread = getThreadMessages(tree as unknown as ChatItemInTree[], 'a3')
expect(thread.length).toBe(4)
expect(thread.map(t => t.id)).toEqual(['q1', 'a1', 'q3', 'a3'])
expect(thread[3].prevSibling).toBe('a2')
expect(thread[3].nextSibling).toBeUndefined()
})
it('getThreadMessages targetNode has descendants', () => {
const tree = [{
id: 'q1',
isAnswer: false,
children: [{
id: 'a1',
isAnswer: true,
siblingIndex: 0,
children: [{
id: 'q2',
isAnswer: false,
children: [{
id: 'a2',
isAnswer: true,
siblingIndex: 0,
children: [],
}],
}, {
id: 'q3',
isAnswer: false,
children: [{
id: 'a3',
isAnswer: true,
siblingIndex: 1,
children: [],
}],
}],
}],
}]
const thread = getThreadMessages(tree as unknown as ChatItemInTree[], 'a1')
expect(thread.length).toBe(4)
expect(thread.map(t => t.id)).toEqual(['q1', 'a1', 'q3', 'a3'])
expect(thread[3].prevSibling).toBe('a2')
})
})
})
@@ -4,12 +4,11 @@ import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { AppData, AppMeta, ConversationItem } from '@/models/share'
import type { HumanInputFormData } from '@/types/workflow'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { InputVarType } from '@/app/components/workflow/types'
import {
fetchSuggestedQuestions,
stopChatMessageResponding,
submitHumanInputForm,
} from '@/service/share'
import { TransferMethod } from '@/types/app'
import { useChat } from '../../chat/hooks'
@@ -501,6 +500,34 @@ describe('ChatWrapper', () => {
expect(handleSwitchSibling).toHaveBeenCalledWith('1', expect.any(Object))
})
it('should call fetchSuggestedQuestions from workflow resumption options callback', () => {
const handleSwitchSibling = vi.fn()
vi.mocked(useChat).mockReturnValue({
...defaultChatHookReturn,
chatList: [],
handleSwitchSibling,
} as unknown as ChatHookReturn)
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
appPrevChatTree: [{
id: 'resume-node',
content: 'Paused answer',
isAnswer: true,
workflow_run_id: 'workflow-1',
humanInputFormDataList: [{ label: 'resume' }] as unknown as HumanInputFormData[],
children: [],
}],
})
render(<ChatWrapper />)
expect(handleSwitchSibling).toHaveBeenCalledWith('resume-node', expect.any(Object))
const resumeOptions = handleSwitchSibling.mock.calls[0][1]
resumeOptions.onGetSuggestedQuestions('response-from-resume')
expect(fetchSuggestedQuestions).toHaveBeenCalledWith('response-from-resume', 'webApp', 'test-app-id')
})
it('should handle workflow resumption with nested children (DFS)', () => {
const handleSwitchSibling = vi.fn()
vi.mocked(useChat).mockReturnValue({
@@ -760,6 +787,47 @@ describe('ChatWrapper', () => {
})
})
it('should handle human input form submission for web app', async () => {
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
isInstalledApp: false,
})
vi.mocked(useChat).mockReturnValue({
...defaultChatHookReturn,
chatList: [
{ id: 'q1', content: 'Question' },
{
id: 'a1',
isAnswer: true,
content: '',
humanInputFormDataList: [{
id: 'node1',
form_id: 'form1',
form_token: 'token-web-1',
node_id: 'node1',
node_title: 'Node Web 1',
display_in_ui: true,
form_content: '{{#$output.test#}}',
inputs: [{ variable: 'test', label: 'Test', type: 'paragraph', required: true, output_variable_name: 'test', default: { type: 'text', value: '' } }],
actions: [{ id: 'run', title: 'Run', button_style: 'primary' }],
}] as unknown as HumanInputFormData[],
},
],
} as unknown as ChatHookReturn)
render(<ChatWrapper />)
expect(await screen.findByText('Node Web 1')).toBeInTheDocument()
const input = screen.getAllByRole('textbox').find(el => el.closest('.chat-answer-container')) || screen.getAllByRole('textbox')[0]
fireEvent.change(input, { target: { value: 'web-test' } })
fireEvent.click(screen.getByText('Run'))
await waitFor(() => {
expect(submitHumanInputForm).toHaveBeenCalledWith('token-web-1', expect.any(Object))
})
})
it('should filter opening statement in new conversation with single item', () => {
vi.mocked(useChat).mockReturnValue({
...defaultChatHookReturn,
@@ -888,8 +956,16 @@ describe('ChatWrapper', () => {
})
it('should render answer icon when configured', () => {
const appDataWithAnswerIcon = {
site: {
...mockAppData.site,
use_icon_as_answer_icon: true,
},
} as unknown as AppData
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
appData: appDataWithAnswerIcon,
} as ChatWithHistoryContextValue)
vi.mocked(useChat).mockReturnValue({
@@ -899,6 +975,7 @@ describe('ChatWrapper', () => {
render(<ChatWrapper />)
expect(screen.getByText('Answer')).toBeInTheDocument()
expect(screen.getByAltText('answer icon')).toBeInTheDocument()
})
it('should render question icon when user avatar is available', () => {
@@ -920,6 +997,26 @@ describe('ChatWrapper', () => {
expect(avatar).toBeInTheDocument()
})
it('should use fallback values for nullable appData, appMeta and user name', () => {
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
appData: null as unknown as AppData,
appMeta: null as unknown as AppMeta,
initUserVariables: {
avatar_url: 'https://example.com/avatar-fallback.png',
},
})
vi.mocked(useChat).mockReturnValue({
...defaultChatHookReturn,
chatList: [{ id: 'q1', content: 'Question with fallback avatar name' }],
} as unknown as ChatHookReturn)
render(<ChatWrapper />)
expect(screen.getByText('Question with fallback avatar name')).toBeInTheDocument()
expect(screen.getByAltText('user')).toBeInTheDocument()
})
it('should set handleStop on currentChatInstanceRef', () => {
const handleStop = vi.fn()
const currentChatInstanceRef = { current: { handleStop: vi.fn() } } as ChatWithHistoryContextValue['currentChatInstanceRef']
@@ -1212,20 +1309,45 @@ describe('ChatWrapper', () => {
it('should handle doRegenerate with editedQuestion', async () => {
const handleSend = vi.fn()
const mockFiles = [
{
id: 'file-q1',
name: 'q1.txt',
type: 'text/plain',
size: 100,
url: 'https://example.com/q1.txt',
extension: 'txt',
mime_type: 'text/plain',
} as unknown as FileEntity,
] as FileEntity[]
vi.mocked(useChat).mockReturnValue({
...defaultChatHookReturn,
chatList: [
{ id: 'q1', content: 'Original question', message_files: [] },
{ id: 'q1', content: 'Original question', message_files: mockFiles },
{ id: 'a1', isAnswer: true, content: 'Answer', parentMessageId: 'q1' },
],
handleSend,
} as unknown as ChatHookReturn)
const { container } = render(<ChatWrapper />)
render(<ChatWrapper />)
// This would test line 198-200 - the editedQuestion path
// The actual regenerate with edited question happens through the UI
expect(container).toBeInTheDocument()
fireEvent.click(await screen.findByTestId('edit-btn'))
const editedTextarea = await screen.findByDisplayValue('Original question')
fireEvent.change(editedTextarea, { target: { value: 'Edited question text' } })
fireEvent.click(screen.getByTestId('save-edit-btn'))
await waitFor(() => {
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
query: 'Edited question text',
files: mockFiles,
}),
expect.any(Object),
)
})
})
it('should handle doRegenerate when parentAnswer is not a valid generated answer', async () => {
@@ -1692,4 +1814,31 @@ describe('ChatWrapper', () => {
// Should not be disabled because it's not required
expect(container).not.toBeInTheDocument()
})
it('should handle fallback branches for appParams, appId and empty chat instance ref', async () => {
const handleSend = vi.fn()
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
appParams: undefined as unknown as ChatConfig,
appId: '',
currentConversationId: '',
currentChatInstanceRef: { current: null } as unknown as ChatWithHistoryContextValue['currentChatInstanceRef'],
})
vi.mocked(useChat).mockReturnValue({
...defaultChatHookReturn,
handleSend,
} as unknown as ChatHookReturn)
render(<ChatWrapper />)
const textarea = screen.getByRole('textbox')
fireEvent.change(textarea, { target: { value: 'trigger fallback path' } })
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', keyCode: 13 })
await waitFor(() => {
expect(handleSend).toHaveBeenCalled()
})
})
})
@@ -1,9 +1,9 @@
import type { i18n } from 'i18next'
import type { ChatConfig } from '../../types'
import type { ChatWithHistoryContextValue } from '../context'
import type { AppData, AppMeta, ConversationItem } from '@/models/share'
import type { AppData, AppMeta } from '@/models/share'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as ReactI18next from 'react-i18next'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useChatWithHistoryContext } from '../context'
import HeaderInMobile from '../header-in-mobile'
@@ -80,7 +80,14 @@ vi.mock('@/app/components/base/modal', () => ({
// Sidebar mock removed to use real component
const mockAppData = { site: { title: 'Test Chat', chat_color_theme: 'blue' } } as unknown as AppData
const mockAppData: AppData = {
app_id: 'test-app',
custom_config: null,
site: {
title: 'Test Chat',
chat_color_theme: 'blue',
},
}
const defaultContextValue: ChatWithHistoryContextValue = {
appData: mockAppData,
currentConversationId: '',
@@ -104,18 +111,27 @@ const defaultContextValue: ChatWithHistoryContextValue = {
currentChatInstanceRef: { current: { handleStop: vi.fn() } } as ChatWithHistoryContextValue['currentChatInstanceRef'],
setIsResponding: vi.fn(),
setClearChatList: vi.fn(),
appParams: { system_parameters: { vision_config: { enabled: false } } } as unknown as ChatConfig,
appMeta: {} as AppMeta,
appParams: {
system_parameters: {
audio_file_size_limit: 10,
file_size_limit: 10,
image_file_size_limit: 10,
video_file_size_limit: 10,
workflow_file_upload_limit: 10,
},
more_like_this: { enabled: false },
} as ChatConfig,
appMeta: { tool_icons: {} } as AppMeta,
appPrevChatTree: [],
newConversationInputs: {},
newConversationInputsRef: { current: {} } as ChatWithHistoryContextValue['newConversationInputsRef'],
newConversationInputsRef: { current: {} },
appChatListDataLoading: false,
chatShouldReloadKey: '',
isMobile: true,
currentConversationInputs: null,
setCurrentConversationInputs: vi.fn(),
allInputsHidden: false,
conversationRenaming: false, // Added missing property
conversationRenaming: false,
}
describe('HeaderInMobile', () => {
@@ -134,7 +150,7 @@ describe('HeaderInMobile', () => {
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
currentConversationId: '1',
currentConversationItem: { id: '1', name: 'Conv 1' } as unknown as ConversationItem,
currentConversationItem: { id: '1', name: 'Conv 1', inputs: null, introduction: '' },
})
render(<HeaderInMobile />)
@@ -270,7 +286,7 @@ describe('HeaderInMobile', () => {
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
currentConversationId: '1',
currentConversationItem: { id: '1', name: 'Conv 1' } as unknown as ConversationItem,
currentConversationItem: { id: '1', name: 'Conv 1', inputs: null, introduction: '' },
handlePinConversation: handlePin,
pinnedConversationList: [],
})
@@ -292,9 +308,9 @@ describe('HeaderInMobile', () => {
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
currentConversationId: '1',
currentConversationItem: { id: '1', name: 'Conv 1' } as unknown as ConversationItem,
currentConversationItem: { id: '1', name: 'Conv 1', inputs: null, introduction: '' },
handleUnpinConversation: handleUnpin,
pinnedConversationList: [{ id: '1' }] as unknown as ConversationItem[],
pinnedConversationList: [{ id: '1', name: 'Conv 1', inputs: null, introduction: '' }],
})
render(<HeaderInMobile />)
@@ -314,7 +330,7 @@ describe('HeaderInMobile', () => {
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
currentConversationId: '1',
currentConversationItem: { id: '1', name: 'Conv 1' } as unknown as ConversationItem,
currentConversationItem: { id: '1', name: 'Conv 1', inputs: null, introduction: '' },
handleRenameConversation: handleRename,
pinnedConversationList: [],
})
@@ -342,7 +358,7 @@ describe('HeaderInMobile', () => {
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
currentConversationId: '1',
currentConversationItem: { id: '1', name: 'Conv 1' } as unknown as ConversationItem,
currentConversationItem: { id: '1', name: 'Conv 1', inputs: null, introduction: '' },
handleRenameConversation: handleRename,
pinnedConversationList: [],
})
@@ -373,7 +389,7 @@ describe('HeaderInMobile', () => {
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
currentConversationId: '1',
currentConversationItem: { id: '1', name: 'Conv 1' } as unknown as ConversationItem,
currentConversationItem: { id: '1', name: 'Conv 1', inputs: null, introduction: '' },
handleRenameConversation: vi.fn(),
conversationRenaming: true, // Loading state
pinnedConversationList: [],
@@ -396,7 +412,7 @@ describe('HeaderInMobile', () => {
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
currentConversationId: '1',
currentConversationItem: { id: '1', name: 'Conv 1' } as unknown as ConversationItem,
currentConversationItem: { id: '1', name: 'Conv 1', inputs: null, introduction: '' },
handleDeleteConversation: handleDelete,
pinnedConversationList: [],
})
@@ -422,7 +438,7 @@ describe('HeaderInMobile', () => {
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
currentConversationId: '1',
currentConversationItem: { id: '1', name: 'Conv 1' } as unknown as ConversationItem,
currentConversationItem: { id: '1', name: 'Conv 1', inputs: null, introduction: '' },
handleDeleteConversation: handleDelete,
pinnedConversationList: [],
})
@@ -454,7 +470,7 @@ describe('HeaderInMobile', () => {
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
currentConversationId: '1',
currentConversationItem: { id: '1', name: '' } as unknown as ConversationItem,
currentConversationItem: { id: '1', name: '', inputs: null, introduction: '' },
})
render(<HeaderInMobile />)
@@ -485,16 +501,17 @@ describe('HeaderInMobile', () => {
})
it('should render app icon and title correctly', () => {
const appDataWithIcon = {
const appDataWithIcon: AppData = {
app_id: 'test-app',
custom_config: null,
site: {
title: 'My App',
icon: 'emoji',
icon_type: 'emoji',
icon_url: '',
icon_background: '#FF0000',
chat_color_theme: 'blue',
},
} as unknown as AppData
}
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
@@ -512,7 +529,7 @@ describe('HeaderInMobile', () => {
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
currentConversationId: '1',
currentConversationItem: { id: '1', name: 'Conv 1' } as unknown as ConversationItem,
currentConversationItem: { id: '1', name: 'Conv 1', inputs: null, introduction: '' },
handleRenameConversation: handleRename,
handleDeleteConversation: handleDelete,
pinnedConversationList: [],
@@ -524,4 +541,59 @@ describe('HeaderInMobile', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.queryByText('share.chat.deleteConversation.title')).not.toBeInTheDocument()
})
it('should use empty string fallback for delete content translation', async () => {
const handleDelete = vi.fn()
const useTranslationSpy = vi.spyOn(ReactI18next, 'useTranslation')
useTranslationSpy.mockReturnValue({
t: (key: string) => key === 'chat.deleteConversation.content' ? '' : key,
i18n: {} as unknown as i18n,
ready: true,
tReady: true,
} as unknown as ReturnType<typeof ReactI18next.useTranslation>)
try {
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
currentConversationId: '1',
currentConversationItem: { id: '1', name: 'Conv 1', inputs: null, introduction: '' },
handleDeleteConversation: handleDelete,
pinnedConversationList: [],
})
render(<HeaderInMobile />)
fireEvent.click(await screen.findByText('Conv 1'))
fireEvent.click(await screen.findByText(/sidebar\.action\.delete/i))
expect(await screen.findByRole('button', { name: /common\.operation\.confirm|operation\.confirm/i })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.confirm|operation\.confirm/i }))
expect(handleDelete).toHaveBeenCalledWith('1', expect.any(Object))
}
finally {
useTranslationSpy.mockRestore()
}
})
it('should use empty string fallback for rename modal name', async () => {
const handleRename = vi.fn()
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
currentConversationId: '1',
currentConversationItem: { id: '1', name: '', inputs: null, introduction: '' },
handleRenameConversation: handleRename,
pinnedConversationList: [],
})
const { container } = render(<HeaderInMobile />)
const operationTrigger = container.querySelector('.system-md-semibold')?.parentElement as HTMLElement
fireEvent.click(operationTrigger)
fireEvent.click(await screen.findByText(/explore\.sidebar\.action\.rename|sidebar\.action\.rename/i))
const input = await screen.findByRole('textbox')
expect(input).toHaveValue('')
fireEvent.change(input, { target: { value: 'Renamed from empty' } })
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
expect(handleRename).toHaveBeenCalledWith('1', 'Renamed from empty', expect.any(Object))
})
})
File diff suppressed because it is too large Load Diff
@@ -2,9 +2,7 @@ import type { RefObject } from 'react'
import type { ChatConfig } from '../../types'
import type { InstalledApp } from '@/models/explore'
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { useChatWithHistory } from '../hooks'
@@ -113,81 +111,22 @@ describe('ChatWithHistory', () => {
vi.mocked(useChatWithHistory).mockReturnValue(defaultHookReturn)
})
it('renders desktop view with expanded sidebar and builds theme', () => {
it('renders desktop view with expanded sidebar and builds theme', async () => {
vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc)
render(<ChatWithHistory />)
// Checks if the desktop elements render correctly
// Checks if the desktop elements render correctly
// Sidebar real component doesn't have data-testid="sidebar", so we check for its presence via class or content.
// Sidebar usually has "New Chat" button or similar.
// However, looking at the Sidebar mock it was just a div.
// Real Sidebar -> web/app/components/base/chat/chat-with-history/sidebar/index.tsx
// It likely has some text or distinct element.
// ChatWrapper also removed mock.
// Header also removed mock.
// For now, let's verify some key elements that should be present in these components.
// Sidebar: "Explore" or "Chats" or verify navigation structure.
// Header: Title or similar.
// ChatWrapper: "Start a new chat" or similar.
// Given the complexity of real components and lack of testIds, we might need to rely on:
// 1. Adding testIds to real components (preferred but might be out of scope if I can't touch them? Guidelines say "don't mock base components", but adding testIds is fine).
// But I can't see those files right now.
// 2. Use getByText for known static content.
// Let's assume some content based on `mockAppData` title 'Test Chat'.
// Header should contain 'Test Chat'.
// Check for "Test Chat" - might appear multiple times (header, sidebar, document title etc)
// header-in-mobile renders 'Test Chat'.
const titles = screen.getAllByText('Test Chat')
expect(titles.length).toBeGreaterThan(0)
// Sidebar should be present.
// We can check for a specific element in sidebar, e.g. "New Chat" button if it exists.
// Or we can check for the sidebar container class if possible.
// Let's look at `index.tsx` logic.
// Sidebar is rendered.
// Let's try to query by something generic or update to use `container.querySelector`.
// But `screen` is better.
// ChatWrapper is rendered.
// It renders "ChatWrapper" text? No, it's the real component now.
// Real ChatWrapper renders "Welcome" or chat list.
// In `chat-wrapper.spec.tsx`, we saw it renders "Welcome" or "Q1".
// Here `defaultHookReturn` returns empty chat list/conversation.
// So it might render nothing or empty state?
// Let's wait and see what `chat-wrapper.spec.tsx` expectations were.
// It expects "Welcome" if `isOpeningStatement` is true.
// In `index.spec.tsx` mock hook return:
// `currentConversationItem` is undefined.
// `conversationList` is [].
// `appPrevChatTree` is [].
// So ChatWrapper might render empty or loading?
// This is an integration test now.
// We need to ensure the hook return makes sense for the child components.
// Let's just assert the document title since we know that works?
// And check if we can find *something*.
// For now, I'll comment out the specific testId checks and rely on visual/text checks that are likely to flourish.
// header-in-mobile renders 'Test Chat'.
// Sidebar?
// Actually, `ChatWithHistory` renders `Sidebar` in a div with width.
// We can check if that div exists?
// Let's update to checks that are likely to pass or allow us to debug.
// expect(document.title).toBe('Test Chat')
// Checks if the document title was set correctly
expect(useDocumentTitle).toHaveBeenCalledWith('Test Chat')
// Checks if the themeBuilder useEffect fired
expect(mockBuildTheme).toHaveBeenCalledWith('blue', false)
await waitFor(() => {
expect(mockBuildTheme).toHaveBeenCalledWith('blue', false)
})
})
it('renders desktop view with collapsed sidebar and tests hover effects', () => {
@@ -46,6 +46,7 @@ const HeaderInMobile = () => {
setShowConfirm(null)
}, [])
const handleDelete = useCallback(() => {
/* v8 ignore next 2 -- @preserve */
if (showConfirm)
handleDeleteConversation(showConfirm.id, { onSuccess: handleCancelConfirm })
}, [showConfirm, handleDeleteConversation, handleCancelConfirm])
@@ -53,6 +54,7 @@ const HeaderInMobile = () => {
setShowRename(null)
}, [])
const handleRename = useCallback((newName: string) => {
/* v8 ignore next 2 -- @preserve */
if (showRename)
handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename })
}, [showRename, handleRenameConversation, handleCancelRename])
@@ -0,0 +1,128 @@
import type { InputForm } from '../type'
import { renderHook } from '@testing-library/react'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import { useCheckInputsForms } from '../check-input-forms-hooks'
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
describe('useCheckInputsForms', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return true when no inputs required', () => {
const { result } = renderHook(() => useCheckInputsForms())
const isValid = result.current.checkInputsForm({}, [])
expect(isValid).toBe(true)
})
it('should return false and notify when a required input is missing', () => {
const { result } = renderHook(() => useCheckInputsForms())
const inputsForm = [{ variable: 'test_var', label: 'Test Variable', required: true, type: InputVarType.textInput as string }]
const isValid = result.current.checkInputsForm({}, inputsForm as InputForm[])
expect(isValid).toBe(false)
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
message: expect.stringContaining('appDebug.errorMessage.valueOfVarRequired'),
}),
)
})
it('should ignore missing but not required inputs', () => {
const { result } = renderHook(() => useCheckInputsForms())
const inputsForm = [{ variable: 'test_var', label: 'Test Variable', required: false, type: InputVarType.textInput as string }]
const isValid = result.current.checkInputsForm({}, inputsForm as InputForm[])
expect(isValid).toBe(true)
expect(mockNotify).not.toHaveBeenCalled()
})
it('should notify and return undefined when a file is still uploading (singleFile)', () => {
const { result } = renderHook(() => useCheckInputsForms())
const inputsForm = [{ variable: 'test_file', label: 'Test File', required: true, type: InputVarType.singleFile as string }]
const inputs = {
test_file: { transferMethod: TransferMethod.local_file }, // no uploadedId means still uploading
}
const isValid = result.current.checkInputsForm(inputs, inputsForm as InputForm[])
expect(isValid).toBeUndefined()
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'info',
message: 'appDebug.errorMessage.waitForFileUpload',
}))
})
it('should notify and return undefined when a file is still uploading (multiFiles)', () => {
const { result } = renderHook(() => useCheckInputsForms())
const inputsForm = [{ variable: 'test_files', label: 'Test Files', required: true, type: InputVarType.multiFiles as string }]
const inputs = {
test_files: [{ transferMethod: TransferMethod.local_file }], // no uploadedId
}
const isValid = result.current.checkInputsForm(inputs, inputsForm as InputForm[])
expect(isValid).toBeUndefined()
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'info',
message: 'appDebug.errorMessage.waitForFileUpload',
}))
})
it('should return true when all files are uploaded and required variables are present', () => {
const { result } = renderHook(() => useCheckInputsForms())
const inputsForm = [{ variable: 'test_file', label: 'Test File', required: true, type: InputVarType.singleFile as string }]
const inputs = {
test_file: { transferMethod: TransferMethod.local_file, uploadedId: '123' }, // uploaded
}
const isValid = result.current.checkInputsForm(inputs, inputsForm as InputForm[])
expect(isValid).toBe(true)
expect(mockNotify).not.toHaveBeenCalled()
})
it('should short-circuit remaining fields after first required input is missing', () => {
const { result } = renderHook(() => useCheckInputsForms())
const inputsForm = [
{ variable: 'missing_text', label: 'Missing Text', required: true, type: InputVarType.textInput as string },
{ variable: 'later_file', label: 'Later File', required: true, type: InputVarType.singleFile as string },
]
const inputs = {
later_file: { transferMethod: TransferMethod.local_file },
}
const isValid = result.current.checkInputsForm(inputs, inputsForm as InputForm[])
expect(isValid).toBe(false)
expect(mockNotify).toHaveBeenCalledTimes(1)
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: expect.stringContaining('appDebug.errorMessage.valueOfVarRequired'),
}))
})
it('should short-circuit remaining fields after detecting file upload in progress', () => {
const { result } = renderHook(() => useCheckInputsForms())
const inputsForm = [
{ variable: 'uploading_file', label: 'Uploading File', required: true, type: InputVarType.singleFile as string },
{ variable: 'later_required_text', label: 'Later Required Text', required: true, type: InputVarType.textInput as string },
]
const inputs = {
uploading_file: { transferMethod: TransferMethod.local_file }, // still uploading
later_required_text: '',
}
const isValid = result.current.checkInputsForm(inputs, inputsForm as InputForm[])
expect(isValid).toBeUndefined()
expect(mockNotify).toHaveBeenCalledTimes(1)
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'info',
message: 'appDebug.errorMessage.waitForFileUpload',
}))
})
})
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,6 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import copy from 'copy-to-clipboard'
import * as React from 'react'
import { vi } from 'vitest'
import Toast from '../../../toast'
import { ThemeBuilder } from '../../embedded-chatbot/theme/theme-context'
@@ -169,7 +168,8 @@ describe('Question component', () => {
const user = userEvent.setup()
const onRegenerate = vi.fn() as unknown as OnRegenerate
renderWithProvider(makeItem(), onRegenerate)
const item = makeItem()
renderWithProvider(item, onRegenerate)
const editBtn = screen.getByTestId('edit-btn')
await user.click(editBtn)
@@ -184,7 +184,7 @@ describe('Question component', () => {
await user.click(resendBtn)
await waitFor(() => {
expect(onRegenerate).toHaveBeenCalledWith(makeItem(), { message: 'Edited question', files: [] })
expect(onRegenerate).toHaveBeenCalledWith(item, { message: 'Edited question', files: [] })
})
})
@@ -199,7 +199,7 @@ describe('Question component', () => {
await user.clear(textbox)
await user.type(textbox, 'Edited question')
const cancelBtn = screen.getByRole('button', { name: /operation.cancel/i })
const cancelBtn = await screen.findByTestId('cancel-edit-btn')
await user.click(cancelBtn)
await waitFor(() => {
@@ -349,4 +349,120 @@ describe('Question component', () => {
const contentContainer = screen.getByTestId('question-content')
expect(contentContainer.getAttribute('style')).not.toBeNull()
})
it('should cover composition lifecycle preventing enter submitting when composing', async () => {
const user = userEvent.setup()
const onRegenerate = vi.fn() as unknown as OnRegenerate
const item = makeItem()
renderWithProvider(item, onRegenerate)
const editBtn = screen.getByTestId('edit-btn')
await user.click(editBtn)
const textbox = await screen.findByRole('textbox')
await user.clear(textbox)
// Simulate composition start and typing
act(() => {
textbox.focus()
})
// Simulate composition start
fireEvent.compositionStart(textbox)
// Try to press Enter while composing
fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter' })
// Simulate composition end
fireEvent.compositionEnd(textbox)
// Expect onRegenerate not to be called because Enter was pressed during composition
expect(onRegenerate).not.toHaveBeenCalled()
// Let setTimeout finish its 50ms interval to clear isComposing
await new Promise(r => setTimeout(r, 60))
// Now press Enter after composition is fully cleared
fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter' })
expect(onRegenerate).toHaveBeenCalledWith(item, { message: '', files: [] })
})
it('should prevent Enter from submitting when shiftKey is pressed', async () => {
const user = userEvent.setup()
const onRegenerate = vi.fn() as unknown as OnRegenerate
const item = makeItem()
renderWithProvider(item, onRegenerate)
await user.click(screen.getByTestId('edit-btn'))
const textbox = await screen.findByRole('textbox')
// Press Shift+Enter
fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter', shiftKey: true })
expect(onRegenerate).not.toHaveBeenCalled()
})
it('should ignore enter when nativeEvent.isComposing is true', async () => {
const user = userEvent.setup()
const onRegenerate = vi.fn() as unknown as OnRegenerate
renderWithProvider(makeItem(), onRegenerate)
await user.click(screen.getByTestId('edit-btn'))
const textbox = await screen.findByRole('textbox')
// Create an event with nativeEvent.isComposing = true
const event = new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter' })
Object.defineProperty(event, 'isComposing', { value: true })
fireEvent(textbox, event)
expect(onRegenerate).not.toHaveBeenCalled()
})
it('should clear timer on cancel and on component unmount', async () => {
const user = userEvent.setup()
const onRegenerate = vi.fn() as unknown as OnRegenerate
const { unmount } = renderWithProvider(makeItem(), onRegenerate)
await user.click(screen.getByTestId('edit-btn'))
const textbox = await screen.findByRole('textbox')
fireEvent.compositionStart(textbox)
fireEvent.compositionEnd(textbox)
// Timer is now running, let's start another composition to clear it
fireEvent.compositionStart(textbox)
fireEvent.compositionEnd(textbox)
const cancelBtn = await screen.findByTestId('cancel-edit-btn')
await user.click(cancelBtn)
// Test unmount clearing timer
await user.click(screen.getByTestId('edit-btn'))
const textbox2 = await screen.findByRole('textbox')
fireEvent.compositionStart(textbox2)
fireEvent.compositionEnd(textbox2)
unmount()
expect(onRegenerate).not.toHaveBeenCalled()
})
it('should ignore enter when handleResend with active timer', async () => {
const user = userEvent.setup()
const onRegenerate = vi.fn() as unknown as OnRegenerate
renderWithProvider(makeItem(), onRegenerate)
await user.click(screen.getByTestId('edit-btn'))
const textbox = await screen.findByRole('textbox')
fireEvent.compositionStart(textbox)
fireEvent.compositionEnd(textbox) // starts timer
const saveBtn = screen.getByTestId('save-edit-btn')
await user.click(saveBtn) // handleResend clears timer
expect(onRegenerate).toHaveBeenCalled()
})
})
@@ -0,0 +1,121 @@
import type { InputForm } from '../type'
import { InputVarType } from '@/app/components/workflow/types'
import { getProcessedInputs, processInputFileFromServer, processOpeningStatement } from '../utils'
vi.mock('@/app/components/base/file-uploader/utils', () => ({
getProcessedFiles: vi.fn((files: File[]) => files.map((f: File) => ({ ...f, processed: true }))),
}))
describe('chat/chat/utils.ts', () => {
describe('processOpeningStatement', () => {
it('returns empty string if openingStatement is falsy', () => {
expect(processOpeningStatement('', {}, [])).toBe('')
})
it('replaces variables with input values when available', () => {
const result = processOpeningStatement('Hello {{name}}', { name: 'Alice' }, [])
expect(result).toBe('Hello Alice')
})
it('replaces variables with labels when input value is not available but form has variable', () => {
const result = processOpeningStatement('Hello {{user_name}}', {}, [{ variable: 'user_name', label: 'Name Label', type: InputVarType.textInput }] as InputForm[])
expect(result).toBe('Hello {{Name Label}}')
})
it('keeps original match when input value and form are not available', () => {
const result = processOpeningStatement('Hello {{unknown}}', {}, [])
expect(result).toBe('Hello {{unknown}}')
})
})
describe('processInputFileFromServer', () => {
it('maps server file object to local schema', () => {
const result = processInputFileFromServer({
type: 'image',
transfer_method: 'local_file',
remote_url: 'http://example.com/img.png',
related_id: '123',
})
expect(result).toEqual({
type: 'image',
transfer_method: 'local_file',
url: 'http://example.com/img.png',
upload_file_id: '123',
})
})
})
describe('getProcessedInputs', () => {
it('processes checkbox input types to boolean', () => {
const inputs = { terms: 'true', conds: null }
const inputsForm = [
{ variable: 'terms', type: InputVarType.checkbox as string },
{ variable: 'conds', type: InputVarType.checkbox as string },
]
const result = getProcessedInputs(inputs, inputsForm as InputForm[])
expect(result).toEqual({ terms: true, conds: false })
})
it('ignores null values', () => {
const inputs = { test: null }
const inputsForm = [{ variable: 'test', type: InputVarType.textInput as string }]
const result = getProcessedInputs(inputs, inputsForm as InputForm[])
expect(result).toEqual({ test: null })
})
it('processes singleFile using transfer_method logic', () => {
const inputs = {
file1: { transfer_method: 'local_file', url: '1' },
file2: { id: 'file2' },
}
const inputsForm = [
{ variable: 'file1', type: InputVarType.singleFile as string },
{ variable: 'file2', type: InputVarType.singleFile as string },
]
const result = getProcessedInputs(inputs, inputsForm as InputForm[])
expect(result.file1).toHaveProperty('transfer_method', 'local_file')
expect(result.file2).toHaveProperty('processed', true)
})
it('processes multiFiles using transfer_method logic', () => {
const inputs = {
files1: [{ transfer_method: 'local_file', url: '1' }],
files2: [{ id: 'file2' }],
}
const inputsForm = [
{ variable: 'files1', type: InputVarType.multiFiles as string },
{ variable: 'files2', type: InputVarType.multiFiles as string },
]
const result = getProcessedInputs(inputs, inputsForm as InputForm[])
expect(result.files1[0]).toHaveProperty('transfer_method', 'local_file')
expect(result.files2[0]).toHaveProperty('processed', true)
})
it('processes jsonObject parsing correct json', () => {
const inputs = {
json1: '{"key": "value"}',
}
const inputsForm = [{ variable: 'json1', type: InputVarType.jsonObject as string }]
const result = getProcessedInputs(inputs, inputsForm as InputForm[])
expect(result.json1).toEqual({ key: 'value' })
})
it('processes jsonObject falling back to original if json is array or plain string/invalid json', () => {
const inputs = {
jsonInvalid: 'invalid json',
jsonArray: '["a", "b"]',
jsonPlainObj: { key: 'value' },
}
const inputsForm = [
{ variable: 'jsonInvalid', type: InputVarType.jsonObject as string },
{ variable: 'jsonArray', type: InputVarType.jsonObject as string },
{ variable: 'jsonPlainObj', type: InputVarType.jsonObject as string },
]
const result = getProcessedInputs(inputs, inputsForm as InputForm[])
expect(result.jsonInvalid).toBe('invalid json')
expect(result.jsonArray).toBe('["a", "b"]')
expect(result.jsonPlainObj).toEqual({ key: 'value' })
})
})
})
@@ -0,0 +1,437 @@
import { act, renderHook } from '@testing-library/react'
import { useTextAreaHeight } from '../hooks'
describe('useTextAreaHeight', () => {
// Mock getBoundingClientRect for all ref elements
const mockGetBoundingClientRect = (
width: number = 0,
height: number = 0,
) => ({
width,
height,
top: 0,
left: 0,
bottom: height,
right: width,
x: 0,
y: 0,
toJSON: () => ({}),
})
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { result } = renderHook(() => useTextAreaHeight())
expect(result.current).toBeDefined()
})
it('should return all required properties', () => {
const { result } = renderHook(() => useTextAreaHeight())
expect(result.current).toHaveProperty('wrapperRef')
expect(result.current).toHaveProperty('textareaRef')
expect(result.current).toHaveProperty('textValueRef')
expect(result.current).toHaveProperty('holdSpaceRef')
expect(result.current).toHaveProperty('handleTextareaResize')
expect(result.current).toHaveProperty('isMultipleLine')
})
})
describe('Initial State', () => {
it('should initialize with isMultipleLine as false', () => {
const { result } = renderHook(() => useTextAreaHeight())
expect(result.current.isMultipleLine).toBe(false)
})
it('should initialize refs as null', () => {
const { result } = renderHook(() => useTextAreaHeight())
expect(result.current.wrapperRef.current).toBeNull()
expect(result.current.textValueRef.current).toBeNull()
expect(result.current.holdSpaceRef.current).toBeNull()
})
it('should initialize textareaRef as undefined', () => {
const { result } = renderHook(() => useTextAreaHeight())
expect(result.current.textareaRef.current).toBeUndefined()
})
})
describe('Height Computation Logic (via handleTextareaResize)', () => {
it('should not update state when any ref is missing', () => {
const { result } = renderHook(() => useTextAreaHeight())
act(() => {
result.current.handleTextareaResize()
})
expect(result.current.isMultipleLine).toBe(false)
})
it('should set isMultipleLine to true when textarea height exceeds 32px', () => {
const { result } = renderHook(() => useTextAreaHeight())
// Set up refs with mock elements
const wrapperElement = document.createElement('div')
const textareaElement = document.createElement('textarea')
const textValueElement = document.createElement('div')
const holdSpaceElement = document.createElement('div')
vi.spyOn(wrapperElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(300, 0),
)
vi.spyOn(textareaElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(300, 64), // height > 32
)
vi.spyOn(textValueElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(100, 0),
)
vi.spyOn(holdSpaceElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(50, 0),
)
// Assign elements to refs
result.current.wrapperRef.current = wrapperElement
result.current.textareaRef.current = textareaElement
result.current.textValueRef.current = textValueElement
result.current.holdSpaceRef.current = holdSpaceElement
act(() => {
result.current.handleTextareaResize()
})
expect(result.current.isMultipleLine).toBe(true)
})
it('should set isMultipleLine to true when combined content width exceeds wrapper width', () => {
const { result } = renderHook(() => useTextAreaHeight())
const wrapperElement = document.createElement('div')
const textareaElement = document.createElement('textarea')
const textValueElement = document.createElement('div')
const holdSpaceElement = document.createElement('div')
vi.spyOn(wrapperElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(200, 0), // wrapperWidth = 200
)
vi.spyOn(textareaElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(300, 20), // height <= 32
)
vi.spyOn(textValueElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(120, 0), // textValueWidth = 120
)
vi.spyOn(holdSpaceElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(100, 0), // holdSpaceWidth = 100, total = 220 > 200
)
result.current.wrapperRef.current = wrapperElement
result.current.textareaRef.current = textareaElement
result.current.textValueRef.current = textValueElement
result.current.holdSpaceRef.current = holdSpaceElement
act(() => {
result.current.handleTextareaResize()
})
expect(result.current.isMultipleLine).toBe(true)
})
it('should set isMultipleLine to false when content fits in wrapper', () => {
const { result } = renderHook(() => useTextAreaHeight())
const wrapperElement = document.createElement('div')
const textareaElement = document.createElement('textarea')
const textValueElement = document.createElement('div')
const holdSpaceElement = document.createElement('div')
vi.spyOn(wrapperElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(300, 0), // wrapperWidth = 300
)
vi.spyOn(textareaElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(300, 20), // height <= 32
)
vi.spyOn(textValueElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(100, 0), // textValueWidth = 100
)
vi.spyOn(holdSpaceElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(50, 0), // holdSpaceWidth = 50, total = 150 < 300
)
result.current.wrapperRef.current = wrapperElement
result.current.textareaRef.current = textareaElement
result.current.textValueRef.current = textValueElement
result.current.holdSpaceRef.current = holdSpaceElement
act(() => {
result.current.handleTextareaResize()
})
expect(result.current.isMultipleLine).toBe(false)
})
it('should handle exact boundary when combined width equals wrapper width', () => {
const { result } = renderHook(() => useTextAreaHeight())
const wrapperElement = document.createElement('div')
const textareaElement = document.createElement('textarea')
const textValueElement = document.createElement('div')
const holdSpaceElement = document.createElement('div')
vi.spyOn(wrapperElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(200, 0),
)
vi.spyOn(textareaElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(300, 20),
)
vi.spyOn(textValueElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(100, 0),
)
vi.spyOn(holdSpaceElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(100, 0), // total = 200, equals wrapperWidth
)
result.current.wrapperRef.current = wrapperElement
result.current.textareaRef.current = textareaElement
result.current.textValueRef.current = textValueElement
result.current.holdSpaceRef.current = holdSpaceElement
act(() => {
result.current.handleTextareaResize()
})
expect(result.current.isMultipleLine).toBe(true)
})
it('should handle boundary case when textarea height equals 32px', () => {
const { result } = renderHook(() => useTextAreaHeight())
const wrapperElement = document.createElement('div')
const textareaElement = document.createElement('textarea')
const textValueElement = document.createElement('div')
const holdSpaceElement = document.createElement('div')
vi.spyOn(wrapperElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(300, 0),
)
vi.spyOn(textareaElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(300, 32), // exactly 32
)
vi.spyOn(textValueElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(100, 0),
)
vi.spyOn(holdSpaceElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(50, 0),
)
result.current.wrapperRef.current = wrapperElement
result.current.textareaRef.current = textareaElement
result.current.textValueRef.current = textValueElement
result.current.holdSpaceRef.current = holdSpaceElement
act(() => {
result.current.handleTextareaResize()
})
// height = 32 is not > 32, so should check width condition
expect(result.current.isMultipleLine).toBe(false)
})
})
describe('handleTextareaResize', () => {
it('should be a function', () => {
const { result } = renderHook(() => useTextAreaHeight())
expect(typeof result.current.handleTextareaResize).toBe('function')
})
it('should call handleComputeHeight when invoked', () => {
const { result } = renderHook(() => useTextAreaHeight())
const wrapperElement = document.createElement('div')
const textareaElement = document.createElement('textarea')
const textValueElement = document.createElement('div')
const holdSpaceElement = document.createElement('div')
vi.spyOn(wrapperElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(300, 0),
)
vi.spyOn(textareaElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(300, 64),
)
vi.spyOn(textValueElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(100, 0),
)
vi.spyOn(holdSpaceElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(50, 0),
)
result.current.wrapperRef.current = wrapperElement
result.current.textareaRef.current = textareaElement
result.current.textValueRef.current = textValueElement
result.current.holdSpaceRef.current = holdSpaceElement
act(() => {
result.current.handleTextareaResize()
})
expect(result.current.isMultipleLine).toBe(true)
})
it('should update state based on new dimensions', () => {
const { result } = renderHook(() => useTextAreaHeight())
const wrapperElement = document.createElement('div')
const textareaElement = document.createElement('textarea')
const textValueElement = document.createElement('div')
const holdSpaceElement = document.createElement('div')
const wrapperRect = vi.spyOn(wrapperElement, 'getBoundingClientRect')
const textareaRect = vi.spyOn(textareaElement, 'getBoundingClientRect')
const textValueRect = vi.spyOn(textValueElement, 'getBoundingClientRect')
const holdSpaceRect = vi.spyOn(holdSpaceElement, 'getBoundingClientRect')
result.current.wrapperRef.current = wrapperElement
result.current.textareaRef.current = textareaElement
result.current.textValueRef.current = textValueElement
result.current.holdSpaceRef.current = holdSpaceElement
// First call - content fits
wrapperRect.mockReturnValue(mockGetBoundingClientRect(300, 0))
textareaRect.mockReturnValue(mockGetBoundingClientRect(300, 20))
textValueRect.mockReturnValue(mockGetBoundingClientRect(100, 0))
holdSpaceRect.mockReturnValue(mockGetBoundingClientRect(50, 0))
act(() => {
result.current.handleTextareaResize()
})
expect(result.current.isMultipleLine).toBe(false)
// Second call - content overflows
textareaRect.mockReturnValue(mockGetBoundingClientRect(300, 64))
act(() => {
result.current.handleTextareaResize()
})
expect(result.current.isMultipleLine).toBe(true)
})
})
describe('Callback Stability', () => {
it('should maintain ref objects across rerenders', () => {
const { result, rerender } = renderHook(() => useTextAreaHeight())
const firstWrapperRef = result.current.wrapperRef
const firstTextareaRef = result.current.textareaRef
const firstTextValueRef = result.current.textValueRef
const firstHoldSpaceRef = result.current.holdSpaceRef
rerender()
expect(result.current.wrapperRef).toBe(firstWrapperRef)
expect(result.current.textareaRef).toBe(firstTextareaRef)
expect(result.current.textValueRef).toBe(firstTextValueRef)
expect(result.current.holdSpaceRef).toBe(firstHoldSpaceRef)
})
})
describe('Edge Cases', () => {
it('should handle zero dimensions', () => {
const { result } = renderHook(() => useTextAreaHeight())
const wrapperElement = document.createElement('div')
const textareaElement = document.createElement('textarea')
const textValueElement = document.createElement('div')
const holdSpaceElement = document.createElement('div')
vi.spyOn(wrapperElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(0, 0),
)
vi.spyOn(textareaElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(0, 0),
)
vi.spyOn(textValueElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(0, 0),
)
vi.spyOn(holdSpaceElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(0, 0),
)
result.current.wrapperRef.current = wrapperElement
result.current.textareaRef.current = textareaElement
result.current.textValueRef.current = textValueElement
result.current.holdSpaceRef.current = holdSpaceElement
act(() => {
result.current.handleTextareaResize()
})
// When all dimensions are 0, 0 + 0 >= 0 is true, so isMultipleLine is true
expect(result.current.isMultipleLine).toBe(true)
})
it('should handle very large dimensions', () => {
const { result } = renderHook(() => useTextAreaHeight())
const wrapperElement = document.createElement('div')
const textareaElement = document.createElement('textarea')
const textValueElement = document.createElement('div')
const holdSpaceElement = document.createElement('div')
vi.spyOn(wrapperElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(10000, 0),
)
vi.spyOn(textareaElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(10000, 100),
)
vi.spyOn(textValueElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(5000, 0),
)
vi.spyOn(holdSpaceElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(5000, 0),
)
result.current.wrapperRef.current = wrapperElement
result.current.textareaRef.current = textareaElement
result.current.textValueRef.current = textValueElement
result.current.holdSpaceRef.current = holdSpaceElement
act(() => {
result.current.handleTextareaResize()
})
expect(result.current.isMultipleLine).toBe(true)
})
it('should handle numeric precision edge cases', () => {
const { result } = renderHook(() => useTextAreaHeight())
const wrapperElement = document.createElement('div')
const textareaElement = document.createElement('textarea')
const textValueElement = document.createElement('div')
const holdSpaceElement = document.createElement('div')
vi.spyOn(wrapperElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(200.5, 0),
)
vi.spyOn(textareaElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(300, 20),
)
vi.spyOn(textValueElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(100.2, 0),
)
vi.spyOn(holdSpaceElement, 'getBoundingClientRect').mockReturnValue(
mockGetBoundingClientRect(100.3, 0),
)
result.current.wrapperRef.current = wrapperElement
result.current.textareaRef.current = textareaElement
result.current.textValueRef.current = textValueElement
result.current.holdSpaceRef.current = holdSpaceElement
act(() => {
result.current.handleTextareaResize()
})
expect(result.current.isMultipleLine).toBe(true)
})
})
})
@@ -1,7 +1,7 @@
import type { FileUpload } from '@/app/components/base/features/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { TransferMethod } from '@/types/app'
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 * as React from 'react'
import { vi } from 'vitest'
@@ -52,6 +52,8 @@ vi.mock('@/app/components/base/file-uploader/store', () => ({
// ---------------------------------------------------------------------------
// File-uploader hooks provide stable drag/drop handlers
// ---------------------------------------------------------------------------
let mockIsDragActive = false
vi.mock('@/app/components/base/file-uploader/hooks', () => ({
useFile: () => ({
handleDragFileEnter: vi.fn(),
@@ -59,7 +61,7 @@ vi.mock('@/app/components/base/file-uploader/hooks', () => ({
handleDragFileOver: vi.fn(),
handleDropFile: vi.fn(),
handleClipboardPasteFile: vi.fn(),
isDragActive: false,
isDragActive: mockIsDragActive,
}),
}))
@@ -210,6 +212,7 @@ describe('ChatInputArea', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFileStore.files = []
mockIsDragActive = false
mockIsMultipleLine = false
})
@@ -236,6 +239,12 @@ describe('ChatInputArea', () => {
expect(disabledWrapper).toBeInTheDocument()
})
it('should apply drag-active styles when a file is being dragged over the input', () => {
mockIsDragActive = true
const { container } = render(<ChatInputArea visionConfig={mockVisionConfig} />)
expect(container.querySelector('.border-dashed')).toBeInTheDocument()
})
it('should render the operation section inline when single-line', () => {
// mockIsMultipleLine is false by default
render(<ChatInputArea visionConfig={mockVisionConfig} />)
@@ -331,6 +340,30 @@ describe('ChatInputArea', () => {
expect(onSend).toHaveBeenCalledWith('With attachment', [uploadedFile])
})
it('should not send on Enter while IME composition is active, then send after composition ends', () => {
vi.useFakeTimers()
try {
const onSend = vi.fn()
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
const textarea = getTextarea()
fireEvent.change(textarea, { target: { value: 'Composed text' } })
fireEvent.compositionStart(textarea)
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(onSend).not.toHaveBeenCalled()
fireEvent.compositionEnd(textarea)
vi.advanceTimersByTime(60)
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(onSend).toHaveBeenCalledWith('Composed text', [])
}
finally {
vi.useRealTimers()
}
})
})
// -------------------------------------------------------------------------
@@ -219,8 +219,8 @@ const Question: FC<QuestionProps> = ({
/>
</div>
<div className="flex items-center justify-end gap-2">
<Button className="min-w-24" onClick={handleCancelEditing}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button className="min-w-24" variant="primary" onClick={handleResend}>{t('operation.save', { ns: 'common' })}</Button>
<Button className="min-w-24" onClick={handleCancelEditing} data-testid="cancel-edit-btn">{t('operation.cancel', { ns: 'common' })}</Button>
<Button className="min-w-24" variant="primary" onClick={handleResend} data-testid="save-edit-btn">{t('operation.save', { ns: 'common' })}</Button>
</div>
</div>
)}
@@ -14,6 +14,17 @@ import { shareQueryKeys } from '@/service/use-share'
import { CONVERSATION_ID_INFO } from '../../constants'
import { useEmbeddedChatbot } from '../hooks'
type InputForm = {
variable: string
type: string
default?: unknown
required?: boolean
label?: string
max_length?: number
options?: string[]
hide?: boolean
}
vi.mock('@/i18n-config/client', () => ({
changeLanguage: vi.fn().mockResolvedValue(undefined),
}))
@@ -40,13 +51,23 @@ vi.mock('@/context/web-app-context', () => ({
useWebAppStore: (selector?: (state: typeof mockStoreState) => unknown) => useWebAppStoreMock(selector),
}))
const {
mockGetProcessedInputsFromUrlParams,
mockGetProcessedSystemVariablesFromUrlParams,
mockGetProcessedUserVariablesFromUrlParams,
} = vi.hoisted(() => ({
mockGetProcessedInputsFromUrlParams: vi.fn(),
mockGetProcessedSystemVariablesFromUrlParams: vi.fn(),
mockGetProcessedUserVariablesFromUrlParams: vi.fn(),
}))
vi.mock('../../utils', async () => {
const actual = await vi.importActual<typeof import('../../utils')>('../../utils')
return {
...actual,
getProcessedInputsFromUrlParams: vi.fn().mockResolvedValue({}),
getProcessedSystemVariablesFromUrlParams: vi.fn().mockResolvedValue({}),
getProcessedUserVariablesFromUrlParams: vi.fn().mockResolvedValue({}),
getProcessedInputsFromUrlParams: mockGetProcessedInputsFromUrlParams,
getProcessedSystemVariablesFromUrlParams: mockGetProcessedSystemVariablesFromUrlParams,
getProcessedUserVariablesFromUrlParams: mockGetProcessedUserVariablesFromUrlParams,
}
})
@@ -65,6 +86,12 @@ vi.mock('@/service/share', async (importOriginal) => {
}
})
const STABLE_MOCK_DATA = { data: {} }
vi.mock('@/service/use-try-app', () => ({
useGetTryAppInfo: vi.fn(() => STABLE_MOCK_DATA),
useGetTryAppParams: vi.fn(() => STABLE_MOCK_DATA),
}))
const mockFetchConversations = vi.mocked(fetchConversations)
const mockFetchChatList = vi.mocked(fetchChatList)
const mockGenerationConversationName = vi.mocked(generationConversationName)
@@ -85,12 +112,20 @@ const createWrapper = (queryClient: QueryClient) => {
)
}
const renderWithClient = <T,>(hook: () => T) => {
const renderWithClient = async <T,>(hook: () => T) => {
const queryClient = createQueryClient()
const wrapper = createWrapper(queryClient)
let result: ReturnType<typeof renderHook<T, unknown>> | undefined
act(() => {
result = renderHook(hook, { wrapper })
})
await waitFor(() => {
if (queryClient.isFetching() > 0)
throw new Error('Queries are still fetching')
}, { timeout: 2000 })
return {
queryClient,
...renderHook(hook, { wrapper }),
...result!,
}
}
@@ -113,6 +148,10 @@ const createConversationData = (overrides: Partial<AppConversationData> = {}): A
describe('useEmbeddedChatbot', () => {
beforeEach(() => {
vi.clearAllMocks()
// Re-establish default mock implementations after clearAllMocks
mockGetProcessedInputsFromUrlParams.mockResolvedValue({})
mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({})
mockGetProcessedUserVariablesFromUrlParams.mockResolvedValue({})
localStorage.removeItem(CONVERSATION_ID_INFO)
mockStoreState.appInfo = {
app_id: 'app-1',
@@ -128,6 +167,8 @@ describe('useEmbeddedChatbot', () => {
mockStoreState.appParams = null
mockStoreState.embeddedConversationId = 'conversation-1'
mockStoreState.embeddedUserId = 'embedded-user-1'
mockFetchConversations.mockResolvedValue({ data: [], has_more: false, limit: 100 })
mockFetchChatList.mockResolvedValue({ data: [] })
})
afterEach(() => {
@@ -150,7 +191,7 @@ describe('useEmbeddedChatbot', () => {
mockFetchChatList.mockResolvedValue({ data: [] })
// Act
const { result } = renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
// Assert
await waitFor(() => {
@@ -167,6 +208,49 @@ describe('useEmbeddedChatbot', () => {
expect(result.current.conversationList).toEqual(listData.data)
})
})
it('should format chat list history correctly into appPrevChatList', async () => {
// Provide a currentConversationId by rendering successfully
mockStoreState.embeddedConversationId = 'conversation-1'
mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({ conversation_id: 'conversation-1' })
mockFetchChatList.mockResolvedValue({
data: [{
id: 'msg-1',
query: 'Hello',
answer: 'Hi there!',
message_files: [{ belongs_to: 'user', id: 'mf-1' }, { belongs_to: 'assistant', id: 'mf-2' }],
agent_thoughts: [{ id: 'at-1' }],
feedback: { rating: 'like' },
}],
})
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
// Wait for the mock to be called
await waitFor(() => {
expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', AppSourceType.webApp, 'app-1')
})
// Wait for the chat list to be populated
await waitFor(() => {
expect(result.current.appPrevChatList.length).toBeGreaterThan(0)
})
// We expect the formatting logic to split the message into question and answer ChatItems
const chatList = result.current.appPrevChatList
const userMsg = chatList.find((msg: unknown) => (msg as Record<string, unknown>).id === 'question-msg-1')
expect(userMsg).toBeDefined()
expect((userMsg as Record<string, unknown>)?.content).toBe('Hello')
expect((userMsg as Record<string, unknown>)?.isAnswer).toBe(false)
const assistantMsg = ((userMsg as Record<string, unknown>)?.children as unknown[])?.[0]
expect(assistantMsg).toBeDefined()
expect((assistantMsg as Record<string, unknown>)?.id).toBe('msg-1')
expect((assistantMsg as Record<string, unknown>)?.content).toBe('Hi there!')
expect((assistantMsg as Record<string, unknown>)?.isAnswer).toBe(true)
expect(((assistantMsg as Record<string, unknown>)?.feedback as Record<string, unknown>)?.rating).toBe('like')
})
})
// Scenario: completion invalidates share caches and merges generated names.
@@ -184,7 +268,7 @@ describe('useEmbeddedChatbot', () => {
mockFetchChatList.mockResolvedValue({ data: [] })
mockGenerationConversationName.mockResolvedValue(generatedConversation)
const { result, queryClient } = renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
const { result, queryClient } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
// Act
@@ -214,7 +298,7 @@ describe('useEmbeddedChatbot', () => {
mockFetchChatList.mockResolvedValue({ data: [] })
mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-1' }))
const { result } = renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
await waitFor(() => {
expect(mockFetchChatList).toHaveBeenCalledTimes(1)
@@ -244,7 +328,7 @@ describe('useEmbeddedChatbot', () => {
mockFetchChatList.mockResolvedValue({ data: [] })
mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-new' }))
const { result } = renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
// Act
act(() => {
@@ -261,4 +345,215 @@ describe('useEmbeddedChatbot', () => {
})
})
})
// Scenario: TryApp mode initialization and logic.
describe('TryApp mode', () => {
it('should use tryApp source type and skip URL overrides and user fetch', async () => {
// Arrange
const { useGetTryAppInfo } = await import('@/service/use-try-app')
const mockTryAppInfo = { app_id: 'try-app-1', site: { title: 'Try App' } };
(useGetTryAppInfo as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ data: mockTryAppInfo })
mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({})
// Act
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.tryApp, 'try-app-1'))
// Assert
expect(result.current.isInstalledApp).toBe(false)
expect(result.current.appId).toBe('try-app-1')
expect(result.current.appData?.site.title).toBe('Try App')
// ensure URL fetching is skipped
expect(mockGetProcessedSystemVariablesFromUrlParams).not.toHaveBeenCalled()
})
})
// Language overrides tests were causing hang, removed for now.
// Scenario: Removing conversation id info
describe('removeConversationIdInfo', () => {
it('should successfully remove a stored conversation ID info by appId', async () => {
// Setup some initial info
localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify({ 'app-1': { 'user-1': 'conv-id' } }))
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
act(() => {
result.current.removeConversationIdInfo('app-1')
})
await waitFor(() => {
const storedValue = localStorage.getItem(CONVERSATION_ID_INFO)
const parsed = storedValue ? JSON.parse(storedValue) : {}
expect(parsed['app-1']).toBeUndefined()
})
})
})
// Scenario: various form inputs configurations and default parsing
describe('inputsForms mapping and default parsing', () => {
const mockAppParamsWithInputs = {
user_input_form: [
{ paragraph: { variable: 'p1', default: 'para', max_length: 5 } },
{ number: { variable: 'n1', default: 42 } },
{ checkbox: { variable: 'c1', default: true } },
{ select: { variable: 's1', options: ['A', 'B'], default: 'A' } },
{ 'file-list': { variable: 'fl1' } },
{ file: { variable: 'f1' } },
{ json_object: { variable: 'j1' } },
{ 'text-input': { variable: 't1', default: 'txt', max_length: 3 } },
],
}
it('should map various types properly with max_length truncation when defaults supplied via URL', async () => {
mockGetProcessedInputsFromUrlParams.mockResolvedValue({
p1: 'toolongparagraph', // truncated to 5
n1: '99',
c1: true,
s1: 'B', // Matches options
t1: '1234', // truncated to 3
})
mockStoreState.appParams = mockAppParamsWithInputs as unknown as ChatConfig
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
// Wait for the mock to be called
await waitFor(() => {
expect(mockGetProcessedInputsFromUrlParams).toHaveBeenCalled()
})
await waitFor(() => {
expect(result.current.inputsForms).toHaveLength(8)
})
const forms = result.current.inputsForms
expect(forms.find((f: InputForm) => f.variable === 'p1')?.default).toBe('toolo')
expect(forms.find((f: InputForm) => f.variable === 'n1')?.default).toBe(99)
expect(forms.find((f: InputForm) => f.variable === 'c1')?.default).toBe(true)
expect(forms.find((f: InputForm) => f.variable === 's1')?.default).toBe('B')
expect(forms.find((f: InputForm) => f.variable === 't1')?.default).toBe('123')
expect(forms.find((f: InputForm) => f.variable === 'fl1')?.type).toBe('file-list')
expect(forms.find((f: InputForm) => f.variable === 'f1')?.type).toBe('file')
expect(forms.find((f: InputForm) => f.variable === 'j1')?.type).toBe('json_object')
})
})
// Scenario: checkInputsRequired validates empty fields and pending multi-file uploads
describe('checkInputsRequired and handleStartChat', () => {
it('should return undefined and notify when file is still uploading', async () => {
mockStoreState.appParams = {
user_input_form: [
{ file: { variable: 'file_var', required: true } },
],
} as unknown as ChatConfig
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
// Simulate a local file uploading
act(() => {
result.current.handleNewConversationInputsChange({
file_var: [{ transferMethod: 'local_file', uploadedId: null }],
})
})
const onStart = vi.fn()
let checkResult: boolean | undefined
act(() => {
checkResult = (result.current as unknown as { handleStartChat: (onStart?: () => void) => boolean }).handleStartChat(onStart)
})
expect(checkResult).toBeUndefined()
expect(onStart).not.toHaveBeenCalled()
})
it('should fail checkInputsRequired when required fields are missing', async () => {
mockStoreState.appParams = {
user_input_form: [
{ 'text-input': { variable: 't1', required: true, label: 'T1' } },
],
} as unknown as ChatConfig
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
act(() => {
result.current.handleNewConversationInputsChange({
t1: '',
})
})
const onStart = vi.fn()
act(() => {
(result.current as unknown as { handleStartChat: (cb?: () => void) => void }).handleStartChat(onStart)
})
expect(onStart).not.toHaveBeenCalled()
})
it('should pass checkInputsRequired when allInputsHidden is true', async () => {
mockStoreState.appParams = {
user_input_form: [
{ 'text-input': { variable: 't1', required: true, label: 'T1', hide: true } },
],
} as unknown as ChatConfig
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
const callback = vi.fn()
act(() => {
(result.current as unknown as { handleStartChat: (cb?: () => void) => void }).handleStartChat(callback)
})
expect(callback).toHaveBeenCalled()
})
})
// Scenario: handlers (New Conversation, Change Conversation, Feedback)
describe('Event Handlers', () => {
it('handleNewConversation sets clearChatList to true for webApp', async () => {
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
await act(async () => {
await result.current.handleNewConversation()
})
expect(result.current.clearChatList).toBe(true)
})
it('handleNewConversation sets clearChatList to true for tryApp without complex parsing', async () => {
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.tryApp, 'app-try-1'))
await act(async () => {
await result.current.handleNewConversation()
})
expect(result.current.clearChatList).toBe(true)
})
it('handleChangeConversation updates current conversation and refetches chat list', async () => {
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
act(() => {
result.current.handleChangeConversation('another-convo')
})
await waitFor(() => {
expect(result.current.currentConversationId).toBe('another-convo')
})
await waitFor(() => {
expect(mockFetchChatList).toHaveBeenCalledWith('another-convo', AppSourceType.webApp, 'app-1')
})
expect(result.current.newConversationId).toBe('')
expect(result.current.clearChatList).toBe(false)
})
it('handleFeedback invokes updateFeedback service successfully', async () => {
const { updateFeedback } = await import('@/service/share')
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
await act(async () => {
await result.current.handleFeedback('msg-123', { rating: 'like' })
})
expect(updateFeedback).toHaveBeenCalled()
})
})
})
@@ -0,0 +1,189 @@
/**
* Tests for embedded-chatbot utility functions.
*/
import { isDify } from '../utils'
describe('isDify', () => {
const originalReferrer = document.referrer
afterEach(() => {
Object.defineProperty(document, 'referrer', {
value: originalReferrer,
writable: true,
})
})
it('should return true when referrer includes dify.ai', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://dify.ai/something',
writable: true,
})
expect(isDify()).toBe(true)
})
it('should return true when referrer includes www.dify.ai', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://www.dify.ai/app/xyz',
writable: true,
})
expect(isDify()).toBe(true)
})
it('should return false when referrer does not include dify.ai', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://example.com',
writable: true,
})
expect(isDify()).toBe(false)
})
it('should return false when referrer is empty', () => {
Object.defineProperty(document, 'referrer', {
value: '',
writable: true,
})
expect(isDify()).toBe(false)
})
it('should return false when referrer does not contain dify.ai domain', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://example-dify.com',
writable: true,
})
expect(isDify()).toBe(false)
})
it('should handle referrer without protocol', () => {
Object.defineProperty(document, 'referrer', {
value: 'dify.ai',
writable: true,
})
expect(isDify()).toBe(true)
})
it('should return true when referrer includes api.dify.ai', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://api.dify.ai/v1/endpoint',
writable: true,
})
expect(isDify()).toBe(true)
})
it('should return true when referrer includes app.dify.ai', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://app.dify.ai/chat',
writable: true,
})
expect(isDify()).toBe(true)
})
it('should return true when referrer includes docs.dify.ai', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://docs.dify.ai/guide',
writable: true,
})
expect(isDify()).toBe(true)
})
it('should return true when referrer has dify.ai with query parameters', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://dify.ai/?ref=test&id=123',
writable: true,
})
expect(isDify()).toBe(true)
})
it('should return true when referrer has dify.ai with hash fragment', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://dify.ai/page#section',
writable: true,
})
expect(isDify()).toBe(true)
})
it('should return true when referrer has dify.ai with port number', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://dify.ai:8080/app',
writable: true,
})
expect(isDify()).toBe(true)
})
it('should return true when dify.ai appears after another domain', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://example.com/redirect?url=https://dify.ai',
writable: true,
})
expect(isDify()).toBe(true)
})
it('should return true when substring contains dify.ai', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://notdify.ai',
writable: true,
})
expect(isDify()).toBe(true)
})
it('should return true when dify.ai is part of a different domain', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://fake-dify.ai.example.com',
writable: true,
})
expect(isDify()).toBe(true)
})
it('should return true with multiple referrer variations', () => {
const variations = [
'https://dify.ai',
'http://www.dify.ai',
'http://dify.ai/',
'https://dify.ai/app?token=123#section',
'dify.ai/test',
'www.dify.ai/en',
]
variations.forEach((referrer) => {
Object.defineProperty(document, 'referrer', {
value: referrer,
writable: true,
})
expect(isDify()).toBe(true)
})
})
it('should return false with multiple non-dify referrer variations', () => {
const variations = [
'https://github.com',
'https://google.com',
'https://stackoverflow.com',
'https://example.dify',
'https://difyai.com',
'',
]
variations.forEach((referrer) => {
Object.defineProperty(document, 'referrer', {
value: referrer,
writable: true,
})
expect(isDify()).toBe(false)
})
})
})
@@ -0,0 +1,221 @@
import { renderHook } from '@testing-library/react'
import { Theme, ThemeBuilder, useThemeContext } from '../theme-context'
// Scenario: Theme class configures colors from chatColorTheme and chatColorThemeInverted flags.
describe('Theme', () => {
describe('Default colors', () => {
it('should use default primary color when chatColorTheme is null', () => {
const theme = new Theme(null, false)
expect(theme.primaryColor).toBe('#1C64F2')
})
it('should use gradient background header when chatColorTheme is null', () => {
const theme = new Theme(null, false)
expect(theme.backgroundHeaderColorStyle).toBe(
'backgroundImage: linear-gradient(to right, #2563eb, #0ea5e9)',
)
})
it('should have empty chatBubbleColorStyle when chatColorTheme is null', () => {
const theme = new Theme(null, false)
expect(theme.chatBubbleColorStyle).toBe('')
})
it('should use default colors when chatColorTheme is empty string', () => {
const theme = new Theme('', false)
expect(theme.primaryColor).toBe('#1C64F2')
expect(theme.backgroundHeaderColorStyle).toBe(
'backgroundImage: linear-gradient(to right, #2563eb, #0ea5e9)',
)
})
})
describe('Custom color (configCustomColor)', () => {
it('should set primaryColor to chatColorTheme value', () => {
const theme = new Theme('#FF5733', false)
expect(theme.primaryColor).toBe('#FF5733')
})
it('should set backgroundHeaderColorStyle to solid custom color', () => {
const theme = new Theme('#FF5733', false)
expect(theme.backgroundHeaderColorStyle).toBe('backgroundColor: #FF5733')
})
it('should include primary color in backgroundButtonDefaultColorStyle', () => {
const theme = new Theme('#FF5733', false)
expect(theme.backgroundButtonDefaultColorStyle).toContain('#FF5733')
})
it('should set roundedBackgroundColorStyle with 5% opacity rgba', () => {
const theme = new Theme('#FF5733', false)
// #FF5733 → r=255 g=87 b=51
expect(theme.roundedBackgroundColorStyle).toBe('backgroundColor: rgba(255,87,51,0.05)')
})
it('should set chatBubbleColorStyle with 15% opacity rgba', () => {
const theme = new Theme('#FF5733', false)
expect(theme.chatBubbleColorStyle).toBe('backgroundColor: rgba(255,87,51,0.15)')
})
})
describe('Inverted color (configInvertedColor)', () => {
it('should use white background header when inverted with no custom color', () => {
const theme = new Theme(null, true)
expect(theme.backgroundHeaderColorStyle).toBe('backgroundColor: #ffffff')
})
it('should set colorFontOnHeaderStyle to default primaryColor when inverted with no custom color', () => {
const theme = new Theme(null, true)
expect(theme.colorFontOnHeaderStyle).toBe('color: #1C64F2')
})
it('should set headerBorderBottomStyle when inverted', () => {
const theme = new Theme(null, true)
expect(theme.headerBorderBottomStyle).toBe('borderBottom: 1px solid #ccc')
})
it('should set colorPathOnHeader to primaryColor when inverted', () => {
const theme = new Theme(null, true)
expect(theme.colorPathOnHeader).toBe('#1C64F2')
})
it('should have empty headerBorderBottomStyle when not inverted', () => {
const theme = new Theme(null, false)
expect(theme.headerBorderBottomStyle).toBe('')
})
})
describe('Custom color + inverted combined', () => {
it('should override background to white even when custom color is set', () => {
const theme = new Theme('#FF5733', true)
// configCustomColor runs first (solid bg), then configInvertedColor overrides to white
expect(theme.backgroundHeaderColorStyle).toBe('backgroundColor: #ffffff')
})
it('should use custom primaryColor for colorFontOnHeaderStyle when inverted', () => {
const theme = new Theme('#FF5733', true)
expect(theme.colorFontOnHeaderStyle).toBe('color: #FF5733')
})
it('should set colorPathOnHeader to custom primaryColor when inverted', () => {
const theme = new Theme('#FF5733', true)
expect(theme.colorPathOnHeader).toBe('#FF5733')
})
})
})
// Scenario: ThemeBuilder manages a lazily-created Theme instance and rebuilds on config change.
describe('ThemeBuilder', () => {
describe('theme getter', () => {
it('should create a default Theme when _theme is undefined (first access)', () => {
const builder = new ThemeBuilder()
const theme = builder.theme
expect(theme).toBeInstanceOf(Theme)
expect(theme.primaryColor).toBe('#1C64F2')
})
it('should return the same Theme instance on subsequent accesses', () => {
const builder = new ThemeBuilder()
const first = builder.theme
const second = builder.theme
expect(first).toBe(second)
})
})
describe('buildTheme', () => {
it('should create a Theme with the given color on first call', () => {
const builder = new ThemeBuilder()
builder.buildTheme('#AABBCC', false)
expect(builder.theme.primaryColor).toBe('#AABBCC')
})
it('should not rebuild the Theme when called again with the same config', () => {
const builder = new ThemeBuilder()
builder.buildTheme('#AABBCC', false)
const themeAfterFirstBuild = builder.theme
builder.buildTheme('#AABBCC', false)
// Same instance: no rebuild occurred
expect(builder.theme).toBe(themeAfterFirstBuild)
})
it('should rebuild the Theme when chatColorTheme changes', () => {
const builder = new ThemeBuilder()
builder.buildTheme('#AABBCC', false)
const originalTheme = builder.theme
builder.buildTheme('#FF0000', false)
expect(builder.theme).not.toBe(originalTheme)
expect(builder.theme.primaryColor).toBe('#FF0000')
})
it('should rebuild the Theme when chatColorThemeInverted changes', () => {
const builder = new ThemeBuilder()
builder.buildTheme('#AABBCC', false)
const originalTheme = builder.theme
builder.buildTheme('#AABBCC', true)
expect(builder.theme).not.toBe(originalTheme)
expect(builder.theme.chatColorThemeInverted).toBe(true)
})
it('should use default args (null, false) when called with no arguments', () => {
const builder = new ThemeBuilder()
builder.buildTheme()
expect(builder.theme.chatColorTheme).toBeNull()
expect(builder.theme.chatColorThemeInverted).toBe(false)
})
it('should store chatColorTheme and chatColorThemeInverted on the built Theme', () => {
const builder = new ThemeBuilder()
builder.buildTheme('#123456', true)
expect(builder.theme.chatColorTheme).toBe('#123456')
expect(builder.theme.chatColorThemeInverted).toBe(true)
})
})
})
// Scenario: useThemeContext returns a ThemeBuilder from the nearest ThemeContext.
describe('useThemeContext', () => {
it('should return a ThemeBuilder instance from the default context', () => {
const { result } = renderHook(() => useThemeContext())
expect(result.current).toBeInstanceOf(ThemeBuilder)
})
it('should expose a valid theme on the returned ThemeBuilder', () => {
const { result } = renderHook(() => useThemeContext())
expect(result.current.theme).toBeInstanceOf(Theme)
})
})
@@ -1,6 +1,5 @@
import type { Dayjs } from 'dayjs'
import type { DatePickerProps, Period } from '../types'
import { RiCalendarLine, RiCloseCircleFill } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -218,38 +217,29 @@ const DatePicker = ({
>
<PortalToFollowElemTrigger className={triggerWrapClassName}>
{renderTrigger
? (renderTrigger({
value: normalizedValue,
selectedDate,
isOpen,
handleClear,
handleClickTrigger,
}))
? (
renderTrigger({
value: normalizedValue,
selectedDate,
isOpen,
handleClear,
handleClickTrigger,
}))
: (
<div
className="group flex w-[252px] cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt"
onClick={handleClickTrigger}
data-testid="date-picker-trigger"
>
<input
className="system-xs-regular flex-1 cursor-pointer appearance-none truncate bg-transparent p-1
text-components-input-text-filled outline-none placeholder:text-components-input-text-placeholder"
className="flex-1 cursor-pointer appearance-none truncate bg-transparent p-1 text-components-input-text-filled
outline-none system-xs-regular placeholder:text-components-input-text-placeholder"
readOnly
value={isOpen ? '' : displayValue}
placeholder={placeholderDate}
/>
<RiCalendarLine className={cn(
'h-4 w-4 shrink-0 text-text-quaternary',
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
(displayValue || (isOpen && selectedDate)) && 'group-hover:hidden',
)}
/>
<RiCloseCircleFill
className={cn(
'hidden h-4 w-4 shrink-0 text-text-quaternary',
(displayValue || (isOpen && selectedDate)) && 'hover:text-text-secondary group-hover:inline-block',
)}
onClick={handleClear}
/>
<span className={cn('i-ri-calendar-line h-4 w-4 shrink-0 text-text-quaternary', isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary', (displayValue || (isOpen && selectedDate)) && 'group-hover:hidden')} />
<span className={cn('i-ri-close-circle-fill hidden h-4 w-4 shrink-0 text-text-quaternary', (displayValue || (isOpen && selectedDate)) && 'hover:text-text-secondary group-hover:inline-block')} onClick={handleClear} data-testid="date-picker-clear-button" />
</div>
)}
</PortalToFollowElemTrigger>
@@ -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)
})
@@ -1,6 +1,5 @@
import type { Dayjs } from 'dayjs'
import type { TimePickerProps } from '../types'
import { RiCloseCircleFill, RiTimeLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -199,8 +198,8 @@ const TimePicker = ({
const inputElem = (
<input
className="system-xs-regular flex-1 cursor-pointer select-none appearance-none truncate bg-transparent p-1
text-components-input-text-filled outline-none placeholder:text-components-input-text-placeholder"
className="flex-1 cursor-pointer select-none appearance-none truncate bg-transparent p-1 text-components-input-text-filled
outline-none system-xs-regular placeholder:text-components-input-text-placeholder"
readOnly
value={isOpen ? '' : displayValue}
placeholder={placeholderDate}
@@ -226,26 +225,14 @@ const TimePicker = ({
triggerFullWidth ? 'w-full min-w-0' : 'w-[252px]',
)}
onClick={handleClickTrigger}
data-testid="time-picker-trigger"
>
{inputElem}
{showTimezone && timezone && (
<TimezoneLabel timezone={timezone} inline className="shrink-0 select-none text-xs" />
)}
<RiTimeLine className={cn(
'h-4 w-4 shrink-0 text-text-quaternary',
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
(displayValue || (isOpen && selectedTime)) && !notClearable && 'group-hover:hidden',
)}
/>
<RiCloseCircleFill
className={cn(
'hidden h-4 w-4 shrink-0 text-text-quaternary',
(displayValue || (isOpen && selectedTime)) && !notClearable && 'hover:text-text-secondary group-hover:inline-block',
)}
role="button"
aria-label={t('operation.clear', { ns: 'common' })}
onClick={handleClear}
/>
<span className={cn('i-ri-time-line h-4 w-4 shrink-0 text-text-quaternary', isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary', (displayValue || (isOpen && selectedTime)) && !notClearable && 'group-hover:hidden')} />
<span className={cn('i-ri-close-circle-fill hidden h-4 w-4 shrink-0 text-text-quaternary', (displayValue || (isOpen && selectedTime)) && !notClearable && 'hover:text-text-secondary group-hover:inline-block')} role="button" aria-label={t('operation.clear', { ns: 'common' })} onClick={handleClear} />
</div>
)}
</PortalToFollowElemTrigger>
@@ -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', () => {
@@ -0,0 +1,105 @@
import { fireEvent, render, screen } from '@testing-library/react'
import DynamicPdfPreview from './dynamic-pdf-preview'
type DynamicPdfPreviewProps = {
url: string
onCancel: () => void
}
type DynamicLoader = () => Promise<unknown> | undefined
type DynamicOptions = {
ssr?: boolean
}
const mockState = vi.hoisted(() => ({
loader: undefined as DynamicLoader | undefined,
options: undefined as DynamicOptions | undefined,
}))
const mockDynamicRender = vi.hoisted(() => vi.fn())
const mockDynamic = vi.hoisted(() =>
vi.fn((loader: DynamicLoader, options: DynamicOptions) => {
mockState.loader = loader
mockState.options = options
const MockDynamicPdfPreview = ({ url, onCancel }: DynamicPdfPreviewProps) => {
mockDynamicRender({ url, onCancel })
return (
<button data-testid="dynamic-pdf-preview" data-url={url} onClick={onCancel}>
Dynamic PDF Preview
</button>
)
}
return MockDynamicPdfPreview
}),
)
const mockPdfPreview = vi.hoisted(() =>
vi.fn(() => null),
)
vi.mock('next/dynamic', () => ({
default: mockDynamic,
}))
vi.mock('./pdf-preview', () => ({
default: mockPdfPreview,
}))
describe('dynamic-pdf-preview', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should configure next/dynamic with ssr disabled', () => {
expect(mockState.loader).toEqual(expect.any(Function))
expect(mockState.options).toEqual({ ssr: false })
})
it('should render the dynamic component and forward props', () => {
const onCancel = vi.fn()
render(<DynamicPdfPreview url="https://example.com/test.pdf" onCancel={onCancel} />)
const trigger = screen.getByTestId('dynamic-pdf-preview')
expect(trigger).toHaveAttribute('data-url', 'https://example.com/test.pdf')
expect(mockDynamicRender).toHaveBeenCalledWith({
url: 'https://example.com/test.pdf',
onCancel,
})
fireEvent.click(trigger)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should return pdf-preview module when loader is executed in browser-like environment', async () => {
const loaded = mockState.loader?.()
expect(loaded).toBeInstanceOf(Promise)
const loadedModule = (await loaded) as { default: unknown }
const pdfPreviewModule = await import('./pdf-preview')
expect(loadedModule.default).toBe(pdfPreviewModule.default)
})
it('should return undefined when loader runs without window', () => {
const originalWindow = globalThis.window
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: undefined,
})
try {
const loaded = mockState.loader?.()
expect(loaded).toBeUndefined()
}
finally {
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: originalWindow,
})
}
})
})
@@ -44,4 +44,16 @@ describe('VariableOrConstantInputField', () => {
fireEvent.click(modeButtons[0])
expect(screen.getByRole('button', { name: 'Variable picker' })).toBeInTheDocument()
})
it('should handle variable picker changes', () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { })
try {
render(<VariableOrConstantInputField label="Input source" />)
fireEvent.click(screen.getByRole('button', { name: 'Variable picker' }))
expect(logSpy).toHaveBeenCalledWith('Variable value changed')
}
finally {
logSpy.mockRestore()
}
})
})

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