Merge remote-tracking branch 'origin/main' into deploy/konwledge

This commit is contained in:
Stephen Zhou 2026-07-29 17:42:53 +08:00
commit 360fa45b3e
No known key found for this signature in database
162 changed files with 4312 additions and 4345 deletions

View File

@ -76,7 +76,7 @@ from graphon.runtime import GraphRuntimeState
from graphon.variables.segments import ArrayFileSegment, FileSegment, Segment
from graphon.variables.variables import Variable
from graphon.workflow_type_encoder import WorkflowRuntimeTypeConverter
from libs.datetime_utils import naive_utc_now
from libs.datetime_utils import naive_utc_now, to_utc_timestamp
from models import Account, EndUser
from models.human_input import HumanInputForm
from models.workflow import WorkflowRun
@ -371,7 +371,7 @@ class WorkflowResponseConverter:
pause_reasons,
dispositions_by_form_id=dispositions_by_form_id,
expiration_times_by_form_id={
form_id: int(expiration_time.timestamp())
form_id: to_utc_timestamp(expiration_time)
for form_id, expiration_time in expiration_times_by_form_id.items()
},
)
@ -399,7 +399,7 @@ class WorkflowResponseConverter:
form_token=disposition.form_token if disposition else None,
approval_channels=list(disposition.approval_channels) if disposition else [],
resolved_default_values=reason.resolved_default_values,
expiration_time=int(expiration_time.timestamp()),
expiration_time=to_utc_timestamp(expiration_time),
),
)
)
@ -452,7 +452,7 @@ class WorkflowResponseConverter:
data=HumanInputFormTimeoutResponse.Data(
node_id=event.node_id,
node_title=event.node_title,
expiration_time=int(event.expiration_time.timestamp()),
expiration_time=to_utc_timestamp(event.expiration_time),
),
)

View File

@ -151,6 +151,9 @@ def fetch_model_config(
credentials_provider: CredentialsProvider,
model_factory: DifyModelFactory,
) -> tuple[ModelInstance, ModelConfigWithCredentialsEntity]:
if not node_data_model.provider or not node_data_model.name:
raise ValueError("LLM provider and model are required.")
if not node_data_model.mode:
raise LLMModeRequiredError("LLM mode is required.")

View File

@ -35,6 +35,15 @@ def ensure_naive_utc(dt: datetime.datetime) -> datetime.datetime:
return dt.astimezone(datetime.UTC).replace(tzinfo=None)
def to_utc_timestamp(dt: datetime.datetime) -> int:
"""Convert a datetime to Unix epoch seconds, assuming naive values are UTC.
Persisted datetimes may be returned without timezone information. Treat
those values as UTC instead of interpreting them in the host timezone.
"""
return int(ensure_naive_utc(dt).replace(tzinfo=datetime.UTC).timestamp())
def parse_time_range(
start: str | None, end: str | None, tzname: str
) -> tuple[datetime.datetime | None, datetime.datetime | None]:

View File

@ -1,11 +1,15 @@
"""Unit tests for Tencent tracing, including SQLite-backed account resolution."""
import gc
import logging
import warnings
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from dify_trace_tencent.config import TencentConfig
from dify_trace_tencent.tencent_trace import TencentDataTrace
from sqlalchemy.orm import Session
from core.ops.entities.trace_entity import (
DatasetRetrievalTraceInfo,
@ -18,7 +22,9 @@ from core.ops.entities.trace_entity import (
)
from graphon.entities import WorkflowNodeExecution
from graphon.enums import BuiltinNodeTypes
from models import Account, App
from models import Account, App, Tenant, TenantAccountJoin
from models.account import TenantAccountRole
from models.model import AppMode, IconType
logger = logging.getLogger(__name__)
@ -412,59 +418,100 @@ class TestTencentDataTrace:
assert result is None
assert len([r for r in caplog.records if r.levelno == logging.DEBUG]) >= 1
def test_get_workflow_node_executions(self, tencent_data_trace):
@pytest.mark.parametrize(
"sqlite3_session",
[(Account, App, Tenant, TenantAccountJoin)],
indirect=True,
)
def test_get_workflow_node_executions(
self,
tencent_data_trace,
monkeypatch: pytest.MonkeyPatch,
sqlite3_session: Session,
) -> None:
account = Account(name="Trace User", email="trace-user@example.com")
tenant = Tenant(name="Trace Tenant")
sqlite3_session.add_all([account, tenant])
sqlite3_session.flush()
app = App(
id="app-1",
tenant_id=tenant.id,
name="Trace App",
description="",
mode=AppMode.WORKFLOW,
icon_type=IconType.EMOJI,
icon="robot",
icon_background="#FFFFFF",
enable_site=False,
enable_api=False,
created_by=account.id,
max_active_requests=0,
)
tenant_join = TenantAccountJoin(
tenant_id=tenant.id,
account_id=account.id,
current=True,
role=TenantAccountRole.OWNER,
)
sqlite3_session.add_all([app, tenant_join])
sqlite3_session.commit()
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.metadata = {"app_id": "app-1"}
trace_info.metadata = {"app_id": app.id}
trace_info.workflow_run_id = "run-1"
database = SimpleNamespace(engine=sqlite3_session.get_bind())
monkeypatch.setattr("dify_trace_tencent.tencent_trace.db", database)
monkeypatch.setattr("models.account.db", database)
app = MagicMock(spec=App)
app.id = "app-1"
app.created_by = "user-1"
app.tenant_id = "tenant-1"
with patch("dify_trace_tencent.tencent_trace.SQLAlchemyWorkflowNodeExecutionRepository") as mock_repo:
mock_repo.return_value.get_by_workflow_execution.return_value = []
results = tencent_data_trace._get_workflow_node_executions(trace_info)
account = MagicMock(spec=Account)
account.id = "user-1"
assert results == []
service_account = mock_repo.call_args.kwargs["user"]
assert isinstance(service_account, Account)
assert service_account.id == account.id
assert mock_repo.call_args.kwargs["tenant_id"] == tenant.id
mock_executions = [MagicMock()]
with patch("dify_trace_tencent.tencent_trace.db") as mock_db:
mock_db.engine = "engine"
with patch("dify_trace_tencent.tencent_trace.Session") as mock_session_ctx:
session = mock_session_ctx.return_value.__enter__.return_value
session.scalar.side_effect = [app, account]
with patch("dify_trace_tencent.tencent_trace.SQLAlchemyWorkflowNodeExecutionRepository") as mock_repo:
mock_repo.return_value.get_by_workflow_execution.return_value = mock_executions
results = tencent_data_trace._get_workflow_node_executions(trace_info)
assert results == mock_executions
assert mock_repo.call_args.kwargs["tenant_id"] == "tenant-1"
def test_get_workflow_node_executions_no_app_id(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
def test_get_workflow_node_executions_no_app_id(
self,
tencent_data_trace,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
sqlite3_session: Session,
) -> None:
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.metadata = {}
monkeypatch.setattr(
"dify_trace_tencent.tencent_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind()),
)
with caplog.at_level(logging.ERROR):
results = tencent_data_trace._get_workflow_node_executions(trace_info)
assert results == []
assert len([r for r in caplog.records if r.levelno == logging.ERROR]) >= 1
def test_get_workflow_node_executions_app_not_found(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
@pytest.mark.parametrize("sqlite3_session", [(App,)], indirect=True)
def test_get_workflow_node_executions_app_not_found(
self,
tencent_data_trace,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
sqlite3_session: Session,
) -> None:
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.metadata = {"app_id": "app-1"}
monkeypatch.setattr(
"dify_trace_tencent.tencent_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind()),
)
with patch("dify_trace_tencent.tencent_trace.db") as mock_db:
mock_db.init_app = MagicMock() # Ensure init_app is mocked
mock_db.engine = "engine"
with patch("dify_trace_tencent.tencent_trace.Session") as mock_session_ctx:
session = mock_session_ctx.return_value.__enter__.return_value
session.scalar.return_value = None
with caplog.at_level(logging.ERROR):
results = tencent_data_trace._get_workflow_node_executions(trace_info)
assert results == []
assert len([r for r in caplog.records if r.levelno == logging.ERROR]) >= 1
with caplog.at_level(logging.ERROR):
results = tencent_data_trace._get_workflow_node_executions(trace_info)
assert results == []
assert len([r for r in caplog.records if r.levelno == logging.ERROR]) >= 1
def test_get_user_id_workflow(self, tencent_data_trace):
trace_info = MagicMock(spec=WorkflowTraceInfo)

View File

@ -356,7 +356,7 @@ class TidbOnQdrantVector(BaseVector):
query_filter=filter,
limit=kwargs.get("top_k", 4),
with_payload=True,
with_vectors=True,
with_vectors=False,
score_threshold=kwargs.get("score_threshold", 0.0),
)
docs = []

View File

@ -43,6 +43,7 @@ from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
from graphon.runtime import GraphRuntimeState
from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool
from graphon.workflow_type_encoder import WorkflowRuntimeTypeConverter
from libs.datetime_utils import to_utc_timestamp
from models.human_input import HumanInputForm
from models.model import AppMode, Message
from models.workflow import WorkflowNodeExecutionTriggeredFrom, WorkflowRun
@ -451,7 +452,7 @@ def _build_human_input_required_events(
)
with session_maker() as session:
for form_id, expiration_time, form_definition in session.execute(stmt):
expiration_times_by_form_id[str(form_id)] = int(expiration_time.timestamp())
expiration_times_by_form_id[str(form_id)] = to_utc_timestamp(expiration_time)
try:
definition_payload = json.loads(form_definition) if form_definition else {}
except (TypeError, json.JSONDecodeError):
@ -594,7 +595,7 @@ def _build_pause_event(
)
for row in session.execute(stmt):
form_id, expiration_time, *_rest = row
expiration_times_by_form_id[str(form_id)] = int(expiration_time.timestamp())
expiration_times_by_form_id[str(form_id)] = to_utc_timestamp(expiration_time)
# Reconnect paths must preserve the same pause-reason contract as live streams;
# otherwise clients see schema drift after resume.
reasons = enrich_human_input_pause_reasons(

View File

@ -311,34 +311,45 @@ def _publish_failed_workflow_terminal_events(exc: Exception, exec_params: AppExe
topic.publish(json.dumps(finished_payload.model_dump(mode="json"), ensure_ascii=False).encode())
def _get_event_name(event: str | Mapping[str, Any] | BaseModel) -> str | None:
def _get_event_data(event: str | Mapping[str, Any] | BaseModel) -> Mapping[str, Any] | None:
if isinstance(event, BaseModel):
# Temporary compatibility for legacy BaseModel stream events; remove after confirming generators always emit
# str / Mapping responses.
event_name = getattr(event, "event", None)
elif isinstance(event, Mapping):
event_name = event.get("event")
else:
return event.model_dump()
if isinstance(event, Mapping):
return event
return None
def _get_event_name(event: str | Mapping[str, Any] | BaseModel) -> str | None:
event_data = _get_event_data(event)
if event_data is None:
return None
event_name = event_data.get("event")
if event_name is None:
return None
return str(event_name)
def _get_task_id(event: str | Mapping[str, Any] | BaseModel) -> str | None:
if isinstance(event, BaseModel):
# Temporary compatibility for legacy BaseModel stream events; remove after confirming generators always emit
# str / Mapping responses.
task_id = getattr(event, "task_id", None)
elif isinstance(event, Mapping):
task_id = event.get("task_id")
else:
event_data = _get_event_data(event)
if event_data is None:
return None
task_id = event_data.get("task_id")
return task_id if isinstance(task_id, str) and task_id else None
def _get_error_message(event: str | Mapping[str, Any] | BaseModel) -> str | None:
event_data = _get_event_data(event)
if event_data is None:
return None
message = event_data.get("message")
return message if isinstance(message, str) and message else None
def _publish_streaming_response(
response_stream: Generator[str | Mapping[str, Any] | BaseModel, None, None],
workflow_run_id: str | uuid.UUID,
@ -406,6 +417,7 @@ def _publish_streaming_response(
started_published = False
terminal_published = False
last_task_id = normalized_workflow_run_id
stream_error_message: str | None = None
try:
for event in response_stream:
@ -429,6 +441,8 @@ def _publish_streaming_response(
started_published = True
elif event_name in terminal_events:
terminal_published = True
elif event_name == "error":
stream_error_message = _get_error_message(event) or stream_error_message
except Exception as exc:
if not terminal_published:
logger.exception(
@ -448,7 +462,7 @@ def _publish_streaming_response(
normalized_workflow_run_id,
)
_publish_failed_terminal_event(
error_message=unexpected_stream_end_message,
error_message=stream_error_message or unexpected_stream_end_message,
task_id=last_task_id,
publish_started=not started_published,
)

View File

@ -1,152 +1,140 @@
from __future__ import annotations
from contextlib import contextmanager
from unittest.mock import patch
import pytest
from sqlalchemy import Engine, literal, select
from sqlalchemy.orm import Session
from sqlalchemy import event, literal, select
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from controllers.common import session as session_module
from models import Tenant
class FakeSession:
committed: bool
rolled_back: bool
closed: bool
def __init__(self) -> None:
self.committed = False
self.rolled_back = False
self.closed = False
def commit(self) -> None:
self.committed = True
def rollback(self) -> None:
self.rolled_back = True
@contextmanager
def _bind_session_factory(session: Session):
database_session_factory = sessionmaker(
bind=session.get_bind(),
expire_on_commit=False,
)
with patch("core.db.session_factory._session_maker", database_session_factory):
yield
class FakeSessionContext:
session: FakeSession
entered: bool
exited: bool
exc_type: object | None
def __init__(self, session: FakeSession) -> None:
self.session = session
self.entered = False
self.exited = False
self.exc_type = None
def __enter__(self) -> FakeSession:
self.entered = True
return self.session
def __exit__(self, exc_type: object | None, *_args: object) -> None:
self.exited = True
self.exc_type = exc_type
self.session.closed = True
def _tenant_names(session: Session) -> list[str]:
session.expire_all()
return list(session.scalars(select(Tenant.name).order_by(Tenant.name)).all())
def test_with_session_write_commits_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
session = FakeSession()
session_context = FakeSessionContext(session)
monkeypatch.setattr(session_module.session_factory, "create_session", lambda: session_context)
@pytest.mark.parametrize("sqlite_session", [(Tenant,)], indirect=True)
def test_with_session_write_commits_on_success(sqlite_session: Session) -> None:
commit_observed = False
injected_session: Session | None = None
class Handler:
@session_module.with_session(write=True)
def post(self, injected_session):
assert injected_session is session
def post(self, session: Session):
nonlocal commit_observed, injected_session
injected_session = session
def observe_commit(_session: Session) -> None:
nonlocal commit_observed
commit_observed = True
event.listen(session, "after_commit", observe_commit)
session.add(Tenant(name="committed tenant"))
return "ok"
assert Handler().post() == "ok"
with _bind_session_factory(sqlite_session):
assert Handler().post() == "ok"
assert session.closed
assert session.committed
assert not session.rolled_back
assert session_context.entered
assert session_context.exited
assert session_context.exc_type is None
assert commit_observed
assert injected_session is not None
assert not injected_session.in_transaction()
assert _tenant_names(sqlite_session) == ["committed tenant"]
def test_with_session_default_write_commits_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
session = FakeSession()
session_context = FakeSessionContext(session)
monkeypatch.setattr(session_module.session_factory, "create_session", lambda: session_context)
class Handler:
@session_module.with_session
def post(self, injected_session):
assert injected_session is session
return "ok"
assert Handler().post() == "ok"
assert session.committed
assert not session.rolled_back
def test_with_session_write_rolls_back_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
session = FakeSession()
session_context = FakeSessionContext(session)
monkeypatch.setattr(session_module.session_factory, "create_session", lambda: session_context)
class Handler:
@session_module.with_session(write=True)
def get(self, _session):
raise RuntimeError("boom")
with pytest.raises(RuntimeError, match="boom"):
Handler().get()
assert session.closed
assert not session.committed
assert session.rolled_back
assert session_context.entered
assert session_context.exited
assert session_context.exc_type is RuntimeError
def test_with_session_write_allows_commit_then_more_database_work(
monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine
) -> None:
monkeypatch.setattr(session_module.session_factory, "create_session", lambda: Session(sqlite_engine))
@pytest.mark.parametrize("sqlite_session", [(Tenant,)], indirect=True)
def test_with_session_default_write_commits_on_success(sqlite_session: Session) -> None:
class Handler:
@session_module.with_session
def post(self, session: Session):
session.commit()
return session.scalar(select(literal(1)))
session.add(Tenant(name="default write tenant"))
return "ok"
assert Handler().post() == 1
with _bind_session_factory(sqlite_session):
assert Handler().post() == "ok"
assert _tenant_names(sqlite_session) == ["default write tenant"]
def test_with_session_read_mode_does_not_commit(monkeypatch: pytest.MonkeyPatch) -> None:
session = FakeSession()
session_context = FakeSessionContext(session)
monkeypatch.setattr(session_module.session_factory, "create_session", lambda: session_context)
@pytest.mark.parametrize("sqlite_session", [(Tenant,)], indirect=True)
def test_with_session_write_rolls_back_on_error(sqlite_session: Session) -> None:
rollback_observed = False
injected_session: Session | None = None
class Handler:
@session_module.with_session(write=True)
def get(self, session: Session):
nonlocal rollback_observed, injected_session
injected_session = session
def observe_rollback(_session: Session) -> None:
nonlocal rollback_observed
rollback_observed = True
event.listen(session, "after_rollback", observe_rollback)
session.add(Tenant(name="rolled back tenant"))
session.flush()
raise RuntimeError("boom")
with _bind_session_factory(sqlite_session), pytest.raises(RuntimeError, match="boom"):
Handler().get()
assert rollback_observed
assert injected_session is not None
assert not injected_session.in_transaction()
assert _tenant_names(sqlite_session) == []
def test_with_session_write_allows_commit_then_more_database_work(sqlite_engine: Engine) -> None:
with Session(sqlite_engine) as sqlite_session, _bind_session_factory(sqlite_session):
class Handler:
@session_module.with_session
def post(self, session: Session):
session.commit()
return session.scalar(select(literal(1)))
assert Handler().post() == 1
@pytest.mark.parametrize("sqlite_session", [(Tenant,)], indirect=True)
def test_with_session_read_mode_does_not_commit(sqlite_session: Session) -> None:
injected_session: Session | None = None
class Handler:
@session_module.with_session(write=False)
def get(self, injected_session):
assert injected_session is session
def get(self, session: Session):
nonlocal injected_session
injected_session = session
session.add(Tenant(name="uncommitted read tenant"))
session.flush()
return "ok"
assert Handler().get() == "ok"
with _bind_session_factory(sqlite_session):
assert Handler().get() == "ok"
assert session.closed
assert not session.committed
assert not session.rolled_back
assert session_context.entered
assert session_context.exited
assert session_context.exc_type is None
assert injected_session is not None
assert not injected_session.in_transaction()
assert _tenant_names(sqlite_session) == []
def test_with_session_preserves_wrapped_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
session = FakeSession()
session_context = FakeSessionContext(session)
monkeypatch.setattr(session_module.session_factory, "create_session", lambda: session_context)
def test_with_session_preserves_wrapped_metadata() -> None:
class Handler:
@session_module.with_session
def get(self, _session):
def get(self, _session: Session):
"""handler docs"""
return "ok"

View File

@ -0,0 +1,738 @@
"""Unit tests for Service API dataset controller behavior.
Service boundaries stay mocked, while ORM collaborators are concrete model instances
persisted in one in-memory SQLite session. The controller's ``db.session`` and the
session passed to unwrapped ``@with_session`` endpoints both use that same session,
so model properties and service call contracts exercise real SQLAlchemy behavior.
"""
import uuid
from datetime import UTC, datetime
from inspect import unwrap
from typing import cast
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from werkzeug.exceptions import Forbidden, NotFound
import services
from controllers.service_api.dataset.error import DatasetInUseError, DatasetNameDuplicateError, InvalidActionError
from extensions.ext_database import db
from models.account import Account, Tenant, TenantAccountRole
from models.dataset import AppDatasetJoin, Dataset, DatasetMetadata, Document
from models.enums import PermissionEnum
from models.model import App, Tag, TagBinding
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
DATASET_MODEL_TABLES = (
Account,
Tenant,
Dataset,
Document,
App,
AppDatasetJoin,
DatasetMetadata,
Tag,
TagBinding,
)
pytestmark = pytest.mark.parametrize("sqlite_session", [DATASET_MODEL_TABLES], indirect=True)
@pytest.fixture(autouse=True)
def controller_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Session:
"""Route controller and model database access through the test's SQLite session."""
# Flask-SQLAlchemy exposes a callable registry that also proxies Session methods.
# Seed that registry with this fixture's Session so both access styles share one transaction.
existing_session_factory = cast(sessionmaker[Session], lambda: sqlite_session)
session_registry = scoped_session(existing_session_factory)
monkeypatch.setattr(db, "session", session_registry)
return sqlite_session
@pytest.fixture
def tenant(controller_session: Session) -> Tenant:
tenant = Tenant(name="Dataset API Tenant")
controller_session.add(tenant)
controller_session.flush()
return tenant
@pytest.fixture
def account(controller_session: Session, tenant: Tenant, monkeypatch: pytest.MonkeyPatch) -> Account:
account = Account(name="Dataset API User", email=f"dataset-api-{uuid.uuid4()}@example.com")
account.role = TenantAccountRole.OWNER
account._current_tenant = tenant
controller_session.add(account)
controller_session.flush()
# Inject the concrete account at the controller boundary without relying on Flask-Login globals.
from controllers.service_api.dataset import dataset as dataset_module
monkeypatch.setattr(dataset_module, "current_user", account)
return account
def make_dataset(
session: Session,
tenant: Tenant,
account: Account,
**overrides: object,
) -> Dataset:
"""Create and flush a real dataset so its database-backed properties can be serialized."""
base: dict[str, object] = {
"id": str(uuid.uuid4()),
"tenant_id": tenant.id,
"name": "Dataset",
"description": "desc",
"provider": "vendor",
"permission": PermissionEnum.ONLY_ME,
"data_source_type": None,
"indexing_technique": "economy",
"created_by": account.id,
"created_at": datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC),
"updated_by": None,
"updated_at": datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC),
"embedding_model": None,
"embedding_model_provider": None,
"retrieval_model": None,
"summary_index_setting": None,
"built_in_field_enabled": False,
"pipeline_id": None,
"runtime_mode": "general",
"chunk_structure": None,
"icon_info": None,
"enable_api": False,
"is_multimodal": False,
}
base.update(overrides)
dataset = Dataset(**base)
session.add(dataset)
session.flush()
return dataset
@pytest.fixture
def dataset(controller_session: Session, tenant: Tenant, account: Account) -> Dataset:
return make_dataset(controller_session, tenant, account)
DATASET_DETAIL_KEYS = {
"id",
"name",
"description",
"provider",
"permission",
"data_source_type",
"indexing_technique",
"app_count",
"document_count",
"word_count",
"created_by",
"author_name",
"created_at",
"updated_by",
"updated_at",
"embedding_model",
"embedding_model_provider",
"embedding_available",
"retrieval_model_dict",
"summary_index_setting",
"tags",
"doc_form",
"external_knowledge_info",
"external_retrieval_model",
"doc_metadata",
"built_in_field_enabled",
"pipeline_id",
"runtime_mode",
"chunk_structure",
"icon_info",
"is_published",
"total_documents",
"total_available_documents",
"enable_api",
"is_multimodal",
"maintainer",
}
def assert_dataset_detail_shape(response: dict[str, object], *, with_partial_members: bool = False) -> None:
expected_keys = set(DATASET_DETAIL_KEYS)
if with_partial_members:
expected_keys.add("partial_member_list")
assert set(response) == expected_keys
assert isinstance(response["created_at"], int)
assert isinstance(response["updated_at"], int)
retrieval_model = response["retrieval_model_dict"]
assert isinstance(retrieval_model, dict)
assert set(retrieval_model) == {
"search_method",
"reranking_enable",
"reranking_mode",
"reranking_model",
"weights",
"top_k",
"score_threshold_enabled",
"score_threshold",
}
external_retrieval_model = response["external_retrieval_model"]
if external_retrieval_model is not None:
assert isinstance(external_retrieval_model, dict)
assert set(external_retrieval_model) == {
"top_k",
"score_threshold",
"score_threshold_enabled",
}
if not with_partial_members:
assert "partial_member_list" not in response
# ---------------------------------------------------------------------------
# API endpoint tests — DatasetListApi
# ---------------------------------------------------------------------------
class TestDatasetListApiGet:
"""Test suite for DatasetListApi.get() endpoint."""
@patch("controllers.service_api.dataset.dataset.create_plugin_provider_manager")
@patch("controllers.service_api.dataset.dataset.DatasetService")
def test_list_datasets_success(
self,
mock_dataset_svc: MagicMock,
mock_provider_mgr: MagicMock,
app: Flask,
account: Account,
tenant: Tenant,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetListApi
mock_dataset_svc.get_datasets.return_value = ([make_dataset(controller_session, tenant, account)], 1)
mock_provider_mgr.return_value.get_configurations.return_value.get_models.return_value = list[object]()
with app.test_request_context("/datasets?page=1&limit=20", method="GET"):
api = DatasetListApi()
response, status = unwrap(api.get)(api, controller_session, tenant_id=tenant.id)
assert status == 200
assert set(response) == {"data", "has_more", "limit", "total", "page"}
assert response["has_more"] is False
assert response["limit"] == 20
assert response["total"] == 1
assert response["page"] == 1
assert len(response["data"]) == 1
assert_dataset_detail_shape(response["data"][0])
@patch("controllers.service_api.dataset.dataset.create_plugin_provider_manager")
@patch("controllers.service_api.dataset.dataset.DatasetService")
def test_list_datasets_preserves_repeated_tag_ids(
self,
mock_dataset_svc: MagicMock,
mock_provider_mgr: MagicMock,
app: Flask,
account: Account,
tenant: Tenant,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetListApi
mock_dataset_svc.get_datasets.return_value = ([make_dataset(controller_session, tenant, account)], 1)
mock_provider_mgr.return_value.get_configurations.return_value.get_models.return_value = list[object]()
with app.test_request_context("/datasets?tag_ids=tag-a&tag_ids=tag-b", method="GET"):
api = DatasetListApi()
response, status = unwrap(api.get)(api, controller_session, tenant_id=tenant.id)
page, limit, session, tenant_id, user, keyword, tag_ids, include_all = (
mock_dataset_svc.get_datasets.call_args.args
)
assert user is account
assert status == 200
assert response["total"] == 1
assert (page, limit, session, tenant_id, keyword, tag_ids, include_all) == (
1,
20,
controller_session,
tenant.id,
None,
["tag-a", "tag-b"],
False,
)
class TestDatasetListApiPost:
"""Test suite for DatasetListApi.post() endpoint."""
@patch("controllers.service_api.dataset.dataset.DatasetService")
def test_create_dataset_success(
self,
mock_dataset_svc: MagicMock,
app: Flask,
account: Account,
tenant: Tenant,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetListApi
mock_dataset_svc.create_empty_dataset.return_value = make_dataset(
controller_session, tenant, account, name="New Dataset"
)
with app.test_request_context(
"/datasets",
method="POST",
json={"name": "New Dataset"},
):
api = DatasetListApi()
response, status = unwrap(api.post)(api, controller_session, tenant_id=tenant.id)
assert status == 200
assert_dataset_detail_shape(response)
assert response["name"] == "New Dataset"
mock_dataset_svc.create_empty_dataset.assert_called_once()
@pytest.mark.usefixtures("account")
@patch("controllers.service_api.dataset.dataset.DatasetService")
def test_create_dataset_duplicate_name(
self,
mock_dataset_svc: MagicMock,
app: Flask,
tenant: Tenant,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetListApi
mock_dataset_svc.create_empty_dataset.side_effect = services.errors.dataset.DatasetNameDuplicateError()
with app.test_request_context(
"/datasets",
method="POST",
json={"name": "Existing Dataset"},
):
api = DatasetListApi()
with pytest.raises(DatasetNameDuplicateError):
unwrap(api.post)(api, controller_session, tenant_id=tenant.id)
# ---------------------------------------------------------------------------
# API endpoint tests — DatasetApi
# ---------------------------------------------------------------------------
class TestDatasetApiGet:
"""Test suite for DatasetApi.get() endpoint."""
@patch("controllers.service_api.dataset.dataset.create_plugin_provider_manager")
@patch("controllers.service_api.dataset.dataset.DatasetService")
def test_get_dataset_success(
self,
mock_dataset_svc: MagicMock,
mock_provider_mgr: MagicMock,
app: Flask,
dataset: Dataset,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetApi
mock_dataset_svc.get_dataset.return_value = dataset
mock_dataset_svc.check_dataset_permission.return_value = None
mock_provider_mgr.return_value.get_configurations.return_value.get_models.return_value = list[object]()
with app.test_request_context(
f"/datasets/{dataset.id}",
method="GET",
):
api = DatasetApi()
response, status = unwrap(api.get)(api, controller_session, _=dataset.tenant_id, dataset_id=dataset.id)
assert status == 200
assert_dataset_detail_shape(response)
assert response["embedding_available"] is True
assert response["retrieval_model_dict"]["search_method"] == "keyword_search"
@patch("controllers.service_api.dataset.dataset.DatasetPermissionService")
@patch("controllers.service_api.dataset.dataset.create_plugin_provider_manager")
@patch("controllers.service_api.dataset.dataset.DatasetService")
def test_get_dataset_partial_members_shape(
self,
mock_dataset_svc: MagicMock,
mock_provider_mgr: MagicMock,
mock_perm_svc: MagicMock,
app: Flask,
dataset: Dataset,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetApi
dataset.permission = PermissionEnum.PARTIAL_TEAM
mock_dataset_svc.get_dataset.return_value = dataset
mock_dataset_svc.check_dataset_permission.return_value = None
mock_perm_svc.get_dataset_partial_member_list.return_value = ["user-1", "user-2"]
mock_provider_mgr.return_value.get_configurations.return_value.get_models.return_value = list[object]()
with app.test_request_context(
f"/datasets/{dataset.id}",
method="GET",
):
api = DatasetApi()
response, status = unwrap(api.get)(api, controller_session, _=dataset.tenant_id, dataset_id=dataset.id)
assert status == 200
assert_dataset_detail_shape(response, with_partial_members=True)
assert response["partial_member_list"] == ["user-1", "user-2"]
@patch("controllers.service_api.dataset.dataset.create_plugin_provider_manager")
@patch("controllers.service_api.dataset.dataset.DatasetService")
def test_get_dataset_uses_default_external_retrieval_model(
self,
mock_dataset_svc: MagicMock,
mock_provider_mgr: MagicMock,
app: Flask,
dataset: Dataset,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetApi
dataset.retrieval_model = None
mock_dataset_svc.get_dataset.return_value = dataset
mock_dataset_svc.check_dataset_permission.return_value = None
mock_provider_mgr.return_value.get_configurations.return_value.get_models.return_value = list[object]()
with app.test_request_context(f"/datasets/{dataset.id}", method="GET"):
api = DatasetApi()
response, status = unwrap(api.get)(api, controller_session, _=dataset.tenant_id, dataset_id=dataset.id)
assert status == 200
assert_dataset_detail_shape(response)
assert response["external_retrieval_model"] == {
"top_k": 2,
"score_threshold": 0.0,
"score_threshold_enabled": None,
}
@patch("controllers.service_api.dataset.dataset.DatasetService")
def test_get_dataset_not_found(
self,
mock_dataset_svc: MagicMock,
app: Flask,
dataset: Dataset,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetApi
mock_dataset_svc.get_dataset.return_value = None
with app.test_request_context(
f"/datasets/{dataset.id}",
method="GET",
):
api = DatasetApi()
with pytest.raises(NotFound):
unwrap(api.get)(api, controller_session, _=dataset.tenant_id, dataset_id=dataset.id)
@patch("controllers.service_api.dataset.dataset.DatasetService")
def test_get_dataset_no_permission(
self,
mock_dataset_svc: MagicMock,
app: Flask,
dataset: Dataset,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetApi
mock_dataset_svc.get_dataset.return_value = dataset
mock_dataset_svc.check_dataset_permission.side_effect = services.errors.account.NoPermissionError()
with app.test_request_context(
f"/datasets/{dataset.id}",
method="GET",
):
api = DatasetApi()
with pytest.raises(Forbidden):
unwrap(api.get)(api, controller_session, _=dataset.tenant_id, dataset_id=dataset.id)
class TestDatasetApiPatch:
"""Test suite for DatasetApi.patch() endpoint."""
@patch("controllers.service_api.dataset.dataset.DatasetPermissionService")
@patch("controllers.service_api.dataset.dataset.DatasetService")
def test_patch_dataset_success_shape(
self,
mock_dataset_svc: MagicMock,
mock_perm_svc: MagicMock,
app: Flask,
dataset: Dataset,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetApi
dataset.name = "Updated Dataset"
mock_dataset_svc.get_dataset.return_value = dataset
mock_dataset_svc.update_dataset.return_value = dataset
mock_perm_svc.check_permission.return_value = None
mock_perm_svc.get_dataset_partial_member_list.return_value = ["user-1"]
payload = {
"name": "Updated Dataset",
"permission": "partial_members",
"partial_member_list": [{"user_id": "user-1", "role": "editor"}],
}
with app.test_request_context(
f"/datasets/{dataset.id}",
method="PATCH",
json=payload,
):
api = DatasetApi()
response, status = unwrap(api.patch)(
api,
controller_session,
_=dataset.tenant_id,
dataset_id=dataset.id,
)
assert status == 200
assert_dataset_detail_shape(response, with_partial_members=True)
assert response["name"] == "Updated Dataset"
assert response["partial_member_list"] == ["user-1"]
mock_dataset_svc.update_dataset.assert_called_once()
_, update_data, _ = mock_dataset_svc.update_dataset.call_args.args
session = mock_dataset_svc.update_dataset.call_args.kwargs["session"]
assert session is controller_session
assert update_data["name"] == "Updated Dataset"
assert update_data["permission"] == "partial_members"
mock_perm_svc.update_partial_member_list.assert_called_once_with(
dataset.tenant_id,
dataset.id,
[{"user_id": "user-1", "role": "editor"}],
controller_session,
)
class TestDatasetApiDelete:
"""Test suite for DatasetApi.delete() endpoint."""
@patch("controllers.service_api.dataset.dataset.DatasetPermissionService")
@patch("controllers.service_api.dataset.dataset.DatasetService")
def test_delete_dataset_success(
self,
mock_dataset_svc: MagicMock,
mock_perm_svc: MagicMock,
app: Flask,
dataset: Dataset,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetApi
mock_dataset_svc.delete_dataset.return_value = True
with app.test_request_context(
f"/datasets/{dataset.id}",
method="DELETE",
):
api = DatasetApi()
result = unwrap(api.delete)(api, controller_session, _=dataset.tenant_id, dataset_id=dataset.id)
assert result == ("", 204)
mock_perm_svc.clear_partial_member_list.assert_called_once_with(dataset.id, controller_session)
@patch("controllers.service_api.dataset.dataset.DatasetService")
def test_delete_dataset_not_found(
self,
mock_dataset_svc: MagicMock,
app: Flask,
dataset: Dataset,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetApi
mock_dataset_svc.delete_dataset.return_value = False
with app.test_request_context(
f"/datasets/{dataset.id}",
method="DELETE",
):
api = DatasetApi()
with pytest.raises(NotFound):
unwrap(api.delete)(api, controller_session, _=dataset.tenant_id, dataset_id=dataset.id)
@patch("controllers.service_api.dataset.dataset.DatasetService")
def test_delete_dataset_in_use(
self,
mock_dataset_svc: MagicMock,
app: Flask,
dataset: Dataset,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetApi
mock_dataset_svc.delete_dataset.side_effect = services.errors.dataset.DatasetInUseError()
with app.test_request_context(
f"/datasets/{dataset.id}",
method="DELETE",
):
api = DatasetApi()
with pytest.raises(DatasetInUseError):
unwrap(api.delete)(api, controller_session, _=dataset.tenant_id, dataset_id=dataset.id)
# ---------------------------------------------------------------------------
# API endpoint tests — DocumentStatusApi
# ---------------------------------------------------------------------------
class TestDocumentStatusApiPatch:
"""Test suite for DocumentStatusApi.patch() endpoint."""
@patch("controllers.service_api.dataset.dataset.DocumentService")
@patch("controllers.service_api.dataset.dataset.DatasetService")
def test_batch_update_status_success(
self,
mock_dataset_svc: MagicMock,
mock_doc_svc: MagicMock,
app: Flask,
tenant: Tenant,
dataset: Dataset,
) -> None:
from controllers.service_api.dataset.dataset import DocumentStatusApi
mock_dataset_svc.get_dataset.return_value = dataset
mock_dataset_svc.check_dataset_permission.return_value = None
mock_dataset_svc.check_dataset_model_setting.return_value = None
mock_doc_svc.batch_update_document_status.return_value = None
with app.test_request_context(
f"/datasets/{dataset.id}/documents/status/enable",
method="PATCH",
json={"document_ids": ["doc-1", "doc-2"]},
):
api = DocumentStatusApi()
response, status = api.patch(
tenant_id=tenant.id,
dataset_id=dataset.id,
action="enable",
)
assert status == 200
assert response["result"] == "success"
@patch("controllers.service_api.dataset.dataset.DatasetService")
def test_batch_update_status_dataset_not_found(
self,
mock_dataset_svc: MagicMock,
app: Flask,
tenant: Tenant,
dataset: Dataset,
) -> None:
from controllers.service_api.dataset.dataset import DocumentStatusApi
mock_dataset_svc.get_dataset.return_value = None
with app.test_request_context(
f"/datasets/{dataset.id}/documents/status/enable",
method="PATCH",
json={"document_ids": ["doc-1"]},
):
api = DocumentStatusApi()
with pytest.raises(NotFound):
api.patch(
tenant_id=tenant.id,
dataset_id=dataset.id,
action="enable",
)
@patch("controllers.service_api.dataset.dataset.DatasetService")
def test_batch_update_status_permission_error(
self,
mock_dataset_svc: MagicMock,
app: Flask,
tenant: Tenant,
dataset: Dataset,
) -> None:
from controllers.service_api.dataset.dataset import DocumentStatusApi
mock_dataset_svc.get_dataset.return_value = dataset
mock_dataset_svc.check_dataset_permission.side_effect = services.errors.account.NoPermissionError(
"No permission"
)
with app.test_request_context(
f"/datasets/{dataset.id}/documents/status/enable",
method="PATCH",
json={"document_ids": ["doc-1"]},
):
api = DocumentStatusApi()
with pytest.raises(Forbidden):
api.patch(
tenant_id=tenant.id,
dataset_id=dataset.id,
action="enable",
)
@patch("controllers.service_api.dataset.dataset.DocumentService")
@patch("controllers.service_api.dataset.dataset.DatasetService")
def test_batch_update_status_indexing_error(
self,
mock_dataset_svc: MagicMock,
mock_doc_svc: MagicMock,
app: Flask,
tenant: Tenant,
dataset: Dataset,
) -> None:
from controllers.service_api.dataset.dataset import DocumentStatusApi
mock_dataset_svc.get_dataset.return_value = dataset
mock_dataset_svc.check_dataset_permission.return_value = None
mock_dataset_svc.check_dataset_model_setting.return_value = None
mock_doc_svc.batch_update_document_status.side_effect = services.errors.document.DocumentIndexingError()
with app.test_request_context(
f"/datasets/{dataset.id}/documents/status/enable",
method="PATCH",
json={"document_ids": ["doc-1"]},
):
api = DocumentStatusApi()
with pytest.raises(InvalidActionError):
api.patch(
tenant_id=tenant.id,
dataset_id=dataset.id,
action="enable",
)
@patch("controllers.service_api.dataset.dataset.DocumentService")
@patch("controllers.service_api.dataset.dataset.DatasetService")
def test_batch_update_status_value_error(
self,
mock_dataset_svc: MagicMock,
mock_doc_svc: MagicMock,
app: Flask,
tenant: Tenant,
dataset: Dataset,
) -> None:
from controllers.service_api.dataset.dataset import DocumentStatusApi
mock_dataset_svc.get_dataset.return_value = dataset
mock_dataset_svc.check_dataset_permission.return_value = None
mock_dataset_svc.check_dataset_model_setting.return_value = None
mock_doc_svc.batch_update_document_status.side_effect = ValueError("Invalid action")
with app.test_request_context(
f"/datasets/{dataset.id}/documents/status/enable",
method="PATCH",
json={"document_ids": ["doc-1"]},
):
api = DocumentStatusApi()
with pytest.raises(InvalidActionError):
api.patch(
tenant_id=tenant.id,
dataset_id=dataset.id,
action="enable",
)

View File

@ -0,0 +1,207 @@
"""Unit tests for Service API dataset request payloads."""
from typing import Literal
import pytest
from controllers.service_api.dataset.dataset import (
DatasetCreatePayload,
DatasetListQuery,
DatasetUpdatePayload,
TagBindingPayload,
TagCreatePayload,
TagDeletePayload,
TagUnbindingPayload,
TagUpdatePayload,
)
from models.dataset import DatasetPermissionEnum
class TestDatasetCreatePayload:
"""Test suite for DatasetCreatePayload Pydantic model."""
def test_payload_with_required_name(self) -> None:
payload = DatasetCreatePayload(name="Test Dataset")
assert payload.name == "Test Dataset"
assert payload.description == ""
assert payload.permission == DatasetPermissionEnum.ONLY_ME
def test_payload_with_all_fields(self) -> None:
payload = DatasetCreatePayload(
name="Full Dataset",
description="A comprehensive dataset description",
indexing_technique="high_quality",
permission=DatasetPermissionEnum.ALL_TEAM,
provider="vendor",
embedding_model="text-embedding-ada-002",
embedding_model_provider="openai",
)
assert payload.name == "Full Dataset"
assert payload.description == "A comprehensive dataset description"
assert payload.indexing_technique == "high_quality"
assert payload.permission == DatasetPermissionEnum.ALL_TEAM
assert payload.provider == "vendor"
assert payload.embedding_model == "text-embedding-ada-002"
assert payload.embedding_model_provider == "openai"
def test_payload_name_length_validation_min(self) -> None:
with pytest.raises(ValueError):
DatasetCreatePayload(name="")
def test_payload_name_length_validation_max(self) -> None:
with pytest.raises(ValueError):
DatasetCreatePayload(name="A" * 41)
def test_payload_description_max_length(self) -> None:
with pytest.raises(ValueError):
DatasetCreatePayload(name="Dataset", description="A" * 401)
@pytest.mark.parametrize("technique", ["high_quality", "economy"])
def test_payload_valid_indexing_techniques(self, technique: Literal["high_quality", "economy"]) -> None:
payload = DatasetCreatePayload(name="Dataset", indexing_technique=technique)
assert payload.indexing_technique == technique
def test_payload_with_external_knowledge_settings(self) -> None:
payload = DatasetCreatePayload(
name="External Dataset", external_knowledge_api_id="api_123", external_knowledge_id="knowledge_456"
)
assert payload.external_knowledge_api_id == "api_123"
assert payload.external_knowledge_id == "knowledge_456"
class TestDatasetUpdatePayload:
"""Test suite for DatasetUpdatePayload Pydantic model."""
def test_payload_all_optional(self) -> None:
payload = DatasetUpdatePayload()
assert payload.name is None
assert payload.description is None
assert payload.permission is None
def test_payload_with_partial_update(self) -> None:
payload = DatasetUpdatePayload(name="Updated Name", description="Updated description")
assert payload.name == "Updated Name"
assert payload.description == "Updated description"
def test_payload_with_permission_change(self) -> None:
payload = DatasetUpdatePayload(
permission=DatasetPermissionEnum.PARTIAL_TEAM,
partial_member_list=[{"user_id": "user_123", "role": "editor"}],
)
assert payload.permission == DatasetPermissionEnum.PARTIAL_TEAM
assert payload.partial_member_list is not None
assert len(payload.partial_member_list) == 1
def test_payload_name_length_validation(self) -> None:
with pytest.raises(ValueError):
DatasetUpdatePayload(name="")
with pytest.raises(ValueError):
DatasetUpdatePayload(name="A" * 41)
class TestDatasetListQuery:
"""Test suite for DatasetListQuery Pydantic model."""
def test_query_with_defaults(self) -> None:
query = DatasetListQuery()
assert query.page == 1
assert query.limit == 20
assert query.keyword is None
assert query.include_all is False
assert query.tag_ids == []
def test_query_with_all_filters(self) -> None:
query = DatasetListQuery(
page=3, limit=50, keyword="machine learning", include_all=True, tag_ids=["tag1", "tag2", "tag3"]
)
assert query.page == 3
assert query.limit == 50
assert query.keyword == "machine learning"
assert query.include_all is True
assert len(query.tag_ids) == 3
def test_query_with_tag_filter(self) -> None:
query = DatasetListQuery(tag_ids=["tag_abc", "tag_def"])
assert query.tag_ids == ["tag_abc", "tag_def"]
class TestTagCreatePayload:
"""Test suite for TagCreatePayload Pydantic model."""
def test_payload_with_name(self) -> None:
payload = TagCreatePayload(name="New Tag")
assert payload.name == "New Tag"
def test_payload_name_length_min(self) -> None:
with pytest.raises(ValueError):
TagCreatePayload(name="")
def test_payload_name_length_max(self) -> None:
with pytest.raises(ValueError):
TagCreatePayload(name="A" * 51)
def test_payload_with_unicode_name(self) -> None:
payload = TagCreatePayload(name="标签 🏷️ Тег")
assert payload.name == "标签 🏷️ Тег"
class TestTagUpdatePayload:
"""Test suite for TagUpdatePayload Pydantic model."""
def test_payload_with_name_and_id(self) -> None:
payload = TagUpdatePayload(name="Updated Tag", tag_id="tag_123")
assert payload.name == "Updated Tag"
assert payload.tag_id == "tag_123"
def test_payload_requires_tag_id(self) -> None:
with pytest.raises(ValueError):
TagUpdatePayload.model_validate({"name": "Updated Tag"})
class TestTagDeletePayload:
"""Test suite for TagDeletePayload Pydantic model."""
def test_payload_with_tag_id(self) -> None:
payload = TagDeletePayload(tag_id="tag_to_delete")
assert payload.tag_id == "tag_to_delete"
def test_payload_requires_tag_id(self) -> None:
with pytest.raises(ValueError):
TagDeletePayload.model_validate({})
class TestTagBindingPayload:
"""Test suite for TagBindingPayload Pydantic model."""
def test_payload_with_valid_data(self) -> None:
payload = TagBindingPayload(tag_ids=["tag1", "tag2"], target_id="dataset_123")
assert len(payload.tag_ids) == 2
assert payload.target_id == "dataset_123"
def test_payload_rejects_empty_tag_ids(self) -> None:
with pytest.raises(ValueError) as exc_info:
TagBindingPayload(tag_ids=[], target_id="dataset_123")
assert "Tag IDs is required" in str(exc_info.value)
def test_payload_single_tag_id(self) -> None:
payload = TagBindingPayload(tag_ids=["single_tag"], target_id="dataset_456")
assert payload.tag_ids == ["single_tag"]
class TestTagUnbindingPayload:
"""Test suite for TagUnbindingPayload Pydantic model."""
def test_payload_with_valid_data(self) -> None:
payload = TagUnbindingPayload(tag_ids=["tag_123"], target_id="dataset_456")
assert payload.tag_ids == ["tag_123"]
assert payload.target_id == "dataset_456"
def test_payload_normalizes_legacy_tag_id(self) -> None:
payload = TagUnbindingPayload(tag_id="tag_123", target_id="dataset_456")
assert payload.tag_ids == ["tag_123"]
assert payload.target_id == "dataset_456"
def test_payload_rejects_empty_tag_ids(self) -> None:
with pytest.raises(ValueError) as exc_info:
TagUnbindingPayload(tag_ids=[], target_id="dataset_456")
assert "Tag IDs is required" in str(exc_info.value)

View File

@ -0,0 +1,380 @@
"""Unit tests for Service API dataset tag controller behavior.
Service boundaries stay mocked, while users, tenants, and tags are real ORM objects
persisted in SQLite. Controller database calls share that SQLite session so assertions
cover the concrete objects and session passed across the controller boundary.
"""
import uuid
from inspect import unwrap
from typing import cast
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from werkzeug.exceptions import Forbidden
from extensions.ext_database import db
from models.account import Account, Tenant, TenantAccountRole
from models.enums import TagType
from models.model import Tag
TAG_MODEL_TABLES = (Account, Tenant, Tag)
pytestmark = pytest.mark.parametrize("sqlite_session", [TAG_MODEL_TABLES], indirect=True)
@pytest.fixture(autouse=True)
def controller_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Session:
"""Route controller database access through the test's SQLite session."""
# Flask-SQLAlchemy exposes a callable registry that also proxies Session methods.
# Seed that registry with this fixture's Session so both access styles share one transaction.
existing_session_factory = cast(sessionmaker[Session], lambda: sqlite_session)
session_registry = scoped_session(existing_session_factory)
monkeypatch.setattr(db, "session", session_registry)
return sqlite_session
@pytest.fixture
def tenant(controller_session: Session) -> Tenant:
tenant = Tenant(name="Dataset Tag API Tenant")
controller_session.add(tenant)
controller_session.flush()
return tenant
@pytest.fixture
def account(controller_session: Session, tenant: Tenant, monkeypatch: pytest.MonkeyPatch) -> Account:
account = Account(name="Dataset Tag API User", email=f"dataset-tag-api-{uuid.uuid4()}@example.com")
account.role = TenantAccountRole.OWNER
account._current_tenant = tenant
controller_session.add(account)
controller_session.flush()
# Inject the concrete account at the controller boundary without relying on Flask-Login globals.
from controllers.service_api.dataset import dataset as dataset_module
monkeypatch.setattr(dataset_module, "current_user", account)
return account
def make_tag(
session: Session,
tenant: Tenant,
account: Account,
*,
id: str,
name: str,
binding_count: int | None = None,
) -> Tag:
"""Create and flush a real tag, optionally adding the aggregate count returned by TagService."""
tag = Tag(tenant_id=tenant.id, type=TagType.KNOWLEDGE, name=name, created_by=account.id)
tag.id = id
session.add(tag)
session.flush()
if binding_count is not None:
tag.__dict__["binding_count"] = binding_count
return tag
class TestDatasetTagsApiGet:
"""Test suite for DatasetTagsApi.get() endpoint."""
@patch("controllers.service_api.dataset.dataset.TagService")
def test_list_tags_success(
self,
mock_tag_svc: MagicMock,
app: Flask,
account: Account,
tenant: Tenant,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetTagsApi
tag = make_tag(controller_session, tenant, account, id="tag-1", name="Test Tag", binding_count=0)
mock_tag_svc.get_tags.return_value = [tag]
with app.test_request_context("/datasets/tags", method="GET"):
api = DatasetTagsApi()
response, status = unwrap(api.get)(api, controller_session, _=None)
assert status == 200
assert response == [{"id": "tag-1", "name": "Test Tag", "type": "knowledge", "binding_count": "0"}]
mock_tag_svc.get_tags.assert_called_once_with("knowledge", tenant.id, session=controller_session)
class TestDatasetTagsApiPost:
"""Test suite for DatasetTagsApi.post() endpoint."""
@patch("controllers.service_api.dataset.dataset.TagService")
def test_create_tag_success(
self,
mock_tag_svc: MagicMock,
app: Flask,
account: Account,
tenant: Tenant,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetTagsApi
tag = make_tag(controller_session, tenant, account, id="tag-new", name="New Tag")
mock_tag_svc.save_tags.return_value = tag
with app.test_request_context(
"/datasets/tags",
method="POST",
json={"name": "New Tag"},
):
api = DatasetTagsApi()
response, status = unwrap(api.post)(api, controller_session, _=None)
assert status == 200
assert response == {"id": "tag-new", "name": "New Tag", "type": "knowledge", "binding_count": "0"}
mock_tag_svc.save_tags.assert_called_once()
def test_create_tag_forbidden(self, app: Flask, account: Account) -> None:
from controllers.service_api.dataset.dataset import DatasetTagsApi
account.role = TenantAccountRole.NORMAL
with app.test_request_context(
"/datasets/tags",
method="POST",
json={"name": "New Tag"},
):
api = DatasetTagsApi()
with pytest.raises(Forbidden):
api.post(_=None)
class TestDatasetTagsApiPatch:
"""Test suite for DatasetTagsApi.patch() endpoint."""
@patch("controllers.service_api.dataset.dataset.TagService")
@patch("controllers.service_api.dataset.dataset.service_api_ns")
def test_update_tag_success(
self,
mock_service_api_ns: MagicMock,
mock_tag_svc: MagicMock,
app: Flask,
account: Account,
tenant: Tenant,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetTagsApi
tag = make_tag(controller_session, tenant, account, id="tag-1", name="Updated Tag")
mock_tag_svc.update_tags.return_value = tag
mock_tag_svc.get_tag_binding_count.return_value = 5
mock_service_api_ns.payload = {"name": "Updated Tag", "tag_id": "tag-1"}
with app.test_request_context(
"/datasets/tags",
method="PATCH",
json={"name": "Updated Tag", "tag_id": "tag-1"},
):
api = DatasetTagsApi()
response, status = unwrap(api.patch)(api, controller_session, _=None)
assert status == 200
assert response == {"id": "tag-1", "name": "Updated Tag", "type": "knowledge", "binding_count": "5"}
mock_tag_svc.update_tags.assert_called_once()
update_payload, tag_id, session = mock_tag_svc.update_tags.call_args.args
assert update_payload.name == "Updated Tag"
assert tag_id == "tag-1"
assert session is controller_session
def test_update_tag_forbidden(self, app: Flask, account: Account) -> None:
from controllers.service_api.dataset.dataset import DatasetTagsApi
account.role = TenantAccountRole.NORMAL
with app.test_request_context(
"/datasets/tags",
method="PATCH",
json={"name": "Updated Tag", "tag_id": "tag-1"},
):
api = DatasetTagsApi()
with pytest.raises(Forbidden):
api.patch(_=None)
class TestDatasetTagsApiDelete:
"""Test suite for DatasetTagsApi.delete() endpoint."""
@pytest.mark.usefixtures("account")
@patch("controllers.service_api.dataset.dataset.TagService")
@patch("controllers.service_api.dataset.dataset.service_api_ns")
def test_delete_tag_success(
self,
mock_service_api_ns: MagicMock,
mock_tag_svc: MagicMock,
app: Flask,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetTagsApi
mock_tag_svc.delete_tag.return_value = None
mock_service_api_ns.payload = {"tag_id": "tag-1"}
with app.test_request_context(
"/datasets/tags",
method="DELETE",
json={"tag_id": "tag-1"},
):
api = DatasetTagsApi()
result = unwrap(api.delete)(api, controller_session, _=None)
assert result == ("", 204)
mock_tag_svc.delete_tag.assert_called_once_with("tag-1", controller_session, tag_type=TagType.KNOWLEDGE)
class TestDatasetTagsBindingStatusApi:
"""Test suite for DatasetTagsBindingStatusApi endpoints."""
@patch("controllers.service_api.dataset.dataset.TagService")
def test_get_dataset_tags_binding_status(
self,
mock_tag_svc: MagicMock,
app: Flask,
account: Account,
tenant: Tenant,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetTagsBindingStatusApi
tag = make_tag(controller_session, tenant, account, id="tag_1", name="Test Tag")
mock_tag_svc.get_tags_by_target_id.return_value = [tag]
with app.test_request_context("/", method="GET"):
api = DatasetTagsBindingStatusApi()
response, status_code = unwrap(api.get)(api, controller_session, tenant.id, dataset_id="dataset_123")
assert status_code == 200
assert response["data"] == [{"id": "tag_1", "name": "Test Tag"}]
assert response["total"] == 1
mock_tag_svc.get_tags_by_target_id.assert_called_once_with(
"knowledge", tenant.id, "dataset_123", controller_session
)
class TestDatasetTagBindingApiPost:
"""Test suite for DatasetTagBindingApi.post() endpoint."""
@pytest.mark.usefixtures("account")
@patch("controllers.service_api.dataset.dataset.TagService")
def test_bind_tags_success(
self,
mock_tag_svc: MagicMock,
app: Flask,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetTagBindingApi
mock_tag_svc.save_tag_binding.return_value = None
with app.test_request_context(
"/datasets/tags/binding",
method="POST",
json={"tag_ids": ["tag-1"], "target_id": "ds-1"},
):
api = DatasetTagBindingApi()
result = unwrap(api.post)(api, controller_session, _=None)
assert result == ("", 204)
from services.tag_service import TagBindingCreatePayload
mock_tag_svc.save_tag_binding.assert_called_once_with(
TagBindingCreatePayload(tag_ids=["tag-1"], target_id="ds-1", type=TagType.KNOWLEDGE),
controller_session,
)
def test_bind_tags_forbidden(self, app: Flask, account: Account) -> None:
from controllers.service_api.dataset.dataset import DatasetTagBindingApi
account.role = TenantAccountRole.NORMAL
with app.test_request_context(
"/datasets/tags/binding",
method="POST",
json={"tag_ids": ["tag-1"], "target_id": "ds-1"},
):
api = DatasetTagBindingApi()
with pytest.raises(Forbidden):
api.post(_=None)
class TestDatasetTagUnbindingApiPost:
"""Test suite for DatasetTagUnbindingApi.post() endpoint."""
@pytest.mark.usefixtures("account")
@patch("controllers.service_api.dataset.dataset.TagService")
def test_unbind_tag_success(
self,
mock_tag_svc: MagicMock,
app: Flask,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetTagUnbindingApi
mock_tag_svc.delete_tag_binding.return_value = None
with app.test_request_context(
"/datasets/tags/unbinding",
method="POST",
json={"tag_ids": ["tag-1"], "target_id": "ds-1"},
):
api = DatasetTagUnbindingApi()
result = unwrap(api.post)(api, controller_session, _=None)
assert result == ("", 204)
from services.tag_service import TagBindingDeletePayload
mock_tag_svc.delete_tag_binding.assert_called_once_with(
TagBindingDeletePayload(tag_ids=["tag-1"], target_id="ds-1", type=TagType.KNOWLEDGE),
controller_session,
)
@pytest.mark.usefixtures("account")
@patch("controllers.service_api.dataset.dataset.TagService")
def test_unbind_legacy_tag_id_success(
self,
mock_tag_svc: MagicMock,
app: Flask,
controller_session: Session,
) -> None:
from controllers.service_api.dataset.dataset import DatasetTagUnbindingApi
mock_tag_svc.delete_tag_binding.return_value = None
with app.test_request_context(
"/datasets/tags/unbinding",
method="POST",
json={"tag_id": "tag-1", "target_id": "ds-1"},
):
api = DatasetTagUnbindingApi()
result = unwrap(api.post)(api, controller_session, _=None)
assert result == ("", 204)
from services.tag_service import TagBindingDeletePayload
mock_tag_svc.delete_tag_binding.assert_called_once_with(
TagBindingDeletePayload(tag_ids=["tag-1"], target_id="ds-1", type=TagType.KNOWLEDGE),
controller_session,
)
def test_unbind_tag_forbidden(self, app: Flask, account: Account) -> None:
from controllers.service_api.dataset.dataset import DatasetTagUnbindingApi
account.role = TenantAccountRole.NORMAL
with app.test_request_context(
"/datasets/tags/unbinding",
method="POST",
json={"tag_ids": ["tag-1"], "target_id": "ds-1"},
):
api = DatasetTagUnbindingApi()
with pytest.raises(Forbidden):
api.post(_=None)

View File

@ -3,6 +3,7 @@ from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from sqlalchemy.orm import Session
from core.app.apps.common import workflow_response_converter
from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
@ -24,27 +25,7 @@ from graphon.entities.pause_reason import HitlRequired
from graphon.graph_events import GraphRunPausedEvent
from graphon.runtime import GraphRuntimeState, VariablePool
from models.account import Account
from models.human_input import RecipientType
class _FakeSession:
"""Stub session: `execute` feeds the form-expiration query, `scalars` the recipients."""
def __init__(self, *, execute_rows=(), scalars_rows=()):
self._execute_rows = execute_rows
self._scalars_rows = scalars_rows
def execute(self, _stmt):
return list(self._execute_rows)
def scalars(self, _stmt):
return list(self._scalars_rows)
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
from models.human_input import HumanInputForm, HumanInputFormRecipient, RecipientType
class _RecordingWorkflowAppRunner(WorkflowAppRunner):
@ -63,6 +44,46 @@ class _FakeRuntimeState:
return ["node-pause-1"]
@pytest.fixture
def sqlite_pause_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Session:
"""Bind pause-response queries to the shared SQLite session's database."""
monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=sqlite_session.get_bind()))
return sqlite_session
def _persist_human_input_form(
session: Session,
*,
recipients: list[tuple[RecipientType, str]] | None = None,
) -> datetime:
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
form = HumanInputForm(
id="form-1",
tenant_id="tenant-id",
app_id="app-id",
workflow_run_id="run-id",
node_id="node-id",
form_definition='{"display_in_ui": true}',
rendered_content="Rendered",
expiration_time=expiration_time,
)
recipient_models = [
HumanInputFormRecipient(
id=f"recipient-{index}",
form_id=form.id,
delivery_id=f"delivery-{index}",
recipient_type=recipient_type,
recipient_payload="{}",
access_token=access_token,
)
for index, (recipient_type, access_token) in enumerate(recipients or ())
]
session.add(form)
session.add_all(recipient_models)
session.commit()
return expiration_time
def _build_runner():
app_entity = SimpleNamespace(
app_config=SimpleNamespace(app_id="app-id"),
@ -154,7 +175,12 @@ def _build_converter(*, invoke_from: InvokeFrom = InvokeFrom.SERVICE_API):
)
def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize(
"sqlite_session",
[(HumanInputForm, HumanInputFormRecipient)],
indirect=True,
)
def test_queue_workflow_paused_event_to_stream_responses(sqlite_pause_session: Session):
converter = _build_converter()
converter.workflow_start_to_stream_response(
task_id="task",
@ -163,18 +189,14 @@ def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.Mon
reason=WorkflowStartReason.INITIAL,
)
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
session = _FakeSession(
execute_rows=[("form-1", expiration_time, '{"display_in_ui": true}')],
scalars_rows=[
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.CONSOLE, access_token="console-token"),
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
expiration_time = _persist_human_input_form(
sqlite_pause_session,
recipients=[
(RecipientType.CONSOLE, "console-token"),
(RecipientType.BACKSTAGE, "backstage-token"),
],
)
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: session)
monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object()))
reason = HumanInputRequired(
form_id="form-1",
form_content="Rendered",
@ -216,8 +238,11 @@ def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.Mon
assert hi_resp.data.expiration_time == int(expiration_time.timestamp())
def _build_paused_human_input_response(monkeypatch, recipients):
"""Drive the live OPENAPI pause path with the given recipients via a fake session."""
def _build_paused_human_input_response(
session: Session,
recipients: list[tuple[RecipientType, str]],
):
"""Drive the live OPENAPI pause path with persisted forms and recipients."""
converter = _build_converter(invoke_from=InvokeFrom.OPENAPI)
converter.workflow_start_to_stream_response(
task_id="task",
@ -226,14 +251,7 @@ def _build_paused_human_input_response(monkeypatch, recipients):
reason=WorkflowStartReason.INITIAL,
)
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
session = _FakeSession(
execute_rows=[("form-1", expiration_time, '{"display_in_ui": true}')],
scalars_rows=list(recipients),
)
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: session)
monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object()))
_persist_human_input_form(session, recipients=recipients)
reason = HumanInputRequired(
form_id="form-1",
@ -259,12 +277,17 @@ def _build_paused_human_input_response(monkeypatch, recipients):
return responses
def test_openapi_pause_without_web_app_recipient_emits_approval_channels(monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize(
"sqlite_session",
[(HumanInputForm, HumanInputFormRecipient)],
indirect=True,
)
def test_openapi_pause_without_web_app_recipient_emits_approval_channels(sqlite_pause_session: Session):
responses = _build_paused_human_input_response(
monkeypatch,
sqlite_pause_session,
recipients=[
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.EMAIL_MEMBER, access_token="email-token"),
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
(RecipientType.EMAIL_MEMBER, "email-token"),
(RecipientType.BACKSTAGE, "backstage-token"),
],
)
@ -276,16 +299,17 @@ def test_openapi_pause_without_web_app_recipient_emits_approval_channels(monkeyp
assert pause_resp.data.reasons[0]["approval_channels"] == ["console", "email"]
def test_openapi_pause_with_web_app_recipient_sets_token_and_channels(monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize(
"sqlite_session",
[(HumanInputForm, HumanInputFormRecipient)],
indirect=True,
)
def test_openapi_pause_with_web_app_recipient_sets_token_and_channels(sqlite_pause_session: Session):
responses = _build_paused_human_input_response(
monkeypatch,
sqlite_pause_session,
recipients=[
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.STANDALONE_WEB_APP,
access_token="web-app-token",
),
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
(RecipientType.STANDALONE_WEB_APP, "web-app-token"),
(RecipientType.BACKSTAGE, "backstage-token"),
],
)
@ -297,7 +321,12 @@ def test_openapi_pause_with_web_app_recipient_sets_token_and_channels(monkeypatc
assert pause_resp.data.reasons[0]["approval_channels"] == ["console"]
def test_queue_workflow_paused_event_resolves_variable_select_options(monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize(
"sqlite_session",
[(HumanInputForm, HumanInputFormRecipient)],
indirect=True,
)
def test_queue_workflow_paused_event_resolves_variable_select_options(sqlite_pause_session: Session):
converter = _build_converter()
converter.workflow_start_to_stream_response(
task_id="task",
@ -306,11 +335,7 @@ def test_queue_workflow_paused_event_resolves_variable_select_options(monkeypatc
reason=WorkflowStartReason.INITIAL,
)
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
session = _FakeSession(execute_rows=[("form-1", expiration_time, '{"display_in_ui": true}')])
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: session)
monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object()))
_persist_human_input_form(sqlite_pause_session)
reason = HumanInputRequired(
form_id="form-1",

View File

@ -3,19 +3,85 @@ from __future__ import annotations
import base64
import hashlib
import hmac
from collections.abc import Iterator
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from urllib.parse import parse_qs, urlparse
import pytest
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from core.app.file_access import DatabaseFileAccessController, FileAccessScope
from core.app.workflow import file_runtime
from core.app.workflow.file_runtime import DifyWorkflowFileRuntime, bind_dify_workflow_file_runtime
from core.workflow.file_reference import build_file_reference
from extensions.storage.storage_type import StorageType
from graphon.file import File, FileTransferMethod, FileType
from models import ToolFile, UploadFile
from models.base import TypeBase
from models.enums import CreatorUserRole
@pytest.fixture
def file_session(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]:
"""Bind runtime-owned sessions to SQLite with only the two file tables present."""
tables = [TypeBase.metadata.tables[model.__tablename__] for model in (UploadFile, ToolFile)]
TypeBase.metadata.create_all(sqlite_engine, tables=tables)
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
monkeypatch.setattr(file_runtime.session_factory, "create_session", session_maker)
with session_maker() as session:
yield session
def _persist_upload_file(
session: Session,
*,
file_id: str = "upload-file-id",
key: str = "canonical-storage-key",
tenant_id: str = "tenant-id",
created_by: str = "end-user-id",
) -> UploadFile:
upload_file = UploadFile(
tenant_id=tenant_id,
storage_type=StorageType.LOCAL,
key=key,
name="diagram.png",
size=128,
extension="png",
mime_type="image/png",
created_by_role=CreatorUserRole.END_USER,
created_by=created_by,
created_at=datetime(2024, 1, 1, tzinfo=UTC),
used=False,
)
upload_file.id = file_id
session.add(upload_file)
session.commit()
return upload_file
def _persist_tool_file(
session: Session,
*,
file_id: str = "tool-file-id",
key: str = "tool-storage-key",
) -> ToolFile:
tool_file = ToolFile(
user_id="end-user-id",
tenant_id="tenant-id",
conversation_id=None,
file_key=key,
mimetype="image/png",
name="diagram.png",
size=128,
)
tool_file.id = file_id
session.add(tool_file)
session.commit()
return tool_file
def _build_file(
@ -164,56 +230,37 @@ def test_verify_preview_signature_validates_signature_and_expiration(monkeypatch
)
def test_load_file_bytes_returns_bytes_and_rejects_non_bytes(monkeypatch: pytest.MonkeyPatch) -> None:
def test_load_file_bytes_returns_bytes_and_rejects_non_bytes(
monkeypatch: pytest.MonkeyPatch, file_session: Session
) -> None:
runtime = _build_runtime()
file = _build_file(
transfer_method=FileTransferMethod.LOCAL_FILE,
reference=build_file_reference(record_id="upload-file-id"),
)
session = MagicMock()
session.get.return_value = SimpleNamespace(key="canonical-storage-key")
class _SessionContext:
def __enter__(self):
return session
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr(file_runtime.session_factory, "create_session", lambda: _SessionContext())
_persist_upload_file(file_session)
monkeypatch.setattr(file_runtime.storage, "load", lambda *args, **kwargs: b"image-bytes")
assert runtime.load_file_bytes(file=file) == b"image-bytes"
session.get.assert_called_with(UploadFile, "upload-file-id")
monkeypatch.setattr(file_runtime.storage, "load", lambda *args, **kwargs: "not-bytes")
with pytest.raises(ValueError, match="is not a bytes object"):
runtime.load_file_bytes(file=file)
def test_resolve_storage_key_ignores_encoded_reference_when_unscoped(monkeypatch: pytest.MonkeyPatch) -> None:
def test_resolve_storage_key_ignores_encoded_reference_when_unscoped(file_session: Session) -> None:
runtime = _build_runtime()
file = _build_file(
transfer_method=FileTransferMethod.LOCAL_FILE,
reference=build_file_reference(record_id="upload-file-id", storage_key="tampered-storage-key"),
)
session = MagicMock()
session.get.return_value = SimpleNamespace(key="canonical-storage-key")
class _SessionContext:
def __enter__(self):
return session
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr(file_runtime.session_factory, "create_session", lambda: _SessionContext())
_persist_upload_file(file_session)
assert runtime._resolve_storage_key(file=file) == "canonical-storage-key"
session.get.assert_called_once_with(UploadFile, "upload-file-id")
def test_resolve_storage_key_uses_canonical_record_when_scope_is_bound(monkeypatch: pytest.MonkeyPatch) -> None:
def test_resolve_storage_key_uses_canonical_record_when_scope_is_bound(file_session: Session) -> None:
upload_file = _persist_upload_file(file_session)
controller = MagicMock()
controller.current_scope.return_value = FileAccessScope(
tenant_id="tenant-id",
@ -221,28 +268,19 @@ def test_resolve_storage_key_uses_canonical_record_when_scope_is_bound(monkeypat
user_from=UserFrom.END_USER,
invoke_from=InvokeFrom.WEB_APP,
)
controller.get_upload_file.return_value = SimpleNamespace(key="canonical-storage-key")
controller.get_upload_file.return_value = upload_file
runtime = DifyWorkflowFileRuntime(file_access_controller=controller)
file = _build_file(
transfer_method=FileTransferMethod.LOCAL_FILE,
reference=build_file_reference(record_id="upload-file-id", storage_key="tampered-storage-key"),
)
session = MagicMock()
class _SessionContext:
def __enter__(self):
return session
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr(file_runtime.session_factory, "create_session", lambda: _SessionContext())
assert runtime._resolve_storage_key(file=file) == "canonical-storage-key"
controller.get_upload_file.assert_called_once_with(session=session, file_id="upload-file-id")
controller.get_upload_file.assert_called_once()
assert isinstance(controller.get_upload_file.call_args.kwargs["session"], Session)
assert controller.get_upload_file.call_args.kwargs["file_id"] == "upload-file-id"
def test_resolve_upload_file_url_rejects_unauthorized_scoped_access(monkeypatch: pytest.MonkeyPatch) -> None:
def test_resolve_upload_file_url_rejects_unauthorized_scoped_access(file_session: Session) -> None:
controller = MagicMock()
controller.current_scope.return_value = FileAccessScope(
tenant_id="tenant-id",
@ -252,17 +290,6 @@ def test_resolve_upload_file_url_rejects_unauthorized_scoped_access(monkeypatch:
)
controller.get_upload_file.return_value = None
runtime = DifyWorkflowFileRuntime(file_access_controller=controller)
session = MagicMock()
class _SessionContext:
def __enter__(self):
return session
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr(file_runtime.session_factory, "create_session", lambda: _SessionContext())
with pytest.raises(ValueError, match="Upload file upload-file-id not found"):
runtime.resolve_upload_file_url(upload_file_id="upload-file-id")
@ -276,7 +303,7 @@ def test_resolve_upload_file_url_rejects_unauthorized_scoped_access(monkeypatch:
],
)
def test_resolve_storage_key_loads_database_records(
monkeypatch: pytest.MonkeyPatch,
file_session: Session,
transfer_method: FileTransferMethod,
record_id: str,
expected_storage_key: str,
@ -287,25 +314,10 @@ def test_resolve_storage_key_loads_database_records(
reference=build_file_reference(record_id=record_id),
extension=".png",
)
session = MagicMock()
def get(model_class, value):
if transfer_method in {FileTransferMethod.LOCAL_FILE, FileTransferMethod.DATASOURCE_FILE}:
assert model_class is UploadFile
return SimpleNamespace(key="upload-storage-key")
assert model_class is ToolFile
return SimpleNamespace(file_key="tool-storage-key")
session.get.side_effect = get
class _SessionContext:
def __enter__(self):
return session
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr(file_runtime.session_factory, "create_session", lambda: _SessionContext())
if transfer_method in {FileTransferMethod.LOCAL_FILE, FileTransferMethod.DATASOURCE_FILE}:
_persist_upload_file(file_session, key="upload-storage-key")
else:
_persist_tool_file(file_session)
assert runtime._resolve_storage_key(file=file) == expected_storage_key
@ -318,7 +330,7 @@ def test_resolve_storage_key_loads_database_records(
],
)
def test_resolve_storage_key_raises_when_records_are_missing(
monkeypatch: pytest.MonkeyPatch,
file_session: Session,
transfer_method: FileTransferMethod,
expected_message: str,
) -> None:
@ -329,18 +341,6 @@ def test_resolve_storage_key_raises_when_records_are_missing(
reference=build_file_reference(record_id=record_id),
extension=".png",
)
session = MagicMock()
session.get.return_value = None
class _SessionContext:
def __enter__(self):
return session
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr(file_runtime.session_factory, "create_session", lambda: _SessionContext())
with pytest.raises(ValueError, match=expected_message):
runtime._resolve_storage_key(file=file)

View File

@ -1,83 +1,68 @@
from types import SimpleNamespace
from dataclasses import dataclass
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session
import core.rag.extractor.pdf_extractor as pe
from models.model import UploadFile
TENANT_ID = str(uuid4())
USER_ID = str(uuid4())
class _Storage:
saves: list[tuple[str, bytes]]
def __init__(self) -> None:
self.saves = []
def save(self, key: str, data: bytes) -> None:
self.saves.append((key, data))
class _DatabaseBinding:
session: Session
def __init__(self, session: Session) -> None:
self.session = session
@dataclass(frozen=True)
class _Dependencies:
storage: _Storage
session: Session
@pytest.fixture
def mock_dependencies(monkeypatch: pytest.MonkeyPatch):
# Mock storage
saves = []
def save(key, data):
saves.append((key, data))
monkeypatch.setattr(pe, "storage", SimpleNamespace(save=save))
# Mock db
class DummySession:
def __init__(self):
self.added = []
self.committed = False
def add(self, obj):
self.added.append(obj)
def add_all(self, objs):
self.added.extend(objs)
def commit(self):
self.committed = True
db_stub = SimpleNamespace(session=DummySession())
monkeypatch.setattr(pe, "db", db_stub)
# Mock UploadFile
class FakeUploadFile:
DEFAULT_ID = "test_file_id"
def __init__(self, **kwargs):
# Assign id from DEFAULT_ID, allow override via kwargs if needed
self.id = self.DEFAULT_ID
for k, v in kwargs.items():
setattr(self, k, v)
monkeypatch.setattr(pe, "UploadFile", FakeUploadFile)
# Mock config
def mock_dependencies(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> _Dependencies:
storage = _Storage()
monkeypatch.setattr(pe, "storage", storage)
monkeypatch.setattr(pe, "db", _DatabaseBinding(sqlite_session))
monkeypatch.setattr(pe.dify_config, "FILES_URL", "http://files.local")
monkeypatch.setattr(pe.dify_config, "INTERNAL_FILES_URL", None)
monkeypatch.setattr(pe.dify_config, "STORAGE_TYPE", "local")
return SimpleNamespace(saves=saves, db=db_stub, UploadFile=FakeUploadFile)
return _Dependencies(storage=storage, session=sqlite_session)
@pytest.mark.parametrize(
("image_bytes", "expected_mime", "expected_ext", "file_id"),
("image_bytes", "expected_mime", "expected_ext"),
[
(b"\xff\xd8\xff some jpeg", "image/jpeg", "jpg", "test_file_id_jpeg"),
(b"\x89PNG\r\n\x1a\n some png", "image/png", "png", "test_file_id_png"),
(b"\xff\xd8\xff some jpeg", "image/jpeg", "jpg"),
(b"\x89PNG\r\n\x1a\n some png", "image/png", "png"),
],
)
@pytest.mark.parametrize("sqlite_session", [(UploadFile,)], indirect=True)
@pytest.mark.parametrize("inject_session", [False, True])
def test_extract_images_formats(
mock_dependencies,
monkeypatch: pytest.MonkeyPatch,
image_bytes,
expected_mime,
expected_ext,
file_id,
mock_dependencies: _Dependencies,
image_bytes: bytes,
expected_mime: str,
expected_ext: str,
inject_session: bool,
):
saves = mock_dependencies.saves
db_stub = mock_dependencies.db
# Customize FakeUploadFile id for this test case.
# Using monkeypatch ensures the class attribute is reset between parameter sets.
monkeypatch.setattr(mock_dependencies.UploadFile, "DEFAULT_ID", file_id)
# Mock page and image objects
mock_page = MagicMock()
mock_image_obj = MagicMock()
@ -91,25 +76,32 @@ def test_extract_images_formats(
extractor = pe.PdfExtractor(
file_path="test.pdf",
tenant_id="t1",
user_id="u1",
session=db_stub.session if inject_session else None,
tenant_id=TENANT_ID,
user_id=USER_ID,
session=mock_dependencies.session if inject_session else None,
)
# We need to handle the import inside _extract_images
with patch("pypdfium2.raw", autospec=True) as mock_raw:
with (
patch("pypdfium2.raw", autospec=True) as mock_raw,
patch.object(
mock_dependencies.session,
"commit",
wraps=mock_dependencies.session.commit,
) as commit,
):
mock_raw.FPDF_PAGEOBJ_IMAGE = 1
result = extractor._extract_images(mock_page)
assert f"![image](http://files.local/files/{file_id}/file-preview)" in result
assert len(saves) == 1
assert saves[0][1] == image_bytes
assert len(db_stub.session.added) == 1
assert db_stub.session.added[0].tenant_id == "t1"
assert db_stub.session.added[0].size == len(image_bytes)
assert db_stub.session.added[0].mime_type == expected_mime
assert db_stub.session.added[0].extension == expected_ext
assert db_stub.session.committed is not inject_session
assert commit.called is not inject_session
upload_file = mock_dependencies.session.scalar(select(UploadFile))
assert upload_file is not None
assert f"![image](http://files.local/files/{upload_file.id}/file-preview)" in result
assert mock_dependencies.storage.saves == [(upload_file.key, image_bytes)]
assert upload_file.tenant_id == TENANT_ID
assert upload_file.size == len(image_bytes)
assert upload_file.mime_type == expected_mime
assert upload_file.extension == expected_ext
@pytest.mark.parametrize(
@ -120,14 +112,17 @@ def test_extract_images_formats(
(Exception("Failed to get objects"), None), # Exception raised
],
)
def test_extract_images_get_objects_scenarios(mock_dependencies, get_objects_side_effect, get_objects_return_value):
@pytest.mark.parametrize("sqlite_session", [(UploadFile,)], indirect=True)
def test_extract_images_get_objects_scenarios(
mock_dependencies: _Dependencies, get_objects_side_effect, get_objects_return_value
):
mock_page = MagicMock()
if get_objects_side_effect:
mock_page.get_objects.side_effect = get_objects_side_effect
else:
mock_page.get_objects.return_value = get_objects_return_value
extractor = pe.PdfExtractor(file_path="test.pdf", tenant_id="t1", user_id="u1")
extractor = pe.PdfExtractor(file_path="test.pdf", tenant_id=TENANT_ID, user_id=USER_ID)
with patch("pypdfium2.raw", autospec=True) as mock_raw:
mock_raw.FPDF_PAGEOBJ_IMAGE = 1
@ -136,7 +131,8 @@ def test_extract_images_get_objects_scenarios(mock_dependencies, get_objects_sid
assert result == ""
def test_extract_calls_extract_images(mock_dependencies, monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize("sqlite_session", [(UploadFile,)], indirect=True)
def test_extract_calls_extract_images(mock_dependencies: _Dependencies, monkeypatch: pytest.MonkeyPatch):
# Mock pypdfium2
mock_pdf_doc = MagicMock()
mock_page = MagicMock()
@ -152,7 +148,7 @@ def test_extract_calls_extract_images(mock_dependencies, monkeypatch: pytest.Mon
mock_blob = MagicMock()
mock_blob.source = "test.pdf"
with patch("core.rag.extractor.pdf_extractor.Blob.from_path", return_value=mock_blob, autospec=True):
extractor = pe.PdfExtractor(file_path="test.pdf", tenant_id="t1", user_id="u1")
extractor = pe.PdfExtractor(file_path="test.pdf", tenant_id=TENANT_ID, user_id=USER_ID)
# Mock _extract_images to return a known string
monkeypatch.setattr(extractor, "_extract_images", lambda p: "![image](img_url)")
@ -165,10 +161,8 @@ def test_extract_calls_extract_images(mock_dependencies, monkeypatch: pytest.Mon
assert documents[0].metadata["page"] == 0
def test_extract_images_failures(mock_dependencies):
saves = mock_dependencies.saves
db_stub = mock_dependencies.db
@pytest.mark.parametrize("sqlite_session", [(UploadFile,)], indirect=True)
def test_extract_images_failures(mock_dependencies: _Dependencies):
# Mock page and image objects
mock_page = MagicMock()
mock_image_obj_fail = MagicMock()
@ -187,14 +181,14 @@ def test_extract_images_failures(mock_dependencies):
mock_page.get_objects.return_value = [mock_image_obj_fail, mock_image_obj_ok]
extractor = pe.PdfExtractor(file_path="test.pdf", tenant_id="t1", user_id="u1")
extractor = pe.PdfExtractor(file_path="test.pdf", tenant_id=TENANT_ID, user_id=USER_ID)
with patch("pypdfium2.raw", autospec=True) as mock_raw:
mock_raw.FPDF_PAGEOBJ_IMAGE = 1
result = extractor._extract_images(mock_page)
# Should have one success
assert "![image](http://files.local/files/test_file_id/file-preview)" in result
assert len(saves) == 1
assert saves[0][1] == jpeg_bytes
assert db_stub.session.committed is True
upload_file = mock_dependencies.session.scalar(select(UploadFile))
assert upload_file is not None
assert f"![image](http://files.local/files/{upload_file.id}/file-preview)" in result
assert mock_dependencies.storage.saves == [(upload_file.key, jpeg_bytes)]

View File

@ -1,56 +1,59 @@
import json
from datetime import UTC, datetime
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import Session, sessionmaker
from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository
from graphon.entities import WorkflowExecution
from graphon.enums import WorkflowExecutionStatus, WorkflowType
from models import Account, CreatorUserRole, EndUser, WorkflowRun
from models.enums import WorkflowRunTriggeredFrom
from models import Account, CreatorUserRole, EndUser, Tenant, WorkflowRun
from models.enums import EndUserType, WorkflowRunTriggeredFrom
from models.workflow import WorkflowType as ModelWorkflowType
TABLES = (WorkflowRun,)
RESOURCE_TENANT_ID = "resource-tenant-id"
@pytest.fixture
def mock_session_factory():
"""Mock SQLAlchemy session factory."""
session_factory = MagicMock(spec=sessionmaker)
session = MagicMock()
session.get.return_value = None
session_factory.return_value.__enter__.return_value = session
return session_factory
@pytest.fixture
def mock_engine():
"""Mock SQLAlchemy Engine."""
return MagicMock(spec=Engine)
@pytest.fixture
def mock_account():
"""Mock Account user."""
account = MagicMock(spec=Account)
def _make_account(*, tenant_id: str | None = None) -> Account:
account = Account(name="Repository User", email=f"{uuid4()}@example.com")
account.id = str(uuid4())
account.current_tenant_id = str(uuid4())
if tenant_id is not None:
tenant = Tenant(name="Repository Tenant")
tenant.id = tenant_id
account._current_tenant = tenant
return account
@pytest.fixture
def mock_end_user():
"""Mock EndUser."""
user = MagicMock(spec=EndUser)
user.id = str(uuid4())
user.tenant_id = str(uuid4())
return user
def sqlite_session_factory(sqlite_engine: Engine) -> sessionmaker[Session]:
"""Create repository-owned sessions bound to the isolated SQLite engine."""
return sessionmaker(bind=sqlite_engine, expire_on_commit=False)
@pytest.fixture
def sample_workflow_execution():
def account() -> Account:
return _make_account(tenant_id=str(uuid4()))
@pytest.fixture
def end_user() -> EndUser:
return EndUser(
id=str(uuid4()),
tenant_id=str(uuid4()),
app_id=None,
type=EndUserType.SERVICE_API,
external_user_id=None,
name="Repository End User",
session_id=str(uuid4()),
)
@pytest.fixture
def sample_workflow_execution() -> WorkflowExecution:
"""Sample WorkflowExecution for testing."""
return WorkflowExecution(
id_=str(uuid4()),
@ -71,125 +74,147 @@ def sample_workflow_execution():
class TestSQLAlchemyWorkflowExecutionRepository:
def test_init_with_sessionmaker(self, mock_session_factory, mock_account):
def test_init_with_sessionmaker(self, sqlite_session_factory: sessionmaker[Session], account: Account):
app_id = "test_app_id"
triggered_from = WorkflowRunTriggeredFrom.APP_RUN
repo = SQLAlchemyWorkflowExecutionRepository(
session_factory=mock_session_factory,
session_factory=sqlite_session_factory,
tenant_id=RESOURCE_TENANT_ID,
user=mock_account,
user=account,
app_id=app_id,
triggered_from=triggered_from,
)
assert repo._session_factory == mock_session_factory
assert repo._session_factory is sqlite_session_factory
assert repo._tenant_id == RESOURCE_TENANT_ID
assert repo._app_id == app_id
assert repo._triggered_from == triggered_from
assert repo._creator_user_id == mock_account.id
assert repo._creator_user_id == account.id
assert repo._creator_user_role == CreatorUserRole.ACCOUNT
def test_init_with_engine(self, mock_engine, mock_account):
def test_init_with_engine(self, sqlite_engine: Engine, account: Account):
repo = SQLAlchemyWorkflowExecutionRepository(
session_factory=mock_engine,
session_factory=sqlite_engine,
tenant_id=RESOURCE_TENANT_ID,
user=mock_account,
user=account,
app_id="test_app_id",
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
)
assert isinstance(repo._session_factory, sessionmaker)
assert repo._session_factory.kw["bind"] == mock_engine
assert repo._session_factory.kw["bind"] is sqlite_engine
def test_init_invalid_session_factory(self, mock_account):
def test_init_invalid_session_factory(self, account: Account):
with pytest.raises(ValueError, match="Invalid session_factory type"):
SQLAlchemyWorkflowExecutionRepository(
session_factory="invalid",
tenant_id=RESOURCE_TENANT_ID,
user=mock_account,
user=account,
app_id=None,
triggered_from=None,
)
def test_init_no_tenant_id(self, mock_session_factory):
user = MagicMock(spec=Account)
user.current_tenant_id = None
def test_init_no_tenant_id(self, sqlite_session_factory: sessionmaker[Session]):
user = _make_account()
with pytest.raises(ValueError, match="tenant_id is required"):
SQLAlchemyWorkflowExecutionRepository(
session_factory=mock_session_factory,
session_factory=sqlite_session_factory,
tenant_id="",
user=user,
app_id=None,
triggered_from=None,
)
def test_init_uses_resource_tenant_when_account_has_no_current_tenant(self, mock_session_factory):
user = MagicMock(spec=Account)
user.current_tenant_id = None
user.id = str(uuid4())
def test_init_uses_resource_tenant_when_account_has_no_current_tenant(
self, sqlite_session_factory: sessionmaker[Session]
):
user = _make_account()
repo = SQLAlchemyWorkflowExecutionRepository(
session_factory=mock_session_factory,
tenant_id="resource-tenant-id",
session_factory=sqlite_session_factory,
tenant_id=RESOURCE_TENANT_ID,
user=user,
app_id="test-app",
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
)
assert repo._tenant_id == "resource-tenant-id"
assert repo._tenant_id == RESOURCE_TENANT_ID
assert repo._creator_user_id == user.id
def test_init_with_end_user(self, mock_session_factory, mock_end_user):
def test_init_with_end_user(self, sqlite_session_factory: sessionmaker[Session], end_user: EndUser):
repo = SQLAlchemyWorkflowExecutionRepository(
session_factory=mock_session_factory,
session_factory=sqlite_session_factory,
tenant_id=RESOURCE_TENANT_ID,
user=mock_end_user,
user=end_user,
app_id=None,
triggered_from=None,
)
assert repo._tenant_id == RESOURCE_TENANT_ID
assert repo._creator_user_role == CreatorUserRole.END_USER
def test_to_domain_model(self, mock_session_factory, mock_account):
@pytest.mark.parametrize("sqlite_session", [TABLES], indirect=True)
def test_to_domain_model(
self,
sqlite_session_factory: sessionmaker[Session],
sqlite_session: Session,
account: Account,
):
repo = SQLAlchemyWorkflowExecutionRepository(
session_factory=mock_session_factory,
session_factory=sqlite_session_factory,
tenant_id=RESOURCE_TENANT_ID,
user=mock_account,
user=account,
app_id=None,
triggered_from=None,
)
db_model = MagicMock(spec=WorkflowRun)
db_model.id = str(uuid4())
db_model.workflow_id = str(uuid4())
db_model.type = "workflow"
db_model.version = "1.0"
db_model.inputs_dict = {"in": "val"}
db_model.outputs_dict = {"out": "val"}
db_model.graph_dict = {"nodes": []}
db_model.status = "succeeded"
db_model.error = "some error"
db_model.total_tokens = 50
db_model.total_steps = 3
db_model.exceptions_count = 1
db_model.created_at = datetime.now(UTC)
db_model.finished_at = datetime.now(UTC)
db_model = WorkflowRun(
id=str(uuid4()),
tenant_id=account.current_tenant_id,
app_id=str(uuid4()),
workflow_id=str(uuid4()),
type=ModelWorkflowType.WORKFLOW,
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
version="1.0",
inputs=json.dumps({"in": "val"}),
outputs=json.dumps({"out": "val"}),
graph=json.dumps({"nodes": []}),
status=WorkflowExecutionStatus.SUCCEEDED,
error="some error",
elapsed_time=1.0,
total_tokens=50,
total_steps=3,
exceptions_count=1,
created_by_role=CreatorUserRole.ACCOUNT,
created_by=account.id,
created_at=datetime.now(UTC),
finished_at=datetime.now(UTC),
)
sqlite_session.add(db_model)
sqlite_session.commit()
sqlite_session.expunge_all()
persisted_model = sqlite_session.get(WorkflowRun, db_model.id)
assert persisted_model is not None
domain_model = repo._to_domain_model(db_model)
domain_model = repo._to_domain_model(persisted_model)
assert domain_model.id_ == db_model.id
assert domain_model.workflow_id == db_model.workflow_id
assert domain_model.id_ == persisted_model.id
assert domain_model.workflow_id == persisted_model.workflow_id
assert domain_model.status == WorkflowExecutionStatus.SUCCEEDED
assert domain_model.inputs == db_model.inputs_dict
assert domain_model.inputs == {"in": "val"}
assert domain_model.error_message == "some error"
def test_to_db_model(self, mock_session_factory, mock_account, sample_workflow_execution):
def test_to_db_model(
self,
sqlite_session_factory: sessionmaker[Session],
account: Account,
sample_workflow_execution: WorkflowExecution,
):
repo = SQLAlchemyWorkflowExecutionRepository(
session_factory=mock_session_factory,
session_factory=sqlite_session_factory,
tenant_id=RESOURCE_TENANT_ID,
user=mock_account,
user=account,
app_id="test_app",
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
)
@ -208,11 +233,16 @@ class TestSQLAlchemyWorkflowExecutionRepository:
assert db_model.total_tokens == sample_workflow_execution.total_tokens
assert db_model.elapsed_time == 10.0
def test_to_db_model_edge_cases(self, mock_session_factory, mock_account, sample_workflow_execution):
def test_to_db_model_edge_cases(
self,
sqlite_session_factory: sessionmaker[Session],
account: Account,
sample_workflow_execution: WorkflowExecution,
):
repo = SQLAlchemyWorkflowExecutionRepository(
session_factory=mock_session_factory,
session_factory=sqlite_session_factory,
tenant_id=RESOURCE_TENANT_ID,
user=mock_account,
user=account,
app_id="test_app",
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
)
@ -231,11 +261,16 @@ class TestSQLAlchemyWorkflowExecutionRepository:
assert db_model.error is None
assert db_model.elapsed_time == 0
def test_to_db_model_app_id_none(self, mock_session_factory, mock_account, sample_workflow_execution):
def test_to_db_model_app_id_none(
self,
sqlite_session_factory: sessionmaker[Session],
account: Account,
sample_workflow_execution: WorkflowExecution,
):
repo = SQLAlchemyWorkflowExecutionRepository(
session_factory=mock_session_factory,
session_factory=sqlite_session_factory,
tenant_id=RESOURCE_TENANT_ID,
user=mock_account,
user=account,
app_id=None,
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
)
@ -244,11 +279,16 @@ class TestSQLAlchemyWorkflowExecutionRepository:
assert not hasattr(db_model, "app_id") or db_model.app_id is None
assert db_model.tenant_id == repo._tenant_id
def test_to_db_model_missing_context(self, mock_session_factory, mock_account, sample_workflow_execution):
def test_to_db_model_missing_context(
self,
sqlite_session_factory: sessionmaker[Session],
account: Account,
sample_workflow_execution: WorkflowExecution,
):
repo = SQLAlchemyWorkflowExecutionRepository(
session_factory=mock_session_factory,
session_factory=sqlite_session_factory,
tenant_id=RESOURCE_TENANT_ID,
user=mock_account,
user=account,
app_id=None,
triggered_from=None,
)
@ -267,33 +307,47 @@ class TestSQLAlchemyWorkflowExecutionRepository:
with pytest.raises(ValueError, match="created_by_role is required"):
repo._to_db_model(sample_workflow_execution)
def test_save(self, mock_session_factory, mock_account, sample_workflow_execution):
@pytest.mark.parametrize("sqlite_session", [TABLES], indirect=True)
def test_save(
self,
sqlite_session_factory: sessionmaker[Session],
sqlite_session: Session,
account: Account,
sample_workflow_execution: WorkflowExecution,
):
repo = SQLAlchemyWorkflowExecutionRepository(
session_factory=mock_session_factory,
session_factory=sqlite_session_factory,
tenant_id=RESOURCE_TENANT_ID,
user=mock_account,
user=account,
app_id="test_app",
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
)
repo.save(sample_workflow_execution)
session = mock_session_factory.return_value.__enter__.return_value
session.merge.assert_called_once()
session.commit.assert_called_once()
persisted_model = sqlite_session.get(WorkflowRun, sample_workflow_execution.id_)
assert persisted_model is not None
assert persisted_model.tenant_id == RESOURCE_TENANT_ID
assert persisted_model.inputs_dict == sample_workflow_execution.inputs
assert persisted_model.outputs_dict == sample_workflow_execution.outputs
# Check cache
assert sample_workflow_execution.id_ in repo._execution_cache
cached_model = repo._execution_cache[sample_workflow_execution.id_]
assert cached_model.id == sample_workflow_execution.id_
@pytest.mark.parametrize("sqlite_session", [TABLES], indirect=True)
def test_save_uses_execution_started_at_when_record_does_not_exist(
self, mock_session_factory, mock_account, sample_workflow_execution
self,
sqlite_session_factory: sessionmaker[Session],
sqlite_session: Session,
account: Account,
sample_workflow_execution: WorkflowExecution,
):
repo = SQLAlchemyWorkflowExecutionRepository(
session_factory=mock_session_factory,
session_factory=sqlite_session_factory,
tenant_id=RESOURCE_TENANT_ID,
user=mock_account,
user=account,
app_id="test_app",
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
)
@ -301,41 +355,71 @@ class TestSQLAlchemyWorkflowExecutionRepository:
started_at = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
sample_workflow_execution.started_at = started_at
session = mock_session_factory.return_value.__enter__.return_value
session.get.return_value = None
repo.save(sample_workflow_execution)
saved_model = session.merge.call_args.args[0]
assert saved_model.created_at == started_at
session.commit.assert_called_once()
persisted_model = sqlite_session.get(WorkflowRun, sample_workflow_execution.id_)
assert persisted_model is not None
assert persisted_model.created_at == started_at.replace(tzinfo=None)
@pytest.mark.parametrize("sqlite_session", [TABLES], indirect=True)
def test_save_preserves_existing_created_at_when_record_already_exists(
self, mock_session_factory, mock_account, sample_workflow_execution
self,
sqlite_session_factory: sessionmaker[Session],
sqlite_session: Session,
account: Account,
sample_workflow_execution: WorkflowExecution,
):
repo = SQLAlchemyWorkflowExecutionRepository(
session_factory=mock_session_factory,
session_factory=sqlite_session_factory,
tenant_id=RESOURCE_TENANT_ID,
user=mock_account,
user=account,
app_id="test_app",
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
)
execution_id = sample_workflow_execution.id_
existing_created_at = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
existing_run = WorkflowRun()
existing_run.id = execution_id
existing_run.tenant_id = repo._tenant_id
existing_run.created_at = existing_created_at
session = mock_session_factory.return_value.__enter__.return_value
session.get.return_value = existing_run
sample_workflow_execution.started_at = existing_created_at
repo.save(sample_workflow_execution)
sample_workflow_execution.started_at = datetime(2026, 1, 1, 12, 30, 0, tzinfo=UTC)
repo.save(sample_workflow_execution)
saved_model = session.merge.call_args.args[0]
assert saved_model.created_at == existing_created_at
session.commit.assert_called_once()
persisted_model = sqlite_session.get(WorkflowRun, execution_id)
assert persisted_model is not None
assert persisted_model.created_at == existing_created_at.replace(tzinfo=None)
@pytest.mark.parametrize("sqlite_session", [TABLES], indirect=True)
def test_save_rejects_execution_owned_by_another_tenant(
self,
sqlite_session_factory: sessionmaker[Session],
sqlite_session: Session,
account: Account,
sample_workflow_execution: WorkflowExecution,
):
other_tenant_id = str(uuid4())
other_account = _make_account(tenant_id=str(uuid4()))
other_repo = SQLAlchemyWorkflowExecutionRepository(
session_factory=sqlite_session_factory,
tenant_id=other_tenant_id,
user=other_account,
app_id="test_app",
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
)
other_repo.save(sample_workflow_execution)
repo = SQLAlchemyWorkflowExecutionRepository(
session_factory=sqlite_session_factory,
tenant_id=RESOURCE_TENANT_ID,
user=account,
app_id="test_app",
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
)
with pytest.raises(ValueError, match="Unauthorized access to workflow run"):
repo.save(sample_workflow_execution)
sqlite_session.expire_all()
persisted_model = sqlite_session.get(WorkflowRun, sample_workflow_execution.id_)
assert persisted_model is not None
assert persisted_model.tenant_id == other_tenant_id

View File

@ -4,10 +4,10 @@ import calendar
import math
from datetime import date
from types import SimpleNamespace
from unittest.mock import MagicMock
from zoneinfo import ZoneInfo
import pytest
from sqlalchemy.orm import Session
from core.app.entities.app_invoke_entities import InvokeFrom
from core.tools.__base.tool_runtime import ToolRuntime
@ -51,24 +51,26 @@ def _raise_runtime_error(*_args: object, **_kwargs: object) -> None:
raise RuntimeError("boom")
def test_current_time_tool():
def test_current_time_tool(sqlite_session: Session):
current_tool = _build_builtin_tool(CurrentTimeTool)
utc_text = list(current_tool.invoke(session=MagicMock(), user_id="u", tool_parameters={"timezone": "UTC"}))[
utc_text = list(current_tool.invoke(session=sqlite_session, user_id="u", tool_parameters={"timezone": "UTC"}))[
0
].message.text
assert utc_text
invalid_tz = list(
current_tool.invoke(session=MagicMock(), user_id="u", tool_parameters={"timezone": "Invalid/TZ"})
current_tool.invoke(session=sqlite_session, user_id="u", tool_parameters={"timezone": "Invalid/TZ"})
)[0].message.text
assert "Invalid timezone" in invalid_tz
def test_localtime_to_timestamp_tool():
def test_localtime_to_timestamp_tool(sqlite_session: Session):
localtime_tool = _build_builtin_tool(LocaltimeToTimestampTool)
ts_message = list(
localtime_tool.invoke(
session=MagicMock(), user_id="u", tool_parameters={"localtime": "2024-01-01 10:00:00", "timezone": "UTC"}
session=sqlite_session,
user_id="u",
tool_parameters={"localtime": "2024-01-01 10:00:00", "timezone": "UTC"},
)
)[0].message.text
ts_value = float(ts_message.strip())
@ -92,11 +94,11 @@ def test_localtime_to_timestamp_tool():
LocaltimeToTimestampTool.localtime_to_timestamp("bad", "%Y-%m-%d %H:%M:%S", "UTC")
def test_timestamp_to_localtime_tool():
def test_timestamp_to_localtime_tool(sqlite_session: Session):
to_local_tool = _build_builtin_tool(TimestampToLocaltimeTool)
local_text = list(
to_local_tool.invoke(
session=MagicMock(), user_id="u", tool_parameters={"timestamp": 1704067200, "timezone": "UTC"}
session=sqlite_session, user_id="u", tool_parameters={"timestamp": 1704067200, "timezone": "UTC"}
)
)[0].message.text
assert "2024" in local_text
@ -104,11 +106,11 @@ def test_timestamp_to_localtime_tool():
TimestampToLocaltimeTool.timestamp_to_localtime("bad", "UTC") # type: ignore[arg-type]
def test_timezone_conversion_tool():
def test_timezone_conversion_tool(sqlite_session: Session):
timezone_tool = _build_builtin_tool(TimezoneConversionTool)
converted = list(
timezone_tool.invoke(
session=MagicMock(),
session=sqlite_session,
user_id="u",
tool_parameters={
"current_time": "2024-01-01 08:00:00",
@ -122,10 +124,10 @@ def test_timezone_conversion_tool():
TimezoneConversionTool.timezone_convert("bad", "UTC", "Asia/Tokyo")
def test_weekday_tool():
def test_weekday_tool(sqlite_session: Session):
weekday_tool = _build_builtin_tool(WeekdayTool)
valid = list(
weekday_tool.invoke(session=MagicMock(), user_id="u", tool_parameters={"year": 2024, "month": 1, "day": 1})
weekday_tool.invoke(session=sqlite_session, user_id="u", tool_parameters={"year": 2024, "month": 1, "day": 1})
)[0].message.text
expected_date = date(2024, 1, 1)
expected_message = (
@ -135,14 +137,14 @@ def test_weekday_tool():
)
assert valid == expected_message
invalid = list(
weekday_tool.invoke(session=MagicMock(), user_id="u", tool_parameters={"year": 2024, "month": 2, "day": 31})
weekday_tool.invoke(session=sqlite_session, user_id="u", tool_parameters={"year": 2024, "month": 2, "day": 31})
)[0].message.text
assert "Invalid date" in invalid
with pytest.raises(ValueError, match="Month is required"):
list(weekday_tool.invoke(session=MagicMock(), user_id="u", tool_parameters={"year": 2024, "day": 1}))
list(weekday_tool.invoke(session=sqlite_session, user_id="u", tool_parameters={"year": 2024, "day": 1}))
def test_simple_code_valid_execution(monkeypatch: pytest.MonkeyPatch):
def test_simple_code_valid_execution(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session):
simple_code = _build_builtin_tool(SimpleCode)
monkeypatch.setattr(
@ -151,7 +153,7 @@ def test_simple_code_valid_execution(monkeypatch: pytest.MonkeyPatch):
)
result = list(
simple_code.invoke(
session=MagicMock(),
session=sqlite_session,
user_id="u",
tool_parameters={"language": "python3", "code": "print(1)"},
)
@ -159,18 +161,18 @@ def test_simple_code_valid_execution(monkeypatch: pytest.MonkeyPatch):
assert result == "ok"
def test_simple_code_invalid_language():
def test_simple_code_invalid_language(sqlite_session: Session):
simple_code = _build_builtin_tool(SimpleCode)
with pytest.raises(ValueError, match="Only python3 and javascript"):
list(
simple_code.invoke(
session=MagicMock(), user_id="u", tool_parameters={"language": "go", "code": "fmt.Println(1)"}
session=sqlite_session, user_id="u", tool_parameters={"language": "go", "code": "fmt.Println(1)"}
)
)
def test_simple_code_execution_error(monkeypatch: pytest.MonkeyPatch):
def test_simple_code_execution_error(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session):
simple_code = _build_builtin_tool(SimpleCode)
monkeypatch.setattr(
@ -180,33 +182,35 @@ def test_simple_code_execution_error(monkeypatch: pytest.MonkeyPatch):
with pytest.raises(ToolInvokeError, match="boom"):
list(
simple_code.invoke(
session=MagicMock(), user_id="u", tool_parameters={"language": "python3", "code": "print(1)"}
session=sqlite_session,
user_id="u",
tool_parameters={"language": "python3", "code": "print(1)"},
)
)
def test_webscraper_empty_url():
def test_webscraper_empty_url(sqlite_session: Session):
webscraper = _build_builtin_tool(WebscraperTool)
empty = list(webscraper.invoke(session=MagicMock(), user_id="u", tool_parameters={"url": ""}))[0].message.text
empty = list(webscraper.invoke(session=sqlite_session, user_id="u", tool_parameters={"url": ""}))[0].message.text
assert empty == "Please input url"
def test_webscraper_fetch(monkeypatch: pytest.MonkeyPatch):
def test_webscraper_fetch(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session):
webscraper = _build_builtin_tool(WebscraperTool)
monkeypatch.setattr("core.tools.builtin_tool.providers.webscraper.tools.webscraper.get_url", lambda *a, **k: "page")
full = list(webscraper.invoke(session=MagicMock(), user_id="u", tool_parameters={"url": "https://example.com"}))[
full = list(webscraper.invoke(session=sqlite_session, user_id="u", tool_parameters={"url": "https://example.com"}))[
0
].message.text
assert full == "page"
def test_webscraper_summary(monkeypatch: pytest.MonkeyPatch):
def test_webscraper_summary(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session):
webscraper = _build_builtin_tool(WebscraperTool)
monkeypatch.setattr("core.tools.builtin_tool.providers.webscraper.tools.webscraper.get_url", lambda *a, **k: "page")
monkeypatch.setattr(webscraper, "summary", lambda user_id, content: "summary")
summarized = list(
webscraper.invoke(
session=MagicMock(),
session=sqlite_session,
user_id="u",
tool_parameters={"url": "https://example.com", "generate_summary": True},
)
@ -214,26 +218,26 @@ def test_webscraper_summary(monkeypatch: pytest.MonkeyPatch):
assert summarized == "summary"
def test_webscraper_fetch_error(monkeypatch: pytest.MonkeyPatch):
def test_webscraper_fetch_error(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session):
webscraper = _build_builtin_tool(WebscraperTool)
monkeypatch.setattr(
"core.tools.builtin_tool.providers.webscraper.tools.webscraper.get_url",
_raise_runtime_error,
)
with pytest.raises(ToolInvokeError, match="boom"):
list(webscraper.invoke(session=MagicMock(), user_id="u", tool_parameters={"url": "https://example.com"}))
list(webscraper.invoke(session=sqlite_session, user_id="u", tool_parameters={"url": "https://example.com"}))
def test_asr_invalid_file():
def test_asr_invalid_file(sqlite_session: Session):
asr = _build_builtin_tool(ASRTool)
file_obj = SimpleNamespace(type=FileType.DOCUMENT)
invalid_file = list(asr.invoke(session=MagicMock(), user_id="u", tool_parameters={"audio_file": file_obj}))[
invalid_file = list(asr.invoke(session=sqlite_session, user_id="u", tool_parameters={"audio_file": file_obj}))[
0
].message.text
assert "not a valid audio file" in invalid_file
def test_asr_valid_file_invocation(monkeypatch: pytest.MonkeyPatch):
def test_asr_valid_file_invocation(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session):
asr = _build_builtin_tool(ASRTool)
model_instance = type("M", (), {"invoke_speech2text": lambda self, file: "transcript"})()
model_manager = type("Mgr", (), {"get_model_instance": lambda *a, **k: model_instance})()
@ -245,9 +249,9 @@ def test_asr_valid_file_invocation(monkeypatch: pytest.MonkeyPatch):
lambda **kwargs: captured_manager_kwargs.update(kwargs) or model_manager,
)
audio_file = SimpleNamespace(type=FileType.AUDIO)
ok = list(asr.invoke(session=MagicMock(), user_id="u", tool_parameters={"audio_file": audio_file, "model": "p#m"}))[
0
].message.text
ok = list(
asr.invoke(session=sqlite_session, user_id="u", tool_parameters={"audio_file": audio_file, "model": "p#m"})
)[0].message.text
assert ok == "transcript"
assert captured_manager_kwargs == {"tenant_id": "tenant-1", "user_id": "u"}
@ -263,7 +267,7 @@ def test_asr_available_models_and_runtime_parameters(monkeypatch: pytest.MonkeyP
assert asr.get_runtime_parameters()[0].name == "model"
def test_tts_invoke_returns_messages(monkeypatch: pytest.MonkeyPatch):
def test_tts_invoke_returns_messages(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session):
tts = _build_builtin_tool(TTSTool)
captured_manager_kwargs = {}
voices_model_instance = type(
@ -281,7 +285,7 @@ def test_tts_invoke_returns_messages(monkeypatch: pytest.MonkeyPatch):
or type("M", (), {"get_model_instance": lambda *a, **k: voices_model_instance})()
),
)
messages = list(tts.invoke(session=MagicMock(), user_id="u", tool_parameters={"model": "p#m", "text": "hello"}))
messages = list(tts.invoke(session=sqlite_session, user_id="u", tool_parameters={"model": "p#m", "text": "hello"}))
assert [m.type for m in messages] == [ToolInvokeMessage.MessageType.TEXT, ToolInvokeMessage.MessageType.BLOB]
assert captured_manager_kwargs == {"tenant_id": "tenant-1", "user_id": "u"}
@ -293,18 +297,18 @@ def test_tts_get_available_models_requires_runtime():
tts.get_available_models()
def test_tts_tool_raises_when_runtime_missing():
def test_tts_tool_raises_when_runtime_missing(sqlite_session: Session):
tts = _build_builtin_tool(TTSTool)
tts.runtime = None
with pytest.raises(ValueError, match="Runtime is required"):
list(tts.invoke(session=MagicMock(), user_id="u", tool_parameters={"model": "p#m", "text": "hello"}))
list(tts.invoke(session=sqlite_session, user_id="u", tool_parameters={"model": "p#m", "text": "hello"}))
@pytest.mark.parametrize(
"voices",
[[{"value": None}], []],
)
def test_tts_tool_raises_when_voice_unavailable(monkeypatch, voices):
def test_tts_tool_raises_when_voice_unavailable(monkeypatch, voices, sqlite_session: Session):
tts = _build_builtin_tool(TTSTool)
tts.runtime = ToolRuntime(tenant_id="tenant-1", invoke_from=InvokeFrom.DEBUGGER)
model_without_voice = type(
@ -320,7 +324,7 @@ def test_tts_tool_raises_when_voice_unavailable(monkeypatch, voices):
lambda **_: type("Manager", (), {"get_model_instance": lambda *args, **kwargs: model_without_voice})(),
)
with pytest.raises(ValueError, match="no voice available"):
list(tts.invoke(session=MagicMock(), user_id="u", tool_parameters={"model": "p#m", "text": "hello"}))
list(tts.invoke(session=sqlite_session, user_id="u", tool_parameters={"model": "p#m", "text": "hello"}))
def test_tts_tool_get_available_models_and_runtime_parameters(monkeypatch: pytest.MonkeyPatch):

View File

@ -1,11 +1,14 @@
from __future__ import annotations
from collections.abc import Generator
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import Mock, patch
from uuid import uuid4
import pytest
from sqlalchemy import select
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from core.app.entities.app_invoke_entities import InvokeFrom
from core.tools.__base.tool import Tool
@ -26,6 +29,45 @@ from core.tools.errors import (
ToolParameterValidationError,
)
from core.tools.tool_engine import ToolEngine
from models.model import AppMode, Message, MessageFile
class _DatabaseBinding:
engine: Engine
def __init__(self, engine: Engine) -> None:
self.engine = engine
def _message() -> Message:
message = Message(
app_id=str(uuid4()),
model_provider="provider",
model_id="model",
override_model_configs=None,
conversation_id=str(uuid4()),
inputs={},
query="query",
message="",
message_tokens=0,
message_unit_price=0,
message_price_unit=0,
answer="",
answer_tokens=0,
answer_unit_price=0,
answer_price_unit=0,
parent_message_id=None,
provider_response_latency=0,
total_price=0,
currency="USD",
invoke_from="debugger",
from_source="console",
from_end_user_id=None,
from_account_id=str(uuid4()),
app_mode=AppMode.CHAT,
)
message.id = str(uuid4())
return message
class _DummyTool(Tool):
@ -120,52 +162,41 @@ def test_convert_tool_response_to_str_and_extract_binary_messages():
)
def test_create_message_files_and_invoke_generator():
@pytest.mark.parametrize("sqlite_session", [(MessageFile,)], indirect=True)
def test_create_message_files_and_invoke_generator(sqlite_engine: Engine, sqlite_session: Session):
binaries = [
ToolInvokeMessageBinary(mimetype="image/png", url="https://example.com/abc.png"),
ToolInvokeMessageBinary(mimetype="audio/wav", url="https://example.com/def.wav"),
]
created = []
def _message_file_factory(**kwargs):
obj = SimpleNamespace(id=f"mf-{len(created) + 1}", **kwargs)
created.append(obj)
return obj
file_session = MagicMock()
session_factory = MagicMock()
session_factory.begin.return_value.__enter__.return_value = file_session
with (
patch("core.tools.tool_engine.MessageFile", side_effect=_message_file_factory),
patch("core.tools.tool_engine.db") as mock_db,
patch("core.tools.tool_engine.sessionmaker", return_value=session_factory) as mock_sessionmaker,
):
agent_message = _message()
with patch("core.tools.tool_engine.db", _DatabaseBinding(sqlite_engine)):
ids = ToolEngine._create_message_files(
tool_messages=binaries,
agent_message=SimpleNamespace(id="msg-1"),
agent_message=agent_message,
invoke_from=InvokeFrom.DEBUGGER,
user_id="user-1",
user_id=str(uuid4()),
)
assert ids == ["mf-1", "mf-2"]
mock_sessionmaker.assert_called_once_with(bind=mock_db.engine, expire_on_commit=False)
assert file_session.add.call_count == 2
mock_db.session.close.assert_not_called()
message_files = list(sqlite_session.scalars(select(MessageFile).order_by(MessageFile.created_at)).all())
assert ids == [message_file.id for message_file in message_files]
assert len(message_files) == 2
assert {message_file.message_id for message_file in message_files} == {agent_message.id}
tool = _build_tool()
invoked = list(ToolEngine._invoke(MagicMock(), tool, {"a": 1}, user_id="u"))
invoked = list(ToolEngine._invoke(sqlite_session, tool, {"a": 1}, user_id="u"))
assert invoked[0].type == ToolInvokeMessage.MessageType.TEXT
assert isinstance(invoked[-1], ToolInvokeMeta)
assert invoked[-1].error is None
def test_generic_invoke_success_and_error_paths():
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_generic_invoke_success_and_error_paths(sqlite_session: Session):
tool = _build_tool()
callback = Mock()
callback.on_tool_execution.side_effect = lambda **kwargs: kwargs["tool_outputs"]
response = list(
ToolEngine.generic_invoke(
session=MagicMock(),
session=sqlite_session,
tool=tool,
tool_parameters={"x": 1},
user_id="u1",
@ -186,7 +217,7 @@ def test_generic_invoke_success_and_error_paths():
with pytest.raises(RuntimeError, match="boom"):
list(
ToolEngine.generic_invoke(
session=MagicMock(),
session=sqlite_session,
tool=tool,
tool_parameters={"x": 1},
user_id="u1",
@ -197,10 +228,11 @@ def test_generic_invoke_success_and_error_paths():
error_callback.on_tool_error.assert_called_once()
def test_agent_invoke_success():
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_agent_invoke_success(sqlite_session: Session):
tool = _build_tool(with_llm_parameter=True)
callback = Mock()
message = SimpleNamespace(id="m1", conversation_id="c1")
message = _message()
meta = ToolInvokeMeta.empty()
with patch.object(ToolEngine, "_invoke", return_value=iter([tool.create_text_message("ok"), meta])):
@ -211,7 +243,7 @@ def test_agent_invoke_success():
with patch.object(ToolEngine, "_extract_tool_response_binary_and_text", return_value=iter([])):
with patch.object(ToolEngine, "_create_message_files", return_value=[]):
result_text, message_files, result_meta = ToolEngine.agent_invoke(
session=MagicMock(),
session=sqlite_session,
tool=tool,
tool_parameters="hello",
user_id="u1",
@ -228,14 +260,15 @@ def test_agent_invoke_success():
callback.on_tool_end.assert_called_once()
def test_agent_invoke_param_validation_error():
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_agent_invoke_param_validation_error(sqlite_session: Session):
tool = _build_tool(with_llm_parameter=True)
callback = Mock()
message = SimpleNamespace(id="m1", conversation_id="c1")
message = _message()
with patch.object(ToolEngine, "_invoke", side_effect=ToolParameterValidationError("bad-param")):
error_text, files, error_meta = ToolEngine.agent_invoke(
session=MagicMock(),
session=sqlite_session,
tool=tool,
tool_parameters={"a": 1},
user_id="u1",
@ -250,15 +283,16 @@ def test_agent_invoke_param_validation_error():
assert error_meta.error
def test_agent_invoke_engine_meta_error():
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_agent_invoke_engine_meta_error(sqlite_session: Session):
tool = _build_tool(with_llm_parameter=True)
callback = Mock()
message = SimpleNamespace(id="m1", conversation_id="c1")
message = _message()
engine_error = ToolEngineInvokeError(ToolInvokeMeta.error_instance("meta failure"))
with patch.object(ToolEngine, "_invoke", side_effect=engine_error):
error_text, files, error_meta = ToolEngine.agent_invoke(
session=MagicMock(),
session=sqlite_session,
tool=tool,
tool_parameters={"a": 1},
user_id="u1",
@ -295,14 +329,15 @@ def test_convert_tool_response_excludes_variable_messages():
assert "variable_name" not in result
def test_agent_invoke_tool_invoke_error():
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_agent_invoke_tool_invoke_error(sqlite_session: Session):
tool = _build_tool(with_llm_parameter=True)
callback = Mock()
message = SimpleNamespace(id="m1", conversation_id="c1")
message = _message()
with patch.object(ToolEngine, "_invoke", side_effect=ToolInvokeError("invoke boom")):
error_text, files, _ = ToolEngine.agent_invoke(
session=MagicMock(),
session=sqlite_session,
tool=tool,
tool_parameters={"a": 1},
user_id="u1",

View File

@ -351,6 +351,33 @@ def test_fetch_model_config_hydrates_model_instance_runtime_settings(model_confi
provider_model.raise_for_status.assert_called_once()
@pytest.mark.parametrize(
("provider", "model_name"),
[
("", "gpt-3.5-turbo"),
("openai", ""),
],
)
def test_fetch_model_config_rejects_unconfigured_model(provider: str, model_name: str):
credentials_provider = mock.MagicMock(spec=CredentialsProvider)
model_factory = mock.MagicMock(spec=DifyModelFactory)
with pytest.raises(ValueError, match="LLM provider and model are required"):
fetch_model_config(
node_data_model=ModelConfig(
provider=provider,
name=model_name,
mode="chat",
completion_params={},
),
credentials_provider=credentials_provider,
model_factory=model_factory,
)
credentials_provider.fetch.assert_not_called()
model_factory.init_model_instance.assert_not_called()
def test_fetch_model_config_reuses_validated_provider_model_from_dify_credentials_provider(
model_config: ModelConfigWithCredentialsEntity,
):

View File

@ -4,7 +4,7 @@ from unittest.mock import patch
import pytest
import pytz
from libs.datetime_utils import naive_utc_now, parse_time_range
from libs.datetime_utils import naive_utc_now, parse_time_range, to_utc_timestamp
def test_naive_utc_now(monkeypatch: pytest.MonkeyPatch):
@ -24,6 +24,18 @@ def test_naive_utc_now(monkeypatch: pytest.MonkeyPatch):
assert naive_time == utc_time
@pytest.mark.parametrize(
"value",
[
datetime.datetime(2024, 1, 1),
datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC),
datetime.datetime(2024, 1, 1, 9, tzinfo=datetime.timezone(datetime.timedelta(hours=9))),
],
)
def test_to_utc_timestamp(value: datetime.datetime):
assert to_utc_timestamp(value) == 1704067200
class TestParseTimeRange:
"""Test cases for parse_time_range function."""

View File

@ -1,22 +1,65 @@
"""Unit tests for require_workspace_member."""
"""SQLite-backed unit tests for workspace membership enforcement."""
from __future__ import annotations
import uuid
from unittest.mock import MagicMock, patch
from collections.abc import Iterator
from dataclasses import dataclass
from unittest.mock import Mock
import pytest
from sqlalchemy import event
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
from libs import oauth_bearer
from libs.oauth_bearer import AuthContext, Scope, SubjectType, TokenType, require_workspace_member
from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole
pytestmark = pytest.mark.usefixtures("community_edition")
def _ctx(verified: dict[str, bool] | None = None, *, account: bool = True) -> AuthContext:
@dataclass(frozen=True)
class Database:
"""Real ORM binding and executed-statement log for one isolated test."""
session: Session
statements: list[str]
@pytest.fixture
def database(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Iterator[Database]:
statements: list[str] = []
def record_statement(_connection, _cursor, statement, _parameters, _context, _executemany) -> None:
statements.append(statement)
engine = sqlite_session.get_bind()
event.listen(engine, "before_cursor_execute", record_statement)
binding = Database(session=sqlite_session, statements=statements)
monkeypatch.setattr(oauth_bearer, "db", binding)
try:
yield binding
finally:
event.remove(engine, "before_cursor_execute", record_statement)
@pytest.fixture
def community_edition(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(oauth_bearer.dify_config, "ENTERPRISE_ENABLED", False)
def _ctx(
verified: dict[str, bool] | None = None,
*,
account_id: uuid.UUID | None = None,
account: bool = True,
) -> AuthContext:
return AuthContext(
subject_type=SubjectType.ACCOUNT if account else SubjectType.EXTERNAL_SSO,
subject_email="e@example.com",
subject_issuer=None,
account_id=uuid.uuid4() if account else None,
account_id=account_id or (uuid.uuid4() if account else None),
client_id="difyctl",
scopes=frozenset({Scope.FULL}),
token_id=uuid.uuid4(),
@ -27,68 +70,130 @@ def _ctx(verified: dict[str, bool] | None = None, *, account: bool = True) -> Au
)
@patch("libs.oauth_bearer.dify_config")
def test_skips_when_enterprise_enabled(mock_cfg):
mock_cfg.ENTERPRISE_ENABLED = True
require_workspace_member(_ctx(), "t1")
def _persist_membership(
session: Session,
*,
account_id: uuid.UUID,
tenant_id: str,
status: AccountStatus = AccountStatus.ACTIVE,
) -> None:
account = _account(account_id, status=status)
tenant = Tenant(name=f"Tenant {tenant_id}")
tenant.id = tenant_id
membership = TenantAccountJoin(
tenant_id=tenant_id,
account_id=account_id.hex,
role=TenantAccountRole.NORMAL,
)
session.add_all([account, tenant, membership])
session.commit()
@patch("libs.oauth_bearer.dify_config")
def test_skips_for_external_sso(mock_cfg):
mock_cfg.ENTERPRISE_ENABLED = False
require_workspace_member(_ctx(account=False), "t1")
def _account(account_id: uuid.UUID, *, status: AccountStatus = AccountStatus.ACTIVE) -> Account:
account = Account(name="Workspace member", email=f"{account_id}@example.com", status=status)
# SQLite's StringUUID adapter binds UUID objects as compact hex, while
# PostgreSQL binds their dashed string form. Persist the SQLite-bound form
# so the production query can keep accepting the AuthContext UUID object.
account.id = account_id.hex
return account
@patch("libs.oauth_bearer.db")
@patch("libs.oauth_bearer.dify_config")
def test_uses_cached_ok_no_db_access(mock_cfg, mock_db):
mock_cfg.ENTERPRISE_ENABLED = False
require_workspace_member(_ctx({"t1": True}), "t1")
mock_db.session.execute.assert_not_called()
def test_skips_when_enterprise_enabled(database: Database, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(oauth_bearer.dify_config, "ENTERPRISE_ENABLED", True)
before = len(database.statements)
require_workspace_member(_ctx(), "tenant-1")
assert len(database.statements) == before
@patch("libs.oauth_bearer.db")
@patch("libs.oauth_bearer.dify_config")
def test_uses_cached_denied(mock_cfg, mock_db):
mock_cfg.ENTERPRISE_ENABLED = False
def test_skips_for_external_sso(database: Database) -> None:
before = len(database.statements)
require_workspace_member(_ctx(account=False), "tenant-1")
assert len(database.statements) == before
def test_uses_cached_allow_without_database_access(database: Database) -> None:
before = len(database.statements)
require_workspace_member(_ctx({"tenant-1": True}), "tenant-1")
assert len(database.statements) == before
def test_uses_cached_denial_without_database_access(database: Database) -> None:
before = len(database.statements)
with pytest.raises(Forbidden, match="workspace_membership_revoked"):
require_workspace_member(_ctx({"t1": False}), "t1")
mock_db.session.execute.assert_not_called()
require_workspace_member(_ctx({"tenant-1": False}), "tenant-1")
assert len(database.statements) == before
@patch("libs.oauth_bearer.record_layer0_verdict")
@patch("libs.oauth_bearer.db")
@patch("libs.oauth_bearer.dify_config")
def test_denies_when_no_membership(mock_cfg, mock_db, mock_record):
mock_cfg.ENTERPRISE_ENABLED = False
mock_db.session.execute.return_value.scalar_one_or_none.return_value = None
@pytest.mark.usefixtures("database")
def test_denies_when_membership_is_absent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
record_verdict = Mock()
monkeypatch.setattr(oauth_bearer, "record_layer0_verdict", record_verdict)
with pytest.raises(Forbidden, match="workspace_membership_revoked"):
require_workspace_member(_ctx({}), "t1")
mock_record.assert_called_once_with("h1", "t1", False)
require_workspace_member(_ctx(), "tenant-1")
record_verdict.assert_called_once_with("h1", "tenant-1", False)
@patch("libs.oauth_bearer.record_layer0_verdict")
@patch("libs.oauth_bearer.db")
@patch("libs.oauth_bearer.dify_config")
def test_denies_when_account_inactive(mock_cfg, mock_db, mock_record):
mock_cfg.ENTERPRISE_ENABLED = False
mock_db.session.execute.side_effect = [
MagicMock(scalar_one_or_none=MagicMock(return_value="join-id")),
MagicMock(scalar_one_or_none=MagicMock(return_value="banned")),
]
def test_denies_membership_from_another_tenant(
database: Database,
monkeypatch: pytest.MonkeyPatch,
) -> None:
account_id = uuid.uuid4()
requested_tenant_member_id = uuid.uuid4()
status_decoy_id = uuid.uuid4()
_persist_membership(database.session, account_id=account_id, tenant_id="tenant-2")
_persist_membership(database.session, account_id=requested_tenant_member_id, tenant_id="tenant-1")
database.session.add(_account(status_decoy_id, status=AccountStatus.BANNED))
database.session.commit()
record_verdict = Mock()
monkeypatch.setattr(oauth_bearer, "record_layer0_verdict", record_verdict)
with pytest.raises(Forbidden, match="workspace_membership_revoked"):
require_workspace_member(_ctx({}), "t1")
mock_record.assert_called_once_with("h1", "t1", False)
require_workspace_member(_ctx(account_id=account_id), "tenant-1")
record_verdict.assert_called_once_with("h1", "tenant-1", False)
@patch("libs.oauth_bearer.record_layer0_verdict")
@patch("libs.oauth_bearer.db")
@patch("libs.oauth_bearer.dify_config")
def test_allows_active_member(mock_cfg, mock_db, mock_record):
mock_cfg.ENTERPRISE_ENABLED = False
mock_db.session.execute.side_effect = [
MagicMock(scalar_one_or_none=MagicMock(return_value="join-id")),
MagicMock(scalar_one_or_none=MagicMock(return_value="active")),
]
require_workspace_member(_ctx({}), "t1")
mock_record.assert_called_once_with("h1", "t1", True)
def test_denies_when_account_is_inactive(
database: Database,
monkeypatch: pytest.MonkeyPatch,
) -> None:
account_id = uuid.uuid4()
_persist_membership(
database.session,
account_id=account_id,
tenant_id="tenant-1",
status=AccountStatus.BANNED,
)
record_verdict = Mock()
monkeypatch.setattr(oauth_bearer, "record_layer0_verdict", record_verdict)
with pytest.raises(Forbidden, match="workspace_membership_revoked"):
require_workspace_member(_ctx(account_id=account_id), "tenant-1")
record_verdict.assert_called_once_with("h1", "tenant-1", False)
def test_allows_active_member_and_records_verdict(
database: Database,
monkeypatch: pytest.MonkeyPatch,
) -> None:
account_id = uuid.uuid4()
_persist_membership(database.session, account_id=account_id, tenant_id="tenant-1")
record_verdict = Mock()
monkeypatch.setattr(oauth_bearer, "record_layer0_verdict", record_verdict)
require_workspace_member(_ctx(account_id=account_id), "tenant-1")
record_verdict.assert_called_once_with("h1", "tenant-1", True)

View File

@ -3,6 +3,7 @@ from typing import cast
from unittest.mock import Mock
import pytest
from sqlalchemy.orm import Session
from models import Account, Tenant
from services.entities.knowledge_entities.knowledge_entities import MetadataArgs
@ -38,7 +39,8 @@ class TestMetadataBugCompleteValidation:
assert valid_args.type == "string"
assert valid_args.name == "test_name"
def test_2_business_logic_layer_crashes_on_none(self) -> None:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_2_business_logic_layer_crashes_on_none(self, sqlite_session: Session) -> None:
"""Test Layer 2: Business logic crashes when None values slip through."""
# Create mock that bypasses Pydantic validation
mock_metadata_args = Mock()
@ -48,15 +50,18 @@ class TestMetadataBugCompleteValidation:
account = _make_account()
# Should crash with TypeError
with pytest.raises(TypeError, match="object of type 'NoneType' has no len"):
MetadataService.create_metadata("dataset-123", mock_metadata_args, account, "tenant-123", session=Mock())
MetadataService.create_metadata(
"dataset-123", mock_metadata_args, account, "tenant-123", session=sqlite_session
)
# Test update method as well
account = _make_account()
none_name = cast(str, None)
with pytest.raises(TypeError, match="object of type 'NoneType' has no len"):
MetadataService.update_metadata_name(
"dataset-123", "metadata-456", none_name, account, "tenant-123", session=Mock()
"dataset-123", "metadata-456", none_name, account, "tenant-123", session=sqlite_session
)
assert not sqlite_session.in_transaction()
def test_3_database_constraints_verification(self) -> None:
"""Test Layer 3: Verify database model has nullable=False constraints."""
@ -91,7 +96,8 @@ class TestMetadataBugCompleteValidation:
assert args.type == "string"
assert args.name == "valid_name"
def test_6_simulated_buggy_behavior(self) -> None:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_6_simulated_buggy_behavior(self, sqlite_session: Session) -> None:
"""Test simulating the original buggy behavior by bypassing Pydantic validation."""
mock_metadata_args = Mock()
mock_metadata_args.name = None
@ -99,7 +105,10 @@ class TestMetadataBugCompleteValidation:
account = _make_account()
with pytest.raises(TypeError, match="object of type 'NoneType' has no len"):
MetadataService.create_metadata("dataset-123", mock_metadata_args, account, "tenant-123", session=Mock())
MetadataService.create_metadata(
"dataset-123", mock_metadata_args, account, "tenant-123", session=sqlite_session
)
assert not sqlite_session.in_transaction()
def test_7_end_to_end_validation_layers(self) -> None:
"""Test all validation layers work together correctly."""

View File

@ -985,7 +985,8 @@ def test_build_snapshot_events_preserves_public_form_token(monkeypatch: pytest.M
)
session_maker = _SessionMaker(
SimpleNamespace(
execute=lambda _stmt: [("form-1", datetime(2024, 1, 1, tzinfo=UTC), '{"display_in_ui": true}')],
# Persisted UTC datetimes can be loaded as naive values.
execute=lambda _stmt: [("form-1", datetime(2024, 1, 1), '{"display_in_ui": true}')],
)
)
pause_entity = _FakePauseEntity(

View File

@ -2,74 +2,22 @@ from __future__ import annotations
from datetime import datetime, timedelta
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import pytest
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
import core.db.session_factory as session_factory_module
from core.repositories.human_input_repository import HumanInputFormSubmissionRepository
from core.workflow.nodes.human_input.entities import FormDefinition
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
from models.human_input import HumanInputForm
from tasks import human_input_timeout_tasks as task_module
class _FakeScalarResult:
def __init__(self, items: list[Any]):
self._items = items
def all(self) -> list[Any]:
return self._items
class _FakeSession:
def __init__(self, items: list[Any], capture: dict[str, Any]):
self._items = items
self._capture = capture
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def scalars(self, stmt):
self._capture["stmt"] = stmt
return _FakeScalarResult(self._items)
class _FakeSessionFactory:
def __init__(self, items: list[Any], capture: dict[str, Any]):
self._items = items
self._capture = capture
self._capture["session_factory"] = self
def __call__(self):
session = _FakeSession(self._items, self._capture)
self._capture["session"] = session
return session
class _FakeFormRepo:
def __init__(self, form_map: dict[str, Any] | None = None):
self.calls: list[dict[str, Any]] = []
self._form_map = form_map or {}
def mark_timeout(self, *, form_id: str, timeout_status: HumanInputFormStatus, reason: str | None = None):
self.calls.append(
{
"form_id": form_id,
"timeout_status": timeout_status,
"reason": reason,
}
)
form = self._form_map.get(form_id)
return SimpleNamespace(
form_id=form_id,
workflow_run_id=getattr(form, "workflow_run_id", None),
conversation_id=getattr(form, "conversation_id", None),
node_id=getattr(form, "node_id", None),
)
class _FakeService:
def __init__(self, _session_factory, form_repository=None):
def __init__(self):
self.enqueued: list[str] = []
self.agent_app_resumed: list[tuple[str, str]] = []
@ -90,22 +38,49 @@ def _build_form(
workflow_run_id: str | None,
node_id: str,
conversation_id: str | None = None,
) -> SimpleNamespace:
return SimpleNamespace(
) -> HumanInputForm:
form_definition = FormDefinition(
form_content="",
rendered_content="",
expiration_time=expiration_time,
)
return HumanInputForm(
id=form_id,
tenant_id="tenant-1",
app_id="app-1",
form_kind=form_kind,
created_at=created_at,
expiration_time=expiration_time,
workflow_run_id=workflow_run_id,
conversation_id=conversation_id,
node_id=node_id,
form_definition=form_definition.model_dump_json(),
rendered_content="",
status=HumanInputFormStatus.WAITING,
)
@pytest.fixture
def sqlite_task_database(
sqlite_engine: Engine,
sqlite_session: Session,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repository_session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
monkeypatch.setattr(session_factory_module, "_session_maker", repository_session_maker)
monkeypatch.setattr(task_module, "db", SimpleNamespace(engine=sqlite_engine))
def test_is_global_timeout_uses_created_at():
now = datetime(2025, 1, 1, 12, 0, 0)
form = SimpleNamespace(created_at=now - timedelta(seconds=61), workflow_run_id="run-1")
form = _build_form(
form_id="form-1",
form_kind=HumanInputFormKind.RUNTIME,
created_at=now - timedelta(seconds=61),
expiration_time=now + timedelta(hours=1),
workflow_run_id="run-1",
node_id="node-1",
)
assert task_module._is_global_timeout(form, 60, now=now) is True
@ -119,11 +94,16 @@ def test_is_global_timeout_uses_created_at():
assert task_module._is_global_timeout(form, 0, now=now) is False
def test_check_and_handle_human_input_timeouts_marks_and_routes(monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize("sqlite_session", [(HumanInputForm,)], indirect=True)
def test_check_and_handle_human_input_timeouts_marks_and_routes(
monkeypatch: pytest.MonkeyPatch,
sqlite_task_database: None,
sqlite_engine: Engine,
sqlite_session: Session,
):
now = datetime(2025, 1, 1, 12, 0, 0)
monkeypatch.setattr(task_module, "naive_utc_now", lambda: now)
monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 3600)
monkeypatch.setattr(task_module, "db", SimpleNamespace(engine=object()))
forms = [
_build_form(
@ -151,74 +131,131 @@ def test_check_and_handle_human_input_timeouts_marks_and_routes(monkeypatch: pyt
node_id="node-delivery",
),
]
sqlite_session.add_all(forms)
sqlite_session.commit()
capture: dict[str, Any] = {}
monkeypatch.setattr(task_module, "sessionmaker", lambda *args, **kwargs: _FakeSessionFactory(forms, capture))
repo = HumanInputFormSubmissionRepository()
mark_timeout_spy = MagicMock(wraps=repo.mark_timeout)
monkeypatch.setattr(repo, "mark_timeout", mark_timeout_spy)
service = _FakeService()
service_factory = MagicMock(return_value=service)
global_timeout_handler = MagicMock()
form_map = {form.id: form for form in forms}
repo = _FakeFormRepo(form_map=form_map)
def _repo_factory():
return repo
service = _FakeService(None)
def _service_factory(_session_factory, form_repository=None):
return service
global_calls: list[dict[str, Any]] = []
monkeypatch.setattr(task_module, "HumanInputFormSubmissionRepository", _repo_factory)
monkeypatch.setattr(task_module, "HumanInputService", _service_factory)
monkeypatch.setattr(task_module, "_handle_global_timeout", lambda **kwargs: global_calls.append(kwargs))
monkeypatch.setattr(task_module, "HumanInputFormSubmissionRepository", lambda: repo)
monkeypatch.setattr(task_module, "HumanInputService", service_factory)
monkeypatch.setattr(task_module, "_handle_global_timeout", global_timeout_handler)
task_module.check_and_handle_human_input_timeouts(limit=100)
assert {(call["form_id"], call["timeout_status"], call["reason"]) for call in repo.calls} == {
assert {
(call.kwargs["form_id"], call.kwargs["timeout_status"], call.kwargs["reason"])
for call in mark_timeout_spy.call_args_list
} == {
("form-global", HumanInputFormStatus.EXPIRED, "global_timeout"),
("form-node", HumanInputFormStatus.TIMEOUT, "node_timeout"),
("form-delivery", HumanInputFormStatus.TIMEOUT, "delivery_test_timeout"),
}
assert service.enqueued == ["run-node"]
assert global_calls == [
{
"form_id": "form-global",
"workflow_run_id": "run-global",
"node_id": "node-global",
"session_factory": capture.get("session_factory"),
}
]
global_timeout_handler.assert_called_once()
global_timeout_call = global_timeout_handler.call_args.kwargs
assert global_timeout_call["form_id"] == "form-global"
assert global_timeout_call["workflow_run_id"] == "run-global"
assert global_timeout_call["node_id"] == "node-global"
task_session_maker = global_timeout_call["session_factory"]
assert isinstance(task_session_maker, sessionmaker)
assert task_session_maker.kw["bind"] is sqlite_engine
service_factory.assert_called_once_with(task_session_maker, form_repository=repo)
stmt = capture.get("stmt")
assert stmt is not None
stmt_text = str(stmt)
assert "created_at <=" in stmt_text
assert "expiration_time <=" in stmt_text
assert "ORDER BY human_input_forms.id" in stmt_text
sqlite_session.expire_all()
assert sqlite_session.get(HumanInputForm, "form-global").status == HumanInputFormStatus.EXPIRED
assert sqlite_session.get(HumanInputForm, "form-node").status == HumanInputFormStatus.TIMEOUT
assert sqlite_session.get(HumanInputForm, "form-delivery").status == HumanInputFormStatus.TIMEOUT
def test_check_and_handle_human_input_timeouts_omits_global_filter_when_disabled(monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize("sqlite_session", [(HumanInputForm,)], indirect=True)
def test_check_and_handle_human_input_timeouts_orders_by_id_before_limit(
monkeypatch: pytest.MonkeyPatch,
sqlite_task_database: None,
sqlite_session: Session,
):
now = datetime(2025, 1, 1, 12, 0, 0)
monkeypatch.setattr(task_module, "naive_utc_now", lambda: now)
monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0)
monkeypatch.setattr(task_module, "db", SimpleNamespace(engine=object()))
capture: dict[str, Any] = {}
monkeypatch.setattr(task_module, "sessionmaker", lambda *args, **kwargs: _FakeSessionFactory([], capture))
monkeypatch.setattr(task_module, "HumanInputFormSubmissionRepository", _FakeFormRepo)
monkeypatch.setattr(task_module, "HumanInputService", _FakeService)
monkeypatch.setattr(task_module, "_handle_global_timeout", lambda **_kwargs: None)
forms = [
_build_form(
form_id=form_id,
form_kind=HumanInputFormKind.DELIVERY_TEST,
created_at=now - timedelta(minutes=1),
expiration_time=now - timedelta(seconds=1),
workflow_run_id=None,
node_id=f"node-{form_id}",
)
for form_id in ("form-b", "form-a")
]
sqlite_session.add_all(forms)
sqlite_session.commit()
repo = HumanInputFormSubmissionRepository()
mark_timeout_spy = MagicMock(wraps=repo.mark_timeout)
monkeypatch.setattr(repo, "mark_timeout", mark_timeout_spy)
monkeypatch.setattr(task_module, "HumanInputFormSubmissionRepository", lambda: repo)
monkeypatch.setattr(task_module, "HumanInputService", MagicMock(return_value=_FakeService()))
task_module.check_and_handle_human_input_timeouts(limit=1)
stmt = capture.get("stmt")
assert stmt is not None
stmt_text = str(stmt)
assert "created_at <=" not in stmt_text
mark_timeout_spy.assert_called_once_with(
form_id="form-a",
timeout_status=HumanInputFormStatus.TIMEOUT,
reason="delivery_test_timeout",
)
sqlite_session.expire_all()
assert sqlite_session.get(HumanInputForm, "form-a").status == HumanInputFormStatus.TIMEOUT
assert sqlite_session.get(HumanInputForm, "form-b").status == HumanInputFormStatus.WAITING
@pytest.mark.parametrize("sqlite_session", [(HumanInputForm,)], indirect=True)
def test_check_and_handle_human_input_timeouts_omits_global_filter_when_disabled(
monkeypatch: pytest.MonkeyPatch,
sqlite_task_database: None,
sqlite_session: Session,
):
now = datetime(2025, 1, 1, 12, 0, 0)
monkeypatch.setattr(task_module, "naive_utc_now", lambda: now)
monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0)
old_unexpired_form = _build_form(
form_id="form-old",
form_kind=HumanInputFormKind.RUNTIME,
created_at=now - timedelta(hours=2),
expiration_time=now + timedelta(hours=1),
workflow_run_id="run-old",
node_id="node-old",
)
sqlite_session.add(old_unexpired_form)
sqlite_session.commit()
repo = HumanInputFormSubmissionRepository()
mark_timeout_spy = MagicMock(wraps=repo.mark_timeout)
monkeypatch.setattr(repo, "mark_timeout", mark_timeout_spy)
monkeypatch.setattr(task_module, "HumanInputFormSubmissionRepository", lambda: repo)
monkeypatch.setattr(task_module, "HumanInputService", MagicMock(return_value=_FakeService()))
global_timeout_handler = MagicMock()
monkeypatch.setattr(task_module, "_handle_global_timeout", global_timeout_handler)
task_module.check_and_handle_human_input_timeouts(limit=1)
mark_timeout_spy.assert_not_called()
global_timeout_handler.assert_not_called()
sqlite_session.refresh(old_unexpired_form)
assert old_unexpired_form.status == HumanInputFormStatus.WAITING
@pytest.mark.parametrize("sqlite_session", [(HumanInputForm,)], indirect=True)
def test_check_and_handle_human_input_timeouts_routes_conversation_owned_form_to_agent_app_resume(
monkeypatch: pytest.MonkeyPatch,
sqlite_task_database: None,
sqlite_session: Session,
):
# ENG-635 (review): a conversation-owned Agent v2 chat ask_human form has no
# workflow_run_id. On timeout it must enqueue the Agent App resume (so the
@ -227,24 +264,23 @@ def test_check_and_handle_human_input_timeouts_routes_conversation_owned_form_to
now = datetime(2025, 1, 1, 12, 0, 0)
monkeypatch.setattr(task_module, "naive_utc_now", lambda: now)
monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 3600)
monkeypatch.setattr(task_module, "db", SimpleNamespace(engine=object()))
forms = [
_build_form(
form_id="form-chat",
form_kind=HumanInputFormKind.RUNTIME,
created_at=now - timedelta(minutes=5),
expiration_time=now - timedelta(seconds=1),
workflow_run_id=None,
conversation_id="conv-1",
node_id="agent",
),
]
capture: dict[str, Any] = {}
monkeypatch.setattr(task_module, "sessionmaker", lambda *args, **kwargs: _FakeSessionFactory(forms, capture))
form = _build_form(
form_id="form-chat",
form_kind=HumanInputFormKind.RUNTIME,
created_at=now - timedelta(minutes=5),
expiration_time=now - timedelta(seconds=1),
workflow_run_id=None,
conversation_id="conv-1",
node_id="agent",
)
sqlite_session.add(form)
sqlite_session.commit()
repo = _FakeFormRepo(form_map={form.id: form for form in forms})
service = _FakeService(None)
repo = HumanInputFormSubmissionRepository()
mark_timeout_spy = MagicMock(wraps=repo.mark_timeout)
monkeypatch.setattr(repo, "mark_timeout", mark_timeout_spy)
service = _FakeService()
monkeypatch.setattr(task_module, "HumanInputFormSubmissionRepository", lambda: repo)
monkeypatch.setattr(task_module, "HumanInputService", lambda *_args, **_kwargs: service)
monkeypatch.setattr(task_module, "_handle_global_timeout", lambda **_kwargs: None)
@ -252,8 +288,10 @@ def test_check_and_handle_human_input_timeouts_routes_conversation_owned_form_to
task_module.check_and_handle_human_input_timeouts(limit=100)
# Node timeout (conversation forms are never "global"), routed to Agent App resume.
assert repo.calls == [
{"form_id": "form-chat", "timeout_status": HumanInputFormStatus.TIMEOUT, "reason": "node_timeout"}
]
mark_timeout_spy.assert_called_once_with(
form_id="form-chat", timeout_status=HumanInputFormStatus.TIMEOUT, reason="node_timeout"
)
assert service.agent_app_resumed == [("conv-1", "form-chat")]
assert service.enqueued == []
sqlite_session.refresh(form)
assert form.status == HumanInputFormStatus.TIMEOUT

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import json
import logging
import uuid
from collections.abc import Generator, Mapping
from contextlib import nullcontext
from datetime import datetime
from decimal import Decimal
@ -36,6 +37,7 @@ from tasks.app_generate.workflow_execute_task import (
class _StreamEventModel(BaseModel):
event: object | None = None
task_id: object | None = None
message: object | None = None
def _build_advanced_chat_generate_entity(conversation_id: str | None) -> AdvancedChatAppGenerateEntity:
@ -248,6 +250,21 @@ def test_get_task_id(event: object, expected: str | None):
assert workflow_execute_task_module._get_task_id(event) == expected
@pytest.mark.parametrize(
("event", "expected"),
[
({"message": "workflow error"}, "workflow error"),
(_StreamEventModel(message="workflow error"), "workflow error"),
({"message": ""}, None),
({"message": 123}, None),
({}, None),
("workflow error", None),
],
)
def test_get_error_message(event: str | Mapping[str, object] | BaseModel, expected: str | None):
assert workflow_execute_task_module._get_error_message(event) == expected
@pytest.fixture
def mock_topic(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
topic = MagicMock()
@ -486,6 +503,38 @@ def test_publish_streaming_response_publishes_failed_terminal_on_exhaustion_with
assert "ended without a terminal event" in caplog.text
def test_publish_streaming_response_uses_error_message_for_failed_terminal(mock_topic: MagicMock):
def response_stream() -> Generator[str | Mapping[str, object] | BaseModel, None, None]:
yield {
"event": "error",
"workflow_run_id": "workflow-run-id",
"code": "invalid_param",
"message": "LLM provider and model are required.",
"status": 400,
}
_publish_streaming_response(
response_stream(),
"workflow-run-id",
app_mode=AppMode.WORKFLOW,
workflow_id="workflow-id",
inputs={},
started_reason=WorkflowStartReason.INITIAL,
)
payloads = _published_payloads(mock_topic)
error_payload = payloads[0]
finished_payload = payloads[-1]
assert isinstance(error_payload, dict)
assert isinstance(finished_payload, dict)
assert error_payload["status"] == 400
assert error_payload["message"] == "LLM provider and model are required."
finished_data = finished_payload["data"]
assert isinstance(finished_data, dict)
assert finished_data["status"] == WorkflowExecutionStatus.FAILED
assert finished_data["error"] == "LLM provider and model are required."
def test_publish_streaming_response_does_not_publish_synthetic_failure_after_terminal_event(mock_topic: MagicMock):
response_stream = iter(
[

View File

@ -281,6 +281,8 @@ services:
SERVER_WORKER_CONNECTIONS: ${API_WEBSOCKET_WORKER_CONNECTIONS:-1000}
GUNICORN_TIMEOUT: ${API_WEBSOCKET_GUNICORN_TIMEOUT:-360}
depends_on:
init_permissions:
condition: service_completed_successfully
db_postgres:
condition: service_healthy
required: false
@ -289,6 +291,9 @@ services:
required: false
redis:
condition: service_started
volumes:
# Mount the storage directory to the container, for storing user files.
- ./volumes/app/storage:/app/api/storage
networks:
- ssrf_proxy_network
- default

View File

@ -287,6 +287,8 @@ services:
SERVER_WORKER_CONNECTIONS: ${API_WEBSOCKET_WORKER_CONNECTIONS:-1000}
GUNICORN_TIMEOUT: ${API_WEBSOCKET_GUNICORN_TIMEOUT:-360}
depends_on:
init_permissions:
condition: service_completed_successfully
db_postgres:
condition: service_healthy
required: false
@ -295,6 +297,9 @@ services:
required: false
redis:
condition: service_started
volumes:
# Mount the storage directory to the container, for storing user files.
- ./volumes/app/storage:/app/api/storage
networks:
- ssrf_proxy_network
- default

View File

@ -5022,11 +5022,6 @@
"count": 3
}
},
"web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/list.spec.tsx": {
"no-unused-vars": {
"count": 1
}
},
"web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/item.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 2

View File

@ -4,11 +4,10 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NotionPageSelector from '@/app/components/base/notion-page-selector/base'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
const mockInvalidPreImportNotionPages = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockSetSettingsDestination = vi.fn()
const mockUsePreImportNotionPages = vi.fn()
vi.mock('@tanstack/react-virtual', () => ({
@ -29,16 +28,10 @@ vi.mock('@/service/knowledge/use-import', () => ({
useInvalidPreImportNotionPages: () => mockInvalidPreImportNotionPages,
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
useModalContextSelector: (
selector: (state: {
setShowAccountSettingModal: typeof mockSetShowAccountSettingModal
}) => unknown,
) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
const buildCredential = (
id: string,
@ -200,8 +193,6 @@ describe('Base Notion Page Selector Flow', () => {
await user.click(
screen.getByRole('button', { name: 'common.dataSource.notion.selector.configure' }),
)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
})
})

View File

@ -41,7 +41,6 @@ const render = (ui: ReactElement, options: RenderOptions = {}) => {
}
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockProviderCtx,
@ -64,10 +63,6 @@ vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowPricingModal: mockSetShowPricingModal,
}),
useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) =>
selector({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('@/context/i18n', () => ({

View File

@ -42,10 +42,8 @@ const render = (ui: ReactElement, options: RenderOptions = {}) => {
// ─── Mock state ──────────────────────────────────────────────────────────────
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockRouterPush = vi.fn()
const mockMutateAsync = vi.fn()
const mockSetEducationVerifying = vi.hoisted(() => vi.fn())
// ─── Context mocks ───────────────────────────────────────────────────────────
vi.mock('@/context/provider-context', () => ({
@ -69,10 +67,6 @@ vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowPricingModal: mockSetShowPricingModal,
}),
useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) =>
selector({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
// ─── Service mocks ───────────────────────────────────────────────────────────
@ -102,10 +96,6 @@ vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => vi.fn(),
}))
vi.mock('@/app/education-apply/storage', () => ({
useSetEducationVerifying: () => mockSetEducationVerifying,
}))
// ─── External component mocks ───────────────────────────────────────────────
vi.mock('@/app/education-apply/verify-state-modal', () => ({
default: ({
@ -249,20 +239,6 @@ describe('Education Verification Flow', () => {
expect(mockRouterPush).toHaveBeenCalledWith('/education-apply?token=edu-token-123')
})
})
it('should clear education verifying flag on success', async () => {
mockMutateAsync.mockResolvedValue({ token: 'token-xyz' })
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
const user = userEvent.setup()
render(<PlanComp loc="test" />)
await user.click(screen.getByText(/toVerified/i))
await waitFor(() => {
expect(mockSetEducationVerifying).toHaveBeenCalledWith(null)
})
})
})
// ─── 3. Failed Verification Flow ────────────────────────────────────────

View File

@ -1,5 +1,6 @@
'use client'
import { SettingsModal } from '@/app/components/header/account-setting/settings-modal'
import dynamic from '@/next/dynamic'
const InSiteMessageNotification = dynamic(
@ -25,6 +26,7 @@ export function CommonLayoutGlobalMounts() {
<ReadmePanel />
<GotoAnything />
<WorkflowGeneratorMount />
<SettingsModal />
</>
)
}

View File

@ -1,5 +1,4 @@
import type { ReactNode } from 'react'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ModalContextProvider } from '@/context/modal-context-provider'
@ -12,7 +11,6 @@ export async function ConsoleRuntimeProviders({ children }: { children: ReactNod
return (
<>
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<ProfileBootstrapGate>
<ExternalServiceSync />

View File

@ -1,47 +0,0 @@
import { render, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION } from '@/app/education-apply/constants'
import { useSearchParams } from '@/next/navigation'
import { EducationVerifyActionRecorder } from '../education-verify-action-recorder'
const setEducationVerifyingMock = vi.hoisted(() => vi.fn())
vi.mock('@/next/navigation', () => ({
useSearchParams: vi.fn(),
}))
vi.mock('@/app/education-apply/storage', () => ({
useSetEducationVerifying: () => setEducationVerifyingMock,
}))
const mockUseSearchParams = vi.mocked(useSearchParams)
describe('EducationVerifyActionRecorder', () => {
beforeEach(() => {
vi.clearAllMocks()
window.localStorage.clear()
mockUseSearchParams.mockReturnValue(
new URLSearchParams() as unknown as ReturnType<typeof useSearchParams>,
)
})
it('should store the education verification flag when the callback action is present', async () => {
mockUseSearchParams.mockReturnValue(
new URLSearchParams(
`action=${EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION}`,
) as unknown as ReturnType<typeof useSearchParams>,
)
render(<EducationVerifyActionRecorder />)
await waitFor(() => {
expect(setEducationVerifyingMock).toHaveBeenCalledWith('yes')
})
})
it('should leave localStorage unchanged for unrelated routes', () => {
render(<EducationVerifyActionRecorder />)
expect(setEducationVerifyingMock).not.toHaveBeenCalled()
})
})

View File

@ -5,7 +5,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import {
@ -50,7 +49,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
}))
const mockOnCancel = vi.fn()
const mockOnSave = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockSetSettingsDestination = vi.fn()
const mockUseModelList = vi.fn()
const mockUseModelListAndDefaultModel = vi.fn()
@ -81,11 +80,10 @@ vi.mock('@/service/use-common', async () => ({
useMembers: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs${path}`,
@ -396,9 +394,7 @@ describe('SettingsModal', () => {
)
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.PROVIDER,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider')
})
})

View File

@ -9,6 +9,7 @@ import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { isEqual } from 'es-toolkit/predicate'
import { useQueryState } from 'nuqs'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
@ -17,11 +18,13 @@ import { IndexingType } from '@/app/components/datasets/create/step-two'
import IndexMethod from '@/app/components/datasets/settings/index-method'
import PermissionSelector from '@/app/components/datasets/settings/permission-selector'
import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useDocLink } from '@/context/i18n'
import { DatasetPermission } from '@/models/datasets'
import { updateDatasetSetting } from '@/service/datasets'
@ -56,7 +59,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
const docLink = useDocLink()
const ref = useRef(null)
const isExternal = currentDataset.provider === 'external'
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const [loading, setLoading] = useState(false)
const [localeCurrentDataset, setLocaleCurrentDataset] = useState({ ...currentDataset })
const [topK, setTopK] = useState(localeCurrentDataset?.external_retrieval_model.top_k ?? 2)
@ -315,7 +318,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={() => openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER })}
onClick={() => setSettingsDestination('provider')}
>
{t(($) => $['form.embeddingModelTipLink'], { ns: 'datasetSettings' })}
</button>

View File

@ -6,7 +6,7 @@ import { AppModeEnum, ModelModeType } from '@/types/app'
import { AppACLPermission } from '@/utils/permission'
import { useConfiguration } from '../use-configuration'
const mockSetShowAccountSettingModal = vi.fn()
const mockSetSettingsDestination = vi.fn()
const mockSetShowAppConfigureFeaturesModal = vi.fn()
const mockSetDetailSidebarMode = vi.fn()
const mockHandleMultipleModelConfigsChange = vi.fn()
@ -73,11 +73,10 @@ vi.mock('@/context/permission-state', async () => {
}))
})
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
@ -493,7 +492,7 @@ describe('useConfiguration', () => {
expect(mockFormattingChangedDispatcher).toHaveBeenCalled()
expect(mockHandleMultipleModelConfigsChange).toHaveBeenCalled()
expect(mockSetDetailSidebarMode).toHaveBeenCalledWith('collapse')
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'provider' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider')
expect(mockSetConversationHistoriesRole).toHaveBeenCalledWith({
assistant_prefix: 'bot',
user_prefix: 'user',

View File

@ -29,6 +29,7 @@ import { useBoolean, useGetState } from 'ahooks'
import { clone } from 'es-toolkit/object'
import { produce } from 'immer'
import { useAtomValue } from 'jotai'
import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
@ -39,7 +40,6 @@ import {
import useAdvancedPromptConfig from '@/app/components/app/configuration/hooks/use-advanced-prompt-config'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useSetDetailSidebarMode } from '@/app/components/detail-sidebar/storage'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import {
ModelFeatureEnum,
ModelTypeEnum,
@ -48,7 +48,10 @@ import {
useModelListAndDefaultModelAndCurrentProviderAndModel,
useTextGenerationCurrentProviderAndModelAndModelList,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import {
ANNOTATION_DEFAULT,
DATASET_DEFAULT,
@ -128,7 +131,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const currentUserId = useAtomValue(userProfileIdAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const { appDetail, showAppConfigureFeaturesModal, setShowAppConfigureFeaturesModal } =
useAppStore(
@ -769,7 +772,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
onCloseSelectDataSet: hideSelectDataSet,
onCompletionParamsChange: setCompletionParams,
onConfirmUseGPT4: () => {
openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
setSettingsDestination('provider')
setShowUseGPT4Confirm(false)
},
onEnableMultipleModelDebug: handleDebugWithMultipleModelChange,
@ -777,7 +780,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
onHideDebugPanel: hideDebugPanel,
onModelChange: setModel,
onMultipleModelConfigsChange: handleMultipleModelConfigsChange,
onOpenAccountSettings: () => openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER }),
onOpenAccountSettings: () => setSettingsDestination('provider'),
onOpenDebugPanel: showDebugPanel,
onSaveHistory: (data) => {
setConversationHistoriesRole(data)

View File

@ -3,8 +3,6 @@ import userEvent from '@testing-library/user-event'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import { defaultPlan } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render } from '@/test/console/render'
@ -26,16 +24,13 @@ vi.mock('@/context/provider-context', async (importOriginal) => {
}
})
vi.mock('@/context/modal-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/modal-context')>()
return {
...actual,
useModalContextSelector: vi.fn(),
}
const setSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, setSettingsDestination] }
})
const mockUseProviderContext = vi.mocked(useProviderContext)
const mockUseModalContextSelector = vi.mocked(useModalContextSelector)
function mockProviderPlan(planType: Plan) {
mockUseProviderContext.mockReturnValue(
@ -50,7 +45,6 @@ function mockProviderPlan(planType: Plan) {
}
describe('ArchivedLogsNotice', () => {
const setShowAccountSettingModal = vi.fn()
const renderNotice = () => {
const { wrapper } = createConsoleQueryWrapper({
systemFeatures: { deployment_edition: 'CLOUD' },
@ -61,11 +55,6 @@ describe('ArchivedLogsNotice', () => {
beforeEach(() => {
vi.clearAllMocks()
mockProviderPlan(Plan.professional)
mockUseModalContextSelector.mockImplementation((selector) =>
selector({
setShowAccountSettingModal,
} as unknown as Parameters<typeof selector>[0]),
)
})
it('should show an accessible notice for paid workspace managers', async () => {
@ -78,9 +67,7 @@ describe('ArchivedLogsNotice', () => {
expect(within(notice).getByText('appLog.archives.notice.description')).toBeInTheDocument()
await user.click(within(notice).getByRole('button', { name: 'appLog.archives.notice.action' }))
expect(setShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
})
expect(setSettingsDestination).toHaveBeenCalledWith('workflow-log-archives')
})
it('should not show notice for sandbox workspaces', () => {

View File

@ -3,10 +3,13 @@
import { Button } from '@langgenius/dify-ui/button'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useQueryState } from 'nuqs'
import { useTranslation } from 'react-i18next'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContextSelector } from '@/context/modal-context'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useProviderContext } from '@/context/provider-context'
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
@ -19,9 +22,7 @@ export function ArchivedLogsNotice() {
})
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const { enableBilling, plan } = useProviderContext()
const setShowAccountSettingModal = useModalContextSelector(
(state) => state.setShowAccountSettingModal,
)
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
if (
deploymentEdition !== 'CLOUD' ||
@ -53,11 +54,7 @@ export function ArchivedLogsNotice() {
<Button
variant="primary"
className="shrink-0"
onClick={() =>
setShowAccountSettingModal({
payload: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
})
}
onClick={() => setSettingsDestination('workflow-log-archives')}
>
{t(($) => $['archives.notice.action'], { ns: 'appLog' })}
</Button>

View File

@ -1,9 +1,8 @@
import { cleanup, screen } from '@testing-library/react'
import {
clearAllMocks,
defaultModalContext,
interactions,
mockUseModalContext,
mockSetSettingsDestination,
scenarios,
setDeploymentEdition,
} from './test-utils'
@ -11,15 +10,9 @@ import {
afterEach(cleanup)
describe('APIKeyInfoPanel - Cloud Edition', () => {
const setShowAccountSettingModal = vi.fn()
beforeEach(() => {
clearAllMocks()
setDeploymentEdition('CLOUD')
mockUseModalContext.mockReturnValue({
...defaultModalContext,
setShowAccountSettingModal,
})
})
it('hides the panel when an API key already exists', () => {
@ -28,9 +21,9 @@ describe('APIKeyInfoPanel - Cloud Edition', () => {
})
it('opens provider settings from the primary action', () => {
scenarios.withMockModal(setShowAccountSettingModal)
scenarios.withAPIKeyNotSet()
interactions.clickMainButton()
expect(setShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'provider' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider')
})
it('does not show the self-hosted Cloud link', () => {

View File

@ -1,9 +1,8 @@
import { cleanup, screen } from '@testing-library/react'
import {
clearAllMocks,
defaultModalContext,
interactions,
mockUseModalContext,
mockSetSettingsDestination,
scenarios,
setDeploymentEdition,
textKeys,
@ -12,15 +11,9 @@ import {
afterEach(cleanup)
describe('APIKeyInfoPanel - Community Edition', () => {
const setShowAccountSettingModal = vi.fn()
beforeEach(() => {
clearAllMocks()
setDeploymentEdition('COMMUNITY')
mockUseModalContext.mockReturnValue({
...defaultModalContext,
setShowAccountSettingModal,
})
})
it('hides the panel when an API key already exists', () => {
@ -29,9 +22,9 @@ describe('APIKeyInfoPanel - Community Edition', () => {
})
it('opens provider settings from the primary action', () => {
scenarios.withMockModal(setShowAccountSettingModal)
scenarios.withAPIKeyNotSet()
interactions.clickMainButton()
expect(setShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'provider' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider')
})
it('links self-hosted users to Dify Cloud safely', () => {

View File

@ -1,20 +1,16 @@
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
import type { RenderOptions } from '@testing-library/react'
import type { Mock, MockedFunction } from 'vitest'
import type { ModalContextState } from '@/context/modal-context'
import type { MockedFunction } from 'vitest'
import { fireEvent, screen } from '@testing-library/react'
import { noop } from 'es-toolkit/function'
import { defaultPlan } from '@/app/components/billing/config'
import {
useModalContext as actualUseModalContext,
useModalContextSelector as actualUseModalContextSelector,
} from '@/context/modal-context'
import { useProviderContext as actualUseProviderContext } from '@/context/provider-context'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import APIKeyInfoPanel from '../index'
const { mockRouterPush } = vi.hoisted(() => ({
const { mockRouterPush, mockSetSettingsDestination } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
mockSetSettingsDestination: vi.fn(),
}))
// Mock the modules before importing the functions
@ -22,10 +18,13 @@ vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
useModalContextSelector: vi.fn(),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return {
...actual,
useQueryState: () => [null, mockSetSettingsDestination],
}
})
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
@ -37,11 +36,6 @@ vi.mock('@/next/navigation', () => ({
const mockUseProviderContext = actualUseProviderContext as MockedFunction<
typeof actualUseProviderContext
>
const mockUseModalContext = actualUseModalContext as MockedFunction<typeof actualUseModalContext>
const mockUseModalContextSelector = actualUseModalContextSelector as MockedFunction<
typeof actualUseModalContextSelector
>
// Default mock data
const defaultProviderContext = {
modelProviders: [],
@ -78,25 +72,8 @@ const defaultProviderContext = {
humanInputEmailDeliveryEnabled: false,
}
const defaultModalContext: ModalContextState = {
hasBlockingModalOpen: false,
setShowAccountSettingModal: noop,
setShowModerationSettingModal: noop,
setShowExternalDataToolModal: noop,
setShowPricingModal: noop,
setShowAnnotationFullModal: noop,
setShowModelModal: noop,
setShowExternalKnowledgeAPIModal: noop,
setShowModelLoadBalancingModal: noop,
setShowOpeningModal: noop,
setShowUpdatePluginModal: noop,
setShowEducationExpireNoticeModal: noop,
setShowTriggerEventsLimitModal: noop,
}
type MockOverrides = {
providerContext?: Partial<typeof defaultProviderContext>
modalContext?: Partial<typeof defaultModalContext>
}
type APIKeyInfoPanelRenderOptions = {
@ -112,18 +89,6 @@ function setupMocks(overrides: MockOverrides = {}) {
...defaultProviderContext,
...overrides.providerContext,
})
mockUseModalContext.mockReturnValue({
...defaultModalContext,
...overrides.modalContext,
})
mockUseModalContextSelector.mockImplementation((selector) =>
selector({
...defaultModalContext,
...overrides.modalContext,
}),
)
}
// Custom render function
@ -157,15 +122,6 @@ export const scenarios = {
...overrides,
},
}),
// Render with mock modal function
withMockModal: (mockSetShowAccountSettingModal: Mock, overrides: MockOverrides = {}) =>
renderAPIKeyInfoPanel({
mockOverrides: {
modalContext: { setShowAccountSettingModal: mockSetShowAccountSettingModal },
...overrides,
},
}),
}
// Common user interactions
@ -211,4 +167,4 @@ export function setDeploymentEdition(value: DeploymentEdition) {
}
// Export mock functions for external access
export { defaultModalContext, mockUseModalContext }
export { mockSetSettingsDestination }

View File

@ -4,12 +4,15 @@ import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiCloseLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
@ -21,7 +24,7 @@ const APIKeyInfoPanel: FC = () => {
const isCloud = deploymentEdition === 'CLOUD'
const { isAPIKeySet } = useProviderContext()
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const { t } = useTranslation()
@ -67,7 +70,7 @@ const APIKeyInfoPanel: FC = () => {
<Button
variant="primary"
className="mt-2 space-x-2"
onClick={() => openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER })}
onClick={() => setSettingsDestination('provider')}
>
<div className="text-sm font-medium">
{t(($) => $['apiKeyInfo.setAPIBtn'], { ns: 'appOverview' })}

View File

@ -59,12 +59,10 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
const mockOnClose = vi.fn()
const mockOnSave = vi.fn()
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockUseProviderContext = vi.fn<() => ProviderContextState>()
const buildModalContext = (): ModalContextState => ({
hasBlockingModalOpen: false,
setShowAccountSettingModal: mockSetShowAccountSettingModal,
setShowModerationSettingModal: vi.fn(),
setShowExternalDataToolModal: vi.fn(),
setShowPricingModal: mockSetShowPricingModal,
@ -74,7 +72,6 @@ const buildModalContext = (): ModalContextState => ({
setShowModelLoadBalancingModal: vi.fn(),
setShowOpeningModal: vi.fn(),
setShowUpdatePluginModal: vi.fn(),
setShowEducationExpireNoticeModal: vi.fn(),
setShowTriggerEventsLimitModal: vi.fn(),
})
@ -135,7 +132,6 @@ describe('SettingsModal', () => {
mockOnClose.mockClear()
mockOnSave.mockClear()
mockSetShowPricingModal.mockClear()
mockSetShowAccountSettingModal.mockClear()
mockUseProviderContext.mockReturnValue({
...baseProviderContextValue,
enableBilling: true,
@ -423,7 +419,6 @@ describe('SettingsModal', () => {
fireEvent.click((await screen.findAllByText('billing.upgradeBtn.encourageShort'))[0]!)
expect(mockSetShowPricingModal).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).not.toHaveBeenCalled()
})
it('should hide the upgrade badge for non-sandbox plans', async () => {

View File

@ -25,7 +25,9 @@ import dayjs from 'dayjs'
import { APP_PAGE_LIMIT } from '@/config'
import { WorkflowRunTriggeredFrom } from '@/models/log'
import * as useLogModule from '@/service/use-log'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render } from '@/test/console/render'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import { TIME_PERIOD_MAPPING } from '../filter'
import Logs from '../index'
@ -124,7 +126,15 @@ const mockedUseWorkflowLogs = useLogModule.useWorkflowLogs as MockedFunction<
// ============================================================================
const renderWithQueryClient = (ui: React.ReactElement) => {
return renderWithConsoleQuery(ui)
const { wrapper: QueryWrapper } = createConsoleQueryWrapper()
const { wrapper: NuqsWrapper } = createNuqsTestWrapper()
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryWrapper>
<NuqsWrapper>{children}</NuqsWrapper>
</QueryWrapper>
)
return render(ui, { wrapper })
}
// ============================================================================

View File

@ -26,7 +26,6 @@ vi.mock('@/next/dynamic', () => ({
}))
let documentTitleCalls: string[] = []
let educationInitCalls: number = 0
const mockHandleImportDSL = vi.fn()
const mockHandleImportDSLConfirm = vi.fn()
const mockTrackCreateApp = vi.fn()
@ -66,10 +65,12 @@ vi.mock('@/hooks/use-document-title', () => ({
},
}))
vi.mock('@/app/education-apply/hooks', () => ({
useEducationInit: () => {
educationInitCalls++
},
vi.mock('@/app/education-apply/expire-notice', () => ({
EducationExpireNotice: () => null,
}))
vi.mock('@/app/education-apply/external-action-boundary', () => ({
EducationExternalActionBoundary: ({ children }: { children: ReactNode }) => children,
}))
vi.mock('@/context/permission-state', async () => {
@ -255,7 +256,6 @@ describe('Apps', () => {
beforeEach(() => {
vi.clearAllMocks()
documentTitleCalls = []
educationInitCalls = 0
mockWorkspacePermissionKeys = ['app.create_and_management']
mockSearchParams = new URLSearchParams()
mockReplace.mockClear()

View File

@ -6,7 +6,8 @@ import type { TrackCreateAppParams } from '@/utils/create-app-tracking'
import { useAtomValue } from 'jotai'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useEducationInit } from '@/app/education-apply/hooks'
import { EducationExpireNotice } from '@/app/education-apply/expire-notice'
import { EducationExternalActionBoundary } from '@/app/education-apply/external-action-boundary'
import AppListContext from '@/context/app-list-context'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import useDocumentTitle from '@/hooks/use-document-title'
@ -29,7 +30,7 @@ const ImportFromMarketplaceTemplateModal = dynamic(
{ ssr: false },
)
const Apps = () => {
const AppsContent = () => {
const { t } = useTranslation()
const searchParams = useSearchParams()
const { replace } = useRouter()
@ -39,7 +40,6 @@ const Apps = () => {
const templateDismissedRef = useRef(false)
useDocumentTitle(t(($) => $['menus.apps'], { ns: 'common' }))
useEducationInit()
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(
undefined,
@ -192,64 +192,73 @@ const Apps = () => {
)
return (
<AppListContext.Provider
value={{
currentApp: currentTryAppParams,
isShowTryAppPanel,
setShowTryAppPanel,
controlHideCreateFromTemplatePanel,
}}
>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<List
controlRefreshList={controlRefreshList}
onCreateLearnDify={handleCreateLearnDify}
onTryLearnDify={handleTryLearnDify}
/>
{isShowTryAppPanel && currentTryAppParams && (
<TryApp
appId={currentTryAppParams.appId}
app={currentTryAppParams.app}
categories={currentTryAppParams.app.categories}
onClose={hideTryAppPanel}
onCreate={handleShowFromTryApp}
<>
<EducationExpireNotice />
<AppListContext.Provider
value={{
currentApp: currentTryAppParams,
isShowTryAppPanel,
setShowTryAppPanel,
controlHideCreateFromTemplatePanel,
}}
>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<List
controlRefreshList={controlRefreshList}
onCreateLearnDify={handleCreateLearnDify}
onTryLearnDify={handleTryLearnDify}
/>
)}
{isShowTryAppPanel && currentTryAppParams && (
<TryApp
appId={currentTryAppParams.appId}
app={currentTryAppParams.app}
categories={currentTryAppParams.app.categories}
onClose={hideTryAppPanel}
onCreate={handleShowFromTryApp}
/>
)}
{showDSLConfirmModal && (
<DSLConfirmModal
versions={versions}
onCancel={() => setShowDSLConfirmModal(false)}
onConfirm={onConfirmDSL}
confirmDisabled={isFetching}
/>
)}
{showDSLConfirmModal && (
<DSLConfirmModal
versions={versions}
onCancel={() => setShowDSLConfirmModal(false)}
onConfirm={onConfirmDSL}
confirmDisabled={isFetching}
/>
)}
{isShowCreateModal && (
<CreateAppModal
appIconType={currApp?.app.icon_type || 'emoji'}
appIcon={currApp?.app.icon || ''}
appIconBackground={currApp?.app.icon_background || ''}
appIconUrl={currApp?.app.icon_url}
appName={currApp?.app.name || ''}
appDescription={currApp?.app.description || ''}
show
onConfirm={onCreate}
confirmDisabled={isFetching}
onHide={() => setIsShowCreateModal(false)}
/>
)}
{isShowCreateModal && (
<CreateAppModal
appIconType={currApp?.app.icon_type || 'emoji'}
appIcon={currApp?.app.icon || ''}
appIconBackground={currApp?.app.icon_background || ''}
appIconUrl={currApp?.app.icon_url}
appName={currApp?.app.name || ''}
appDescription={currApp?.app.description || ''}
show
onConfirm={onCreate}
confirmDisabled={isFetching}
onHide={() => setIsShowCreateModal(false)}
/>
)}
{canCreateApp && templateId && !templateDismissedRef.current && (
<ImportFromMarketplaceTemplateModal
templateId={templateId}
onClose={handleCloseTemplateModal}
onConfirm={handleMarketplaceTemplateConfirm}
/>
)}
</div>
</AppListContext.Provider>
{canCreateApp && templateId && !templateDismissedRef.current && (
<ImportFromMarketplaceTemplateModal
templateId={templateId}
onClose={handleCloseTemplateModal}
onConfirm={handleMarketplaceTemplateConfirm}
/>
)}
</div>
</AppListContext.Provider>
</>
)
}
const Apps = () => (
<EducationExternalActionBoundary>
<AppsContent />
</EducationExternalActionBoundary>
)
export default Apps

View File

@ -13,12 +13,11 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
},
}))
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
let mockCodeBasedExtensions: { data: { data: Record<string, unknown>[] } } = { data: { data: [] } }
let mockModelProvidersData: {
@ -52,10 +51,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/declaration
CustomConfigurationStatusEnum: { active: 'active' },
}))
vi.mock('@/app/components/header/account-setting/constants', () => ({
ACCOUNT_SETTING_TAB: { PROVIDER: 'provider' },
}))
vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({
ApiBasedExtensionSelector: ({ onChange }: { value: string; onChange: (v: string) => void }) => (
<div data-testid="api-selector">
@ -668,12 +663,7 @@ describe('ModerationSettingModal', () => {
fireEvent.click(screen.getByText(/settings\.provider/))
expect(mockSetShowAccountSettingModal).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'provider',
onCancelCallback: expect.any(Function),
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider')
})
it('should not save when OpenAI type is selected but not configured', async () => {

View File

@ -6,13 +6,16 @@ import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryState } from 'nuqs'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { ApiBasedExtensionSelector } from '@/app/components/header/account-setting/api-based-extension-page/selector'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { CustomConfigurationStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useDocLink, useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import { useCodeBasedExtensions, useModelProviders } from '@/service/use-common'
@ -56,14 +59,10 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ data, onCance
const { t } = useTranslation()
const docLink = useDocLink()
const locale = useLocale()
const {
data: modelProviders,
isPending: isLoading,
refetch: refetchModelProviders,
} = useModelProviders()
const { data: modelProviders, isPending: isLoading } = useModelProviders()
const localeDataRef = useRef<ModerationConfig>(data)
const [localeData, setLocaleData] = useState<ModerationConfig>(data)
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const updateLocaleData = useCallback(
(
update: ModerationConfig | ((current: ModerationConfig) => ModerationConfig),
@ -78,10 +77,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ data, onCance
[],
)
const handleOpenSettingsModal = () => {
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.PROVIDER,
onCancelCallback: refetchModelProviders,
})
setSettingsDestination('provider')
}
const { data: codeBasedExtensionList } = useCodeBasedExtensions('moderation')
const openaiProvider = modelProviders?.data.find(

View File

@ -3,9 +3,7 @@ import type { DataSourceNotionWorkspace } from '@/models/common'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
import { useModalContext, useModalContextSelector } from '@/context/modal-context'
import {
useInvalidPreImportNotionPages,
usePreImportNotionPages,
@ -19,10 +17,11 @@ vi.mock('@/service/knowledge/use-import', () => ({
useInvalidPreImportNotionPages: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
useModalContextSelector: vi.fn(),
}))
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
const buildCredential = (
id: string,
@ -110,21 +109,10 @@ const createPreImportResult = ({
}) as ReturnType<typeof usePreImportNotionPages>
describe('NotionPageSelector Base', () => {
const mockSetShowAccountSettingModal = vi.fn()
const mockInvalidPreImportNotionPages = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useModalContext).mockReturnValue({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
} as unknown as ReturnType<typeof useModalContext>)
vi.mocked(useModalContextSelector).mockImplementation((selector) => {
// Execute the selector to get branch/func coverage for the inline function
selector({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
} as unknown as Parameters<Parameters<typeof useModalContextSelector>[0]>[0])
return mockSetShowAccountSettingModal
})
vi.mocked(useInvalidPreImportNotionPages).mockReturnValue(mockInvalidPreImportNotionPages)
})
@ -145,9 +133,7 @@ describe('NotionPageSelector Base', () => {
const connectButton = screen.getByRole('button', { name: 'datasetCreation.stepOne.connect' })
await user.click(connectButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
})
it('should render page selector and allow selecting a page tree', async () => {
@ -232,9 +218,7 @@ describe('NotionPageSelector Base', () => {
await user.click(
screen.getByRole('button', { name: 'common.dataSource.notion.selector.configure' }),
)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
})
it('should preview a page and call onPreview when callback is provided', async () => {

View File

@ -5,10 +5,13 @@ import type {
DataSourceNotionWorkspace,
NotionPage,
} from '@/models/common'
import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import {
useInvalidPreImportNotionPages,
usePreImportNotionPages,
@ -43,7 +46,7 @@ const NotionPageSelector = ({
}: NotionPageSelectorProps) => {
const { t } = useTranslation()
const [searchValue, setSearchValue] = useState('')
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const invalidPreImportNotionPages = useInvalidPreImportNotionPages()
@ -162,8 +165,8 @@ const NotionPageSelector = ({
)
const handleConfigureNotion = useCallback(() => {
openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE })
}, [openIntegrationsSetting])
setSettingsDestination('data-source')
}, [setSettingsDestination])
if (isFetchingNotionPagesError) {
return <NotionConnector onSetting={handleConfigureNotion} />

View File

@ -6,18 +6,15 @@ import { useSuspenseQuery } from '@tanstack/react-query'
import { useUnmountedRef } from 'ahooks'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiAggregate, TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
import UsageInfo from '@/app/components/billing/usage-info'
import { useSetEducationVerifying } from '@/app/education-apply/storage'
import VerifyStateModal from '@/app/education-apply/verify-state-modal'
import { userProfileEmailAtom } from '@/context/account-state'
import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { usePathname, useRouter } from '@/next/navigation'
import { useRouter } from '@/next/navigation'
import { useEducationVerify } from '@/service/use-education'
import { getDaysUntilEndOfMonth } from '@/utils/time'
import Loading from '../../base/icons/src/public/thought/Loading'
@ -41,7 +38,6 @@ const PlanComp: FC<Props> = ({ loc }) => {
})
const isCloudEdition = deploymentEdition === 'CLOUD'
const router = useRouter()
const path = usePathname()
const userProfileEmail = useAtomValue(userProfileEmailAtom)
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const { plan, enableEducationPlan, allowRefreshEducationVerify, isEducationAccount } =
@ -65,14 +61,11 @@ const PlanComp: FC<Props> = ({ loc }) => {
const [showModal, setShowModal] = React.useState(false)
const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount()
const { mutateAsync, isPending } = useEducationVerify()
const setShowAccountSettingModal = useModalContextSelector((s) => s.setShowAccountSettingModal)
const setEducationVerifying = useSetEducationVerifying()
const unmountedRef = useUnmountedRef()
const handleVerify = () => {
if (isPending) return
mutateAsync()
.then((res) => {
setEducationVerifying(null)
if (unmountedRef.current) return
router.push(`/education-apply?token=${res.token}`)
})
@ -80,10 +73,6 @@ const PlanComp: FC<Props> = ({ loc }) => {
setShowModal(true)
})
}
useEffect(() => {
// setShowAccountSettingModal would prevent navigation
if (path.startsWith('/education-apply')) setShowAccountSettingModal(null)
}, [path, setShowAccountSettingModal])
return (
<div className="relative rounded-2xl border-[0.5px] border-effects-highlight-lightmode-off bg-background-section-burn">
<div className="p-6 pb-2">

View File

@ -79,23 +79,11 @@ vi.mock('@/context/system-features-state', async () => {
}))
})
// Mock modal context
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
useModalContextSelector: (
selector: (state: {
setShowAccountSettingModal: typeof mockSetShowAccountSettingModal
}) => unknown,
) => {
const state = {
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}
return selector(state)
},
}))
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
// Mock dataset detail context
let mockDatasetDetail: DataSet | undefined
@ -650,7 +638,7 @@ describe('DatasetUpdateForm', () => {
fireEvent.click(screen.getByTestId('step-one-setting'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
})
it('should open provider settings when onSetting is called from StepTwo', () => {
@ -659,7 +647,7 @@ describe('DatasetUpdateForm', () => {
fireEvent.click(screen.getByTestId('step-two-setting'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'provider' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider')
})
it('should update crawl options when onCrawlOptionsChange is called', () => {

View File

@ -9,13 +9,16 @@ import type {
import type { RETRIEVE_METHOD } from '@/types/app'
import { produce } from 'immer'
import { useAtomValue } from 'jotai'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { userProfileIdAtom } from '@/context/account-state'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import {
@ -51,7 +54,7 @@ const DEFAULT_CRAWL_OPTIONS: CrawlOptions = {
const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => {
const { t } = useTranslation()
const router = useRouter()
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const datasetDetail = useDatasetDetailContextWithSelector((state) => state.dataset)
const currentUserId = useAtomValue(userProfileIdAtom)
const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom)
@ -159,9 +162,7 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => {
{step === 1 && (
<StepOne
authedDataSourceList={dataSourceList?.result || []}
onSetting={() =>
openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE })
}
onSetting={() => setSettingsDestination('data-source')}
datasetId={datasetId}
dataSourceType={dataSourceType}
dataSourceTypeDisable={!!datasetDetail?.data_source_type}
@ -185,7 +186,7 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => {
{step === 2 && (!datasetId || (datasetId && !!datasetDetail)) && (
<StepTwo
isAPIKeySet={!!embeddingsDefaultModel}
onSetting={() => openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER })}
onSetting={() => setSettingsDestination('provider')}
indexingType={datasetDetail?.indexing_technique}
datasetId={datasetId}
dataSourceType={dataSourceType}

View File

@ -5,9 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
import Website from '../index'
const { mockRouterPush, mockSetShowAccountSettingModal } = vi.hoisted(() => ({
const { mockRouterPush, mockSetSettingsDestination } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
mockSetShowAccountSettingModal: vi.fn(),
mockSetSettingsDestination: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
@ -16,11 +16,10 @@ vi.mock('@/next/navigation', () => ({
}),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
vi.mock('../index.module.css', () => ({
default: {
@ -272,7 +271,7 @@ describe('Website', () => {
const configButton = screen.getByTestId('no-data-config-button')
fireEvent.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
expect(mockRouterPush).not.toHaveBeenCalled()
})
})

View File

@ -10,9 +10,9 @@ import FireCrawl from '../index'
// Mock API service
const mockCreateFirecrawlTask = vi.fn()
const mockCheckFirecrawlTaskStatus = vi.fn()
const { mockRouterPush, mockSetShowAccountSettingModal } = vi.hoisted(() => ({
const { mockRouterPush, mockSetSettingsDestination } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
mockSetShowAccountSettingModal: vi.fn(),
mockSetSettingsDestination: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
@ -26,12 +26,10 @@ vi.mock('@/service/datasets', () => ({
checkFirecrawlTaskStatus: (...args: unknown[]) => mockCheckFirecrawlTaskStatus(...args),
}))
// Mock modal context
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
// Mock sleep utility to speed up tests
vi.mock('@/utils', () => ({
@ -178,7 +176,7 @@ describe('FireCrawl', () => {
const configButton = screen.getByText(/configureFirecrawl/i)
await user.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
expect(mockRouterPush).not.toHaveBeenCalled()
})
})

View File

@ -2,11 +2,14 @@
import type { FC } from 'react'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { checkFirecrawlTaskStatus, createFirecrawlTask } from '@/service/datasets'
import { sleep } from '@/utils'
import CrawledResult from '../base/crawled-result'
@ -69,12 +72,10 @@ const FireCrawl: FC<Props> = ({
isMountedRef.current = false
}
}, [])
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const handleSetting = useCallback(() => {
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
}, [openIntegrationsSetting])
setSettingsDestination('data-source')
}, [setSettingsDestination])
const checkValid = useCallback(
(url: string) => {
let errorMsg = ''

View File

@ -3,11 +3,14 @@ import type { FC } from 'react'
import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import {
ENABLE_WEBSITE_FIRECRAWL,
ENABLE_WEBSITE_JINAREADER,
@ -42,7 +45,7 @@ const Website: FC<Props> = ({
authedDataSourceList,
}) => {
const { t } = useTranslation()
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const [selectedProvider, setSelectedProvider] = useState<DataSourceProvider>(
DataSourceProvider.jinaReader,
)
@ -62,10 +65,8 @@ const Website: FC<Props> = ({
)
const handleOnConfig = useCallback(() => {
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
}, [openIntegrationsSetting])
setSettingsDestination('data-source')
}, [setSettingsDestination])
const source = availableProviders.find((source) => source.provider === selectedProvider)

View File

@ -6,9 +6,9 @@ import { checkJinaReaderTaskStatus, createJinaReaderTask } from '@/service/datas
import { sleep } from '@/utils'
import JinaReader from '../index'
const { mockRouterPush, mockSetShowAccountSettingModal } = vi.hoisted(() => ({
const { mockRouterPush, mockSetSettingsDestination } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
mockSetShowAccountSettingModal: vi.fn(),
mockSetSettingsDestination: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
@ -26,12 +26,10 @@ vi.mock('@/utils', () => ({
sleep: vi.fn(() => Promise.resolve()),
}))
// Mock modal context
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
// Mock doc link context
vi.mock('@/context/i18n', () => ({
@ -400,14 +398,14 @@ describe('JinaReader', () => {
const configButton = screen.getByText('datasetCreation.stepOne.website.configureJinaReader')
fireEvent.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledTimes(1)
expect(mockSetSettingsDestination).toHaveBeenCalledTimes(1)
expect(mockRouterPush).not.toHaveBeenCalled()
// Rerender and click again
rerender(<JinaReader {...props} />)
fireEvent.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledTimes(2)
expect(mockSetSettingsDestination).toHaveBeenCalledTimes(2)
expect(mockRouterPush).not.toHaveBeenCalled()
})
@ -446,7 +444,7 @@ describe('JinaReader', () => {
const configButton = screen.getByText('datasetCreation.stepOne.website.configureJinaReader')
await userEvent.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
expect(mockRouterPush).not.toHaveBeenCalled()
})

View File

@ -2,11 +2,14 @@
import type { FC } from 'react'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { checkJinaReaderTaskStatus, createJinaReaderTask } from '@/service/datasets'
import { sleep } from '@/utils'
import CrawledResult from '../base/crawled-result'
@ -44,12 +47,10 @@ const JinaReader: FC<Props> = ({
const { t } = useTranslation()
const [step, setStep] = useState<Step>(Step.init)
const [controlFoldOptions, setControlFoldOptions] = useState<number>(0)
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const handleSetting = useCallback(() => {
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
}, [openIntegrationsSetting])
setSettingsDestination('data-source')
}, [setSettingsDestination])
const checkValid = useCallback(
(url: string) => {
let errorMsg = ''

View File

@ -6,9 +6,9 @@ import { checkWatercrawlTaskStatus, createWatercrawlTask } from '@/service/datas
import { sleep } from '@/utils'
import WaterCrawl from '../index'
const { mockRouterPush, mockSetShowAccountSettingModal } = vi.hoisted(() => ({
const { mockRouterPush, mockSetSettingsDestination } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
mockSetShowAccountSettingModal: vi.fn(),
mockSetSettingsDestination: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
@ -26,12 +26,10 @@ vi.mock('@/utils', () => ({
sleep: vi.fn(() => Promise.resolve()),
}))
// Mock modal context
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
// Mock i18n context
vi.mock('@/context/i18n', () => ({
@ -396,14 +394,14 @@ describe('WaterCrawl', () => {
const configButton = screen.getByText('datasetCreation.stepOne.website.configureWatercrawl')
fireEvent.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledTimes(1)
expect(mockSetSettingsDestination).toHaveBeenCalledTimes(1)
expect(mockRouterPush).not.toHaveBeenCalled()
// Rerender and click again
rerender(<WaterCrawl {...props} />)
fireEvent.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledTimes(2)
expect(mockSetSettingsDestination).toHaveBeenCalledTimes(2)
expect(mockRouterPush).not.toHaveBeenCalled()
})
@ -448,7 +446,7 @@ describe('WaterCrawl', () => {
const configButton = screen.getByText('datasetCreation.stepOne.website.configureWatercrawl')
await userEvent.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
expect(mockRouterPush).not.toHaveBeenCalled()
})

View File

@ -2,11 +2,14 @@
import type { FC } from 'react'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { checkWatercrawlTaskStatus, createWatercrawlTask } from '@/service/datasets'
import { sleep } from '@/utils'
import CrawledResult from '../base/crawled-result'
@ -49,12 +52,10 @@ const WaterCrawl: FC<Props> = ({
const { t } = useTranslation()
const [step, setStep] = useState<Step>(Step.init)
const controlFoldOptions = STEP_CONTROL_FOLD_OPTIONS[step]
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const handleSetting = useCallback(() => {
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
}, [openIntegrationsSetting])
setSettingsDestination('data-source')
}, [setSettingsDestination])
const checkValid = useCallback(
(url: string) => {
let errorMsg = ''

View File

@ -18,15 +18,11 @@ vi.mock('@/context/dataset-detail', () => ({
selector({ dataset: { pipeline_id: mockPipelineId } }),
}))
// Mock modal context - context provider requires mocking
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) =>
selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }),
}))
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
// Mock ssePost - API service requires mocking
const { mockSsePost } = vi.hoisted(() => ({
@ -237,7 +233,7 @@ describe('OnlineDocuments', () => {
// Reset context values
mockPipelineId = 'pipeline-123'
mockSetShowAccountSettingModal.mockClear()
mockSetSettingsDestination.mockClear()
// Default mock return values
mockUseGetDataSourceAuth.mockReturnValue({
@ -612,9 +608,7 @@ describe('OnlineDocuments', () => {
fireEvent.click(screen.getByTestId('header-config-btn'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'data-source',
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
})
})
@ -709,9 +703,7 @@ describe('OnlineDocuments', () => {
fireEvent.click(screen.getByTestId('header-config-btn'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'data-source',
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
})
it('should handle credential change', () => {

View File

@ -2,12 +2,15 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-so
import type { DataSourceNotionPageMap, DataSourceNotionWorkspace } from '@/models/common'
import type { DataSourceNodeCompletedResponse, DataSourceNodeErrorResponse } from '@/types/pipeline'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import Loading from '@/app/components/base/loading'
import SearchInput from '@/app/components/base/notion-page-selector/search-input'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDocLink } from '@/context/i18n'
import { DatasourceType } from '@/models/pipeline'
@ -35,7 +38,7 @@ const OnlineDocuments = ({
}: OnlineDocumentsProps) => {
const docLink = useDocLink()
const pipelineId = useDatasetDetailContextWithSelector((s) => s.dataset?.pipeline_id)
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const { documentsData, searchValue, selectedPagesId, currentCredentialId } =
useDataSourceStoreWithSelector(
useShallow((state) => ({
@ -141,10 +144,8 @@ const OnlineDocuments = ({
)
const handleSetting = useCallback(() => {
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
}, [openIntegrationsSetting])
setSettingsDestination('data-source')
}, [setSettingsDestination])
return (
<div className="flex flex-col gap-y-2">

View File

@ -2,14 +2,13 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-so
import type { OnlineDriveFile } from '@/models/pipeline'
import type { DataSourceNodeCompletedResponse, DataSourceNodeErrorResponse } from '@/types/pipeline'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { DatasourceType, OnlineDriveFileType } from '@/models/pipeline'
import OnlineDrive from '../index'
const mocks = vi.hoisted(() => ({
pipelineId: 'pipeline-123' as string | undefined,
docLink: vi.fn((path?: string) => `https://docs.example.com${path || ''}`),
openIntegrationsSetting: vi.fn(),
setSettingsDestination: vi.fn(),
ssePost: vi.fn(),
toastError: vi.fn(),
useGetDataSourceAuth: vi.fn(),
@ -49,9 +48,10 @@ vi.mock('@/context/dataset-detail', () => ({
) => selector({ dataset: { pipeline_id: mocks.pipelineId } }),
}))
vi.mock('@/app/components/header/account-setting/use-integrations-setting', () => ({
useIntegrationsSetting: () => mocks.openIntegrationsSetting,
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mocks.setSettingsDestination] }
})
vi.mock('@/service/base', () => ({
ssePost: (...args: unknown[]) => mocks.ssePost(...args),
@ -432,9 +432,7 @@ describe('OnlineDrive', () => {
fireEvent.click(screen.getByRole('button', { name: 'Configure' }))
fireEvent.click(screen.getByRole('button', { name: 'Change Credential' }))
expect(mocks.openIntegrationsSetting).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
expect(mocks.setSettingsDestination).toHaveBeenCalledWith('data-source')
expect(onCredentialChange).toHaveBeenCalledWith('credential-2')
})
})

View File

@ -3,10 +3,13 @@ import type { OnlineDriveFile } from '@/models/pipeline'
import type { DataSourceNodeCompletedResponse, DataSourceNodeErrorResponse } from '@/types/pipeline'
import { toast } from '@langgenius/dify-ui/toast'
import { produce } from 'immer'
import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDocLink } from '@/context/i18n'
import { DatasourceType, OnlineDriveFileType } from '@/models/pipeline'
@ -35,7 +38,7 @@ const OnlineDrive = ({
const docLink = useDocLink()
const [isInitialMount, setIsInitialMount] = useState(true)
const pipelineId = useDatasetDetailContextWithSelector((s) => s.dataset?.pipeline_id)
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const {
nextPageParameters,
breadcrumbs,
@ -202,10 +205,8 @@ const OnlineDrive = ({
)
const handleSetting = useCallback(() => {
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
}, [openIntegrationsSetting])
setSettingsDestination('data-source')
}, [setSettingsDestination])
return (
<div className="flex flex-col gap-y-2">

View File

@ -2,7 +2,6 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-so
import type { CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { CrawlStep } from '@/models/datasets'
import WebsiteCrawl from '../index'
@ -20,16 +19,11 @@ vi.mock('@/context/dataset-detail', () => ({
) => selector({ dataset: { pipeline_id: mockPipelineId } }),
}))
// Mock modal context - context provider requires mocking
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
useModalContextSelector: (
selector: (s: { setShowAccountSettingModal: typeof mockSetShowAccountSettingModal }) => unknown,
) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }),
}))
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
// Mock ssePost - API service requires mocking
const { mockSsePost } = vi.hoisted(() => ({
@ -257,7 +251,7 @@ describe('WebsiteCrawl', () => {
// Reset context values
mockPipelineId = 'pipeline-123'
mockSetShowAccountSettingModal.mockClear()
mockSetSettingsDestination.mockClear()
// Default mock return values
mockUseGetDataSourceAuth.mockReturnValue({
@ -616,9 +610,7 @@ describe('WebsiteCrawl', () => {
fireEvent.click(screen.getByTestId('header-config-btn'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
})
it('should have stable handleCredentialChange that resets state', () => {
@ -653,9 +645,7 @@ describe('WebsiteCrawl', () => {
fireEvent.click(screen.getByTestId('header-config-btn'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
})
it('should handle credential change', () => {

View File

@ -6,12 +6,15 @@ import type {
DataSourceNodeErrorResponse,
DataSourceNodeProcessingResponse,
} from '@/types/pipeline'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDocLink } from '@/context/i18n'
import { CrawlStep } from '@/models/datasets'
@ -52,7 +55,7 @@ const WebsiteCrawl = ({
const [crawledNum, setCrawledNum] = useState(0)
const [crawlErrorMessage, setCrawlErrorMessage] = useState('')
const pipelineId = useDatasetDetailContextWithSelector((s) => s.dataset?.pipeline_id)
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const { crawlResult, step, checkedCrawlResult, previewIndex, currentCredentialId } =
useDataSourceStoreWithSelector(
useShallow((state) => ({
@ -158,10 +161,8 @@ const WebsiteCrawl = ({
)
const handleSetting = useCallback(() => {
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
}, [openIntegrationsSetting])
setSettingsDestination('data-source')
}, [setSettingsDestination])
const handleCredentialChange = useCallback(
(credentialId: string) => {

View File

@ -4,7 +4,7 @@ import DocumentSettings from '../document-settings'
const mockPush = vi.fn()
const mockBack = vi.fn()
const mockOpenIntegrationsSetting = vi.fn()
const mockSetSettingsDestination = vi.fn()
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
@ -107,9 +107,10 @@ vi.mock('@/app/components/datasets/create/step-two', () => ({
),
}))
vi.mock('@/app/components/header/account-setting/use-integrations-setting', () => ({
useIntegrationsSetting: () => mockOpenIntegrationsSetting,
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
describe('DocumentSettings', () => {
beforeEach(() => {
@ -205,7 +206,7 @@ describe('DocumentSettings', () => {
fireEvent.click(screen.getByTestId('setting-btn'))
expect(mockOpenIntegrationsSetting).toHaveBeenCalledWith({ payload: 'provider' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider')
})
})

View File

@ -10,16 +10,19 @@ import type {
UploadFileIdInfo,
WebsiteCrawlInfo,
} from '@/models/datasets'
import { useQueryState } from 'nuqs'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import StepTwo from '@/app/components/datasets/create/step-two'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import DatasetDetailContext from '@/context/dataset-detail'
import { useRouter } from '@/next/navigation'
import {
@ -36,12 +39,12 @@ type DocumentSettingsProps = {
const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => {
const { t } = useTranslation()
const router = useRouter()
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const { indexingTechnique, dataset } = useContext(DatasetDetailContext)
const { data: embeddingsDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding)
const handleOpenAccountSetting = useCallback(() => {
openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}, [openIntegrationsSetting])
setSettingsDestination('provider')
}, [setSettingsDestination])
const invalidDocumentList = useInvalidDocumentList(datasetId)
const invalidDocumentDetail = useInvalidDocumentDetail()

View File

@ -1,18 +0,0 @@
'use client'
import { useEffect } from 'react'
import { EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION } from '@/app/education-apply/constants'
import { useSetEducationVerifying } from '@/app/education-apply/storage'
import { useSearchParams } from '@/next/navigation'
export function EducationVerifyActionRecorder() {
const searchParams = useSearchParams()
const setEducationVerifying = useSetEducationVerifying()
useEffect(() => {
if (searchParams.get('action') === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
setEducationVerifying('yes')
}, [searchParams, setEducationVerifying])
return null
}

View File

@ -8,7 +8,6 @@ import { toast } from '@langgenius/dify-ui/toast'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import { getDocDownloadUrl } from '@/service/common'
@ -40,9 +39,14 @@ vi.mock('@/utils/download', () => ({
downloadUrl: vi.fn(),
}))
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
describe('Compliance', () => {
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const toastSuccessSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success')
const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
let queryClient: QueryClient
@ -66,7 +70,6 @@ describe('Compliance', () => {
})
vi.mocked(useModalContext).mockReturnValue({
setShowPricingModal: mockSetShowPricingModal,
setShowAccountSettingModal: mockSetShowAccountSettingModal,
} as unknown as ModalContextState)
})
@ -224,9 +227,7 @@ describe('Compliance', () => {
fireEvent.click(upgradeBadges[0]!)
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.BILLING,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('billing')
})
// isPending branches: spinner visible, loading button contract, guard blocks second call

View File

@ -7,7 +7,6 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderToString } from 'react-dom/server'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import AccountSection from '@/app/components/main-nav/components/account-section'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
@ -82,6 +81,12 @@ vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
vi.mock('@/service/use-common', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/use-common')>()),
useLogout: vi.fn(),
@ -176,7 +181,6 @@ const setConsoleState = (value: ConsoleStateFixture) => {
describe('AccountDropdown', () => {
const mockPush = vi.fn()
const mockLogout = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
let deploymentEdition: GetSystemFeaturesResponse['deployment_edition'] = 'COMMUNITY'
const renderWithRouter = (
@ -207,9 +211,7 @@ describe('AccountDropdown', () => {
isEducationAccount: false,
plan: { type: Plan.sandbox },
} as unknown as ProviderContextState)
vi.mocked(useModalContext).mockReturnValue({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
} as unknown as ModalContextState)
vi.mocked(useModalContext).mockReturnValue({} as unknown as ModalContextState)
vi.mocked(useLogout).mockReturnValue({
mutateAsync: mockLogout,
} as unknown as ReturnType<typeof useLogout>)
@ -288,14 +290,14 @@ describe('AccountDropdown', () => {
})
describe('Settings and Support', () => {
it('should trigger setShowAccountSettingModal when settings is clicked', () => {
it('should open member settings when settings is clicked', () => {
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('common.userProfile.settings'))
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalled()
expect(mockSetSettingsDestination).toHaveBeenCalledWith('members')
})
it('should open preferences from the account dropdown', () => {
@ -305,9 +307,7 @@ describe('AccountDropdown', () => {
fireEvent.click(screen.getByText('common.settings.preferences'))
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.PREFERENCES,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('preferences')
})
it('should show Appearance after Preferences in the main nav account dropdown', () => {

View File

@ -10,10 +10,14 @@ import {
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useMutation } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { getDocDownloadUrl } from '@/service/common'
@ -95,7 +99,8 @@ type ComplianceDocRowItemProps = {
function ComplianceDocRowItem({ icon, label, docName }: ComplianceDocRowItemProps) {
const { t } = useTranslation()
const { plan } = useProviderContext()
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
const { setShowPricingModal } = useModalContext()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const isFreePlan = plan.type === Plan.sandbox
const { isPending, mutate: downloadCompliance } = useMutation({
@ -128,13 +133,13 @@ function ComplianceDocRowItem({ icon, label, docName }: ComplianceDocRowItemProp
}
if (isFreePlan) setShowPricingModal()
else setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
else setSettingsDestination('billing')
}, [
downloadCompliance,
isCurrentPlanCanDownload,
isFreePlan,
isPending,
setShowAccountSettingModal,
setSettingsDestination,
setShowPricingModal,
])

View File

@ -11,13 +11,16 @@ import {
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useQueryState } from 'nuqs'
import { useTranslation } from 'react-i18next'
import PremiumBadge from '@/app/components/base/premium-badge'
import ThemeSwitcher from '@/app/components/base/theme-switcher'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { userProfileAtom } from '@/context/account-state'
import { useDocLink } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { langGeniusVersionInfoAtom } from '@/context/version-state'
import { isCurrentWorkspaceOwnerAtom } from '@/context/workspace-state'
@ -114,7 +117,7 @@ export function DefaultMenuContent({
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
const isCurrentWorkspaceOwner = useAtomValue(isCurrentWorkspaceOwnerAtom)
const { isEducationAccount } = useProviderContext()
const { setShowAccountSettingModal } = useModalContext()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
return (
<>
@ -145,7 +148,7 @@ export function DefaultMenuContent({
<AccountMenuActionItem
iconClassName="i-ri-settings-3-line"
label={t(($) => $['userProfile.settings'], { ns: 'common' })}
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })}
onClick={() => setSettingsDestination('members')}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator className="my-0! bg-divider-subtle" />

View File

@ -12,11 +12,6 @@ import { useAtomValue } from 'jotai'
import { useState, useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next'
import { resetUser } from '@/app/components/base/amplitude/utils'
import {
useSetEducationExpiredHasNoticed,
useSetEducationReverifyHasNoticed,
useSetEducationReverifyPrevExpireAt,
} from '@/app/education-apply/storage'
import { userProfileAtom } from '@/context/account-state'
import { langGeniusVersionInfoAtom } from '@/context/version-state'
import { useRouter } from '@/next/navigation'
@ -49,9 +44,6 @@ export default function AppSelector({ trigger, variant = 'default' }: AccountDro
const { t } = useTranslation()
const userProfile = useAtomValue(userProfileAtom)
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
const clearEducationReverifyPrevExpireAt = useSetEducationReverifyPrevExpireAt()
const clearEducationReverifyHasNoticed = useSetEducationReverifyHasNoticed()
const clearEducationExpiredHasNoticed = useSetEducationExpiredHasNoticed()
const { mutateAsync: logout } = useLogout()
@ -60,11 +52,6 @@ export default function AppSelector({ trigger, variant = 'default' }: AccountDro
resetUser()
// Tokens are now stored in cookies and cleared by backend
// To avoid use other account's education notice info
clearEducationReverifyPrevExpireAt(null)
clearEducationReverifyHasNoticed(null)
clearEducationExpiredHasNoticed(null)
router.push('/signin')
}

View File

@ -18,10 +18,13 @@ import {
} from '@langgenius/dify-ui/dropdown-menu'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { useQueryState } from 'nuqs'
import { useTranslation } from 'react-i18next'
import PremiumBadge from '@/app/components/base/premium-badge'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useProviderContext } from '@/context/provider-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import Link from '@/next/link'
@ -107,7 +110,7 @@ export function MainNavMenuContent({ onLogout }: MainNavMenuContentProps) {
select: (data) => data.profile,
})
const { isEducationAccount } = useProviderContext()
const { setShowAccountSettingModal } = useModalContext()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
return (
<>
@ -156,7 +159,7 @@ export function MainNavMenuContent({ onLogout }: MainNavMenuContentProps) {
</DropdownMenuLinkItem>
<DropdownMenuItem
className="mx-0 h-8 gap-1 px-3 py-1"
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PREFERENCES })}
onClick={() => setSettingsDestination('preferences')}
>
<MenuItemContent
iconClassName="i-ri-equalizer-2-line"

View File

@ -1,15 +1,26 @@
import { isValidSettingsTab } from '../constants'
import {
isAccountSettingDestination,
isIntegrationSettingDestination,
settingsQueryParser,
} from '../query-params'
describe('isValidSettingsTab', () => {
describe('settingsQueryParser', () => {
it.each([
['roles-and-permissions', true],
['preferences', true],
['provider', true],
['mcp', true],
['agent-strategy', true],
['invalid', false],
[null, false],
])('validates %s', (tab, expected) => {
expect(isValidSettingsTab(tab)).toBe(expected)
['roles-and-permissions', 'roles-and-permissions'],
['preferences', 'preferences'],
['provider', 'provider'],
['mcp', 'mcp'],
['agent-strategy', 'agent-strategy'],
['invalid', null],
['', null],
])('parses %s', (value, expected) => {
expect(settingsQueryParser.parse(value)).toBe(expected)
})
it('keeps account and integration destinations in their owning branches', () => {
expect(isAccountSettingDestination('members')).toBe(true)
expect(isAccountSettingDestination('provider')).toBe(false)
expect(isIntegrationSettingDestination('provider')).toBe(true)
expect(isIntegrationSettingDestination('members')).toBe(false)
})
})

View File

@ -1,7 +1,6 @@
import type { AccountSettingTab } from '../constants'
import type { ConsoleStateFixture } from '@/test/console/state-fixture'
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
@ -9,7 +8,6 @@ import { renderWithConsoleQuery } from '@/test/console/query-data'
import { ACCOUNT_SETTING_TAB } from '../constants'
import AccountSetting from '../index'
const mockResetModelProviderListExpanded = vi.fn()
const mockConsoleState = vi.hoisted(() => ({
current: null as unknown,
}))
@ -66,10 +64,6 @@ vi.mock('next-themes', () => ({
})),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/atoms', () => ({
useResetModelProviderListExpanded: () => mockResetModelProviderListExpanded,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({
default: ({
onSearchTextChange,
@ -274,25 +268,6 @@ describe('AccountSetting', () => {
expect(screen.getByText('common.settings.preferences'))!.toBeInTheDocument()
})
it('should keep hidden legacy tab metadata for direct entries', () => {
// Act
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.DATA_SOURCE })
// Assert
expect(screen.getByText('common.settings.dataSource'))!.toBeInTheDocument()
})
it('should normalize legacy language tab entries to preferences', () => {
// Act
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.LANGUAGE })
// Assert
const preferencesButton = screen.getByRole('button', { name: 'common.settings.preferences' })
expect(preferencesButton.querySelector('.i-ri-equalizer-2-fill')).toBeInTheDocument()
expect(screen.getByText('common.account.general')).toBeInTheDocument()
expect(screen.getByText('common.account.appearanceLabel')).toBeInTheDocument()
})
it('should hide sidebar labels on mobile', () => {
// Arrange
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
@ -691,19 +666,6 @@ describe('AccountSetting', () => {
expect(mockOnCancel).toHaveBeenCalled()
})
it('should keep provider search controlled at the account setting boundary', async () => {
// Arrange
const user = userEvent.setup()
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.PROVIDER })
// Act
const input = screen.getByRole('searchbox', { name: 'common.operation.search' })
await user.type(input, 'test-search')
// Assert
expect(input)!.toHaveValue('test-search')
})
it('should handle scroll event in panel', () => {
// Act
renderAccountSetting()

View File

@ -0,0 +1,134 @@
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useQueryState } from 'nuqs'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { render } from '@/test/console/render'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import { SettingsModal } from '../settings-modal'
vi.mock('@/app/components/header/account-setting', () => ({
default: ({ activeTab, onCancelAction }: { activeTab: string; onCancelAction: () => void }) => (
<>
<div role="status" aria-label="active account setting tab">
{activeTab}
</div>
<button type="button" onClick={onCancelAction}>
cancel account setting
</button>
</>
),
}))
vi.mock('@/app/components/integrations/modal', () => ({
default: ({
section,
onCancel,
onSectionChange,
}: {
section: string
onCancel: () => void
onSectionChange: (section: 'data-source') => void
}) => (
<>
<div role="status" aria-label="active integration setting section">
{section}
</div>
<button type="button" onClick={() => onSectionChange('data-source')}>
switch integration section
</button>
<button type="button" onClick={onCancel}>
cancel integration setting
</button>
</>
),
}))
function PreferencesOpener() {
const [settingsDestination, setSettingsDestination] = useQueryState(
settingsQueryParamName,
settingsQueryParser,
)
return (
<button
type="button"
aria-pressed={settingsDestination === ACCOUNT_SETTING_TAB.PREFERENCES}
onClick={() => setSettingsDestination(ACCOUNT_SETTING_TAB.PREFERENCES)}
>
open preferences
</button>
)
}
const renderSettingsModal = (searchParams = '', children?: React.ReactNode) => {
const { wrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams })
return {
...render(
<>
{children}
<SettingsModal />
</>,
{ wrapper },
),
onUrlUpdate,
}
}
describe('SettingsModal', () => {
it('opens account settings with push and closes them with replace', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderSettingsModal('', <PreferencesOpener />)
await user.click(screen.getByRole('button', { name: 'open preferences' }))
expect(
await screen.findByRole('status', { name: 'active account setting tab' }),
).toHaveTextContent(ACCOUNT_SETTING_TAB.PREFERENCES)
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('settings')).toBe('preferences')
expect(onUrlUpdate.mock.calls.at(-1)?.[0].options).toMatchObject({
history: 'push',
shallow: false,
})
await user.click(screen.getByRole('button', { name: 'cancel account setting' }))
await waitFor(() => {
expect(
screen.queryByRole('status', { name: 'active account setting tab' }),
).not.toBeInTheDocument()
})
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has('settings')).toBe(false)
expect(onUrlUpdate.mock.calls.at(-1)?.[0].options).toMatchObject({
history: 'replace',
shallow: true,
})
})
it('renders an integration destination and replaces it when switching sections', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderSettingsModal('?settings=provider')
expect(
await screen.findByRole('status', { name: 'active integration setting section' }),
).toHaveTextContent('provider')
await user.click(screen.getByRole('button', { name: 'switch integration section' }))
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('settings')).toBe('data-source')
expect(onUrlUpdate.mock.calls.at(-1)?.[0].options).toMatchObject({
history: 'replace',
shallow: true,
})
})
it('ignores invalid settings destinations', () => {
renderSettingsModal('?settings=unknown')
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
})

View File

@ -1,3 +1,4 @@
import type { SettingsDestination } from '@/app/components/header/account-setting/query-params'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import {
@ -5,20 +6,17 @@ import {
AUTO_UPDATE_STRATEGY,
} from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { ACCOUNT_SETTING_TAB } from '../constants'
import UpdateSettingDialogForm from '../update-setting-dialog-form'
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: (
selector: (s: {
setShowAccountSettingModal: typeof mockSetShowAccountSettingModal
}) => typeof mockSetShowAccountSettingModal,
) => {
return selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal })
},
}))
const mockSetSettingsDestination = vi.fn()
let mockSettingsDestination: SettingsDestination | null = null
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return {
...actual,
useQueryState: () => [mockSettingsDestination, mockSetSettingsDestination],
}
})
vi.mock('react-i18next', async () => {
const { withSelectorKey, withSelectorKeyProps } = await import('@/test/i18n-mock')
@ -64,6 +62,7 @@ vi.mock(
describe('UpdateSettingDialogForm', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSettingsDestination = null
})
it('should open preferences after closing the update setting dialog when timezone link is clicked', () => {
@ -96,8 +95,41 @@ describe('UpdateSettingDialogForm', () => {
fireEvent.click(screen.getByText('autoUpdate.changeTimezone'))
expect(onRequestClose).toHaveBeenCalledTimes(1)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.PREFERENCES,
expect(mockSetSettingsDestination).toHaveBeenCalledWith('preferences')
})
it('should replace the current destination when timezone link is clicked inside settings', () => {
mockSettingsDestination = 'provider'
render(
<UpdateSettingDialogForm
autoUpgrade={{
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
upgrade_time_of_day: 0,
upgrade_mode: AUTO_UPDATE_MODE.update_all,
exclude_plugins: [],
include_plugins: [],
}}
category={PluginCategoryEnum.tool}
plugins={[]}
scopeOptions={[{ value: AUTO_UPDATE_MODE.update_all, label: 'All' }]}
strategyOptions={[{ value: AUTO_UPDATE_STRATEGY.fixOnly, label: 'Fix only' }]}
timezone="UTC"
updateTimeValue="00:00"
minuteFilter={(minutes) => minutes}
onAutoUpgradeChange={vi.fn()}
onPluginsChange={vi.fn()}
onRequestClose={vi.fn()}
onUpdateTimeChange={vi.fn()}
renderTimePickerTrigger={() => <button type="button">Pick time</button>}
/>,
)
fireEvent.click(screen.getByText('autoUpdate.changeTimezone'))
expect(mockSetSettingsDestination).toHaveBeenCalledWith('preferences', {
history: 'replace',
shallow: true,
})
})
})

View File

@ -1,70 +0,0 @@
import { act, renderHook } from '@testing-library/react'
import { ACCOUNT_SETTING_TAB } from '../constants'
import { useIntegrationsSetting } from '../use-integrations-setting'
const { mockSetShowAccountSettingModal } = vi.hoisted(() => ({
mockSetShowAccountSettingModal: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
describe('useIntegrationsSetting', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it.each([
[ACCOUNT_SETTING_TAB.PROVIDER, 'provider'],
[ACCOUNT_SETTING_TAB.DATA_SOURCE, 'data-source'],
[ACCOUNT_SETTING_TAB.API_BASED_EXTENSION, 'custom-endpoint'],
])('should open integrations settings for migrated tab %s', (tab, section) => {
const { result } = renderHook(() => useIntegrationsSetting())
act(() => {
result.current({ payload: tab })
})
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: section })
})
it('should open integrations settings from a direct section', () => {
const { result } = renderHook(() => useIntegrationsSetting())
act(() => {
result.current({ section: 'mcp' })
})
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'mcp' })
})
it('should preserve the agent source for agent-scoped settings', () => {
const { result } = renderHook(() => useIntegrationsSetting())
act(() => {
result.current({ payload: ACCOUNT_SETTING_TAB.PROVIDER, source: 'agent' })
})
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'provider',
source: 'agent',
})
})
it('should preserve the cancel callback for migrated integrations settings', () => {
const onCancelCallback = vi.fn()
const { result } = renderHook(() => useIntegrationsSetting())
act(() => {
result.current({ payload: ACCOUNT_SETTING_TAB.PROVIDER, onCancelCallback })
})
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'provider',
onCancelCallback,
})
})
})

View File

@ -1,8 +1,5 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { ModalContextState } from '@/context/modal-context'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { ApiBasedExtensionSelector } from '../selector'
const { mockApiBasedExtensionsQuery, mockCreateApiBasedExtension, mockUpdateApiBasedExtension } =
@ -12,9 +9,11 @@ const { mockApiBasedExtensionsQuery, mockCreateApiBasedExtension, mockUpdateApiB
mockUpdateApiBasedExtension: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
vi.mock('@/context/i18n', () => ({
useDocLink:
@ -55,7 +54,6 @@ vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/bas
describe('ApiBasedExtensionSelector', () => {
const mockOnChange = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockData: ApiBasedExtensionResponse[] = [
{ id: '1', name: 'Extension 1', api_endpoint: 'https://api1.test', api_key: 'key1' },
@ -64,9 +62,6 @@ describe('ApiBasedExtensionSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useModalContext).mockReturnValue({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
} as unknown as ModalContextState)
mockApiBasedExtensionsQuery.mockReturnValue({
data: mockData,
isPending: false,
@ -131,9 +126,7 @@ describe('ApiBasedExtensionSelector', () => {
fireEvent.click(manageButton)
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('custom-endpoint')
})
it('should open add modal when clicking add button and close it after save', async () => {

View File

@ -1,9 +1,12 @@
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useQuery } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { consoleQuery } from '@/service/client'
import { ApiBasedExtensionModal } from './modal'
@ -16,7 +19,7 @@ export function ApiBasedExtensionSelector({ value, onChange }: ApiBasedExtension
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [addModalOpen, setAddModalOpen] = useState(false)
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const { data: apiBasedExtensions = [] } = useQuery(
consoleQuery.apiBasedExtension.get.queryOptions(),
)
@ -84,9 +87,7 @@ export function ApiBasedExtensionSelector({ value, onChange }: ApiBasedExtension
className="flex cursor-pointer items-center border-none bg-transparent p-0 text-xs text-text-accent"
onClick={() => {
setOpen(false)
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION,
})
setSettingsDestination('custom-endpoint')
}}
>
{t(($) => $['apiBasedExtension.selector.manage'], { ns: 'common' })}

View File

@ -1,69 +1,21 @@
import type { IntegrationSection } from '@/app/components/integrations/routes'
import { INTEGRATION_SECTION_VALUES } from '@/app/components/integrations/routes'
export const ACCOUNT_SETTING_MODAL_ACTION = 'showSettings'
export const ACCOUNT_SETTING_TAB = {
PROVIDER: 'provider',
MEMBERS: 'members',
ROLES_AND_PERMISSIONS: 'roles-and-permissions',
PERMISSION_SET: 'permission-set',
BILLING: 'billing',
WORKFLOW_LOG_ARCHIVES: 'workflow-log-archives',
DATA_SOURCE: 'data-source',
API_BASED_EXTENSION: 'custom-endpoint',
CUSTOM: 'custom',
PREFERENCES: 'preferences',
LANGUAGE: 'language',
} as const
export type AccountSettingTab = (typeof ACCOUNT_SETTING_TAB)[keyof typeof ACCOUNT_SETTING_TAB]
export const DEFAULT_ACCOUNT_SETTING_TAB = ACCOUNT_SETTING_TAB.MEMBERS
const WORKSPACE_SETTING_TAB_VALUES = [
export const ACCOUNT_SETTING_TAB_VALUES = [
ACCOUNT_SETTING_TAB.MEMBERS,
ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS,
ACCOUNT_SETTING_TAB.PERMISSION_SET,
ACCOUNT_SETTING_TAB.BILLING,
ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
ACCOUNT_SETTING_TAB.CUSTOM,
] as const
export type WorkspaceSettingTab = (typeof WORKSPACE_SETTING_TAB_VALUES)[number]
const USER_SETTING_TAB_VALUES = [
ACCOUNT_SETTING_TAB.PREFERENCES,
ACCOUNT_SETTING_TAB.LANGUAGE,
] as const
export type UserSettingTab = (typeof USER_SETTING_TAB_VALUES)[number]
export type IntegrationSettingTab = IntegrationSection
export const SETTINGS_TAB_VALUES = [
...WORKSPACE_SETTING_TAB_VALUES,
...USER_SETTING_TAB_VALUES,
...INTEGRATION_SECTION_VALUES,
] as const
export type SettingsTab = (typeof SETTINGS_TAB_VALUES)[number]
export const isValidSettingsTab = (tab: string | null): tab is SettingsTab => {
if (!tab) return false
return SETTINGS_TAB_VALUES.includes(tab as SettingsTab)
}
export const isWorkspaceSettingTab = (tab: SettingsTab | null): tab is WorkspaceSettingTab => {
if (!tab) return false
return WORKSPACE_SETTING_TAB_VALUES.includes(tab as WorkspaceSettingTab)
}
export const isUserSettingTab = (tab: SettingsTab | null): tab is UserSettingTab => {
if (!tab) return false
return USER_SETTING_TAB_VALUES.includes(tab as UserSettingTab)
}
export const isIntegrationSettingTab = (tab: SettingsTab | null): tab is IntegrationSettingTab => {
if (!tab) return false
return INTEGRATION_SECTION_VALUES.includes(tab as IntegrationSection)
}

View File

@ -1,11 +0,0 @@
import type { AccountSettingTab } from './constants'
import type { IntegrationSection } from '@/app/components/integrations/routes'
import { ACCOUNT_SETTING_TAB } from './constants'
export const integrationSectionByMovedAccountSettingTab = {
[ACCOUNT_SETTING_TAB.PROVIDER]: 'provider',
[ACCOUNT_SETTING_TAB.DATA_SOURCE]: 'data-source',
[ACCOUNT_SETTING_TAB.API_BASED_EXTENSION]: 'custom-endpoint',
} as const satisfies Partial<Record<AccountSettingTab, IntegrationSection>>
export type MovedAccountSettingTab = keyof typeof integrationSectionByMovedAccountSettingTab

View File

@ -5,7 +5,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useCallback, useRef, useState } from 'react'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import BillingPage from '@/app/components/billing/billing-page'
import CustomPage from '@/app/components/custom/custom-page'
@ -21,11 +21,7 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { hasPermission } from '@/utils/permission'
import AccessRulesPage from './access-rules-page'
import { ApiBasedExtensionPage } from './api-based-extension-page'
import DataSourcePage from './data-source-page-new'
import MembersPage from './members-page'
import ModelProviderPage from './model-provider-page'
import { useResetModelProviderListExpanded } from './model-provider-page/atoms'
import PermissionsPage from './permissions-page'
import PreferencePage from './preference-page'
import WorkflowLogArchivesPage from './workflow-log-archives-page'
@ -54,7 +50,6 @@ export default function AccountSetting({
activeTab,
onTabChangeAction,
}: IAccountSettingProps) {
const resetModelProviderListExpanded = useResetModelProviderListExpanded()
const { t } = useTranslation()
const { enableBilling, enableReplaceWebAppLogo } = useProviderContext()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
@ -67,34 +62,22 @@ export default function AccountSetting({
const canViewBilling = enableBilling && !isCurrentWorkspaceDatasetOperator
const canViewWorkflowLogArchives =
systemFeatures.deployment_edition === 'CLOUD' && isCurrentWorkspaceManager
// Keep legacy `language` deep links opening Preferences during the tab rename migration.
const normalizedActiveTab =
activeTab === ACCOUNT_SETTING_TAB.LANGUAGE ? ACCOUNT_SETTING_TAB.PREFERENCES : activeTab
const activeMenu = (() => {
if (normalizedActiveTab === ACCOUNT_SETTING_TAB.BILLING && !canViewBilling)
if (activeTab === ACCOUNT_SETTING_TAB.BILLING && !canViewBilling)
return ACCOUNT_SETTING_TAB.PREFERENCES
if (
normalizedActiveTab === ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES &&
!canViewWorkflowLogArchives
)
if (activeTab === ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES && !canViewWorkflowLogArchives)
return ACCOUNT_SETTING_TAB.MEMBERS
if (
(normalizedActiveTab === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS ||
normalizedActiveTab === ACCOUNT_SETTING_TAB.PERMISSION_SET) &&
(activeTab === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS ||
activeTab === ACCOUNT_SETTING_TAB.PERMISSION_SET) &&
!canManageWorkspaceRoles
)
return ACCOUNT_SETTING_TAB.MEMBERS
return normalizedActiveTab
return activeTab
})()
const scrollContainerRef = useRef<HTMLDivElement>(null)
const settingItems: GroupItem[] = [
{
key: ACCOUNT_SETTING_TAB.PROVIDER,
name: t(($) => $['settings.provider'], { ns: 'common' }),
icon: <span className={cn('i-ri-brain-2-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-brain-2-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.MEMBERS,
name: t(($) => $['settings.members'], { ns: 'common' }),
@ -128,18 +111,6 @@ export default function AccountSetting({
icon: <span className={cn('i-ri-archive-drawer-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-archive-drawer-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.DATA_SOURCE,
name: t(($) => $['settings.dataSource'], { ns: 'common' }),
icon: <span className={cn('i-ri-database-2-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-database-2-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION,
name: t(($) => $['settings.customEndpoint'], { ns: 'common' }),
icon: <span className={cn('i-ri-puzzle-2-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-puzzle-2-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.CUSTOM,
name: t(($) => $.custom, { ns: 'custom' }),
@ -193,31 +164,15 @@ export default function AccountSetting({
},
]
const [searchValue, setSearchValue] = useState<string>('')
const handleTabChange = useCallback(
(tab: AccountSettingTab) => {
if (tab === ACCOUNT_SETTING_TAB.PROVIDER) resetModelProviderListExpanded()
onTabChangeAction(tab)
},
[onTabChangeAction, resetModelProviderListExpanded],
)
const handleClose = useCallback(() => {
resetModelProviderListExpanded()
onCancelAction()
}, [onCancelAction, resetModelProviderListExpanded])
return (
<MenuDialog show onClose={handleClose}>
<MenuDialog show onClose={onCancelAction}>
<div className="fixed top-6 right-6 z-20 flex shrink-0 flex-col items-center">
<Button
variant="tertiary"
size="large"
className="px-2"
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
onClick={handleClose}
onClick={onCancelAction}
>
<span className="i-ri-close-line size-5" />
</Button>
@ -257,7 +212,7 @@ export default function AccountSetting({
aria-label={item.name}
title={item.name}
onClick={() => {
handleTabChange(item.key)
onTabChangeAction(item.key)
}}
>
{activeMenu === item.key ? item.activeIcon : item.icon}
@ -289,9 +244,6 @@ export default function AccountSetting({
</div>
</div>
<div className="max-w-full min-w-0 px-4 pt-6 sm:px-8">
{activeMenu === ACCOUNT_SETTING_TAB.PROVIDER && (
<ModelProviderPage searchText={searchValue} onSearchTextChange={setSearchValue} />
)}
{activeMenu === ACCOUNT_SETTING_TAB.MEMBERS && <MembersPage />}
{activeMenu === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS && (
<PermissionsPage containerRef={scrollContainerRef} />
@ -301,8 +253,6 @@ export default function AccountSetting({
{activeMenu === ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES && (
<WorkflowLogArchivesPage />
)}
{activeMenu === ACCOUNT_SETTING_TAB.DATA_SOURCE && <DataSourcePage />}
{activeMenu === ACCOUNT_SETTING_TAB.API_BASED_EXTENSION && <ApiBasedExtensionPage />}
{activeMenu === ACCOUNT_SETTING_TAB.CUSTOM && <CustomPage />}
{activeMenu === ACCOUNT_SETTING_TAB.PREFERENCES && <PreferencePage />}
</div>

View File

@ -5,7 +5,6 @@ import { beforeEach, describe, expect, it } from 'vitest'
import {
useExpandModelProviderList,
useModelProviderListExpanded,
useResetModelProviderListExpanded,
useSetModelProviderListExpanded,
} from '../atoms'
@ -169,73 +168,6 @@ describe('atoms', () => {
})
})
// Reset hook: clears all expanded state back to empty
describe('useResetModelProviderListExpanded', () => {
it('should reset all expanded providers to false', () => {
const { result } = renderHook(
() => ({
openaiExpanded: useModelProviderListExpanded('openai'),
anthropicExpanded: useModelProviderListExpanded('anthropic'),
expand: useExpandModelProviderList(),
reset: useResetModelProviderListExpanded(),
}),
{ wrapper },
)
act(() => {
result.current.expand('openai')
})
act(() => {
result.current.expand('anthropic')
})
act(() => {
result.current.reset()
})
expect(result.current.openaiExpanded).toBe(false)
expect(result.current.anthropicExpanded).toBe(false)
})
it('should be safe to call when no providers are expanded', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
reset: useResetModelProviderListExpanded(),
}),
{ wrapper },
)
act(() => {
result.current.reset()
})
expect(result.current.expanded).toBe(false)
})
it('should allow re-expanding providers after reset', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
expand: useExpandModelProviderList(),
reset: useResetModelProviderListExpanded(),
}),
{ wrapper },
)
act(() => {
result.current.expand('openai')
})
act(() => {
result.current.reset()
})
act(() => {
result.current.expand('openai')
})
expect(result.current.expanded).toBe(true)
})
})
// Cross-hook interaction: verify hooks cooperate through the shared atom
describe('Cross-hook interaction', () => {
it('should reflect state set by useSetModelProviderListExpanded in useModelProviderListExpanded', () => {
@ -290,26 +222,6 @@ describe('atoms', () => {
})
expect(result.current.expanded).toBe(false)
})
it('should reset state set by useSetModelProviderListExpanded via useResetModelProviderListExpanded', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
setExpanded: useSetModelProviderListExpanded('openai'),
reset: useResetModelProviderListExpanded(),
}),
{ wrapper },
)
act(() => {
result.current.setExpanded(true)
})
act(() => {
result.current.reset()
})
expect(result.current.expanded).toBe(false)
})
})
// selectAtom granularity: changing one provider should not affect unrelated reads

View File

@ -26,8 +26,8 @@ type MockReferenceSetting = {
}
}
const { mockSetAccountSettingModal, mockSaveAutoUpgrade } = vi.hoisted(() => ({
mockSetAccountSettingModal: vi.fn(),
const { mockSetSettingsDestination, mockSaveAutoUpgrade } = vi.hoisted(() => ({
mockSetSettingsDestination: vi.fn(),
mockSaveAutoUpgrade: vi.fn(),
}))
@ -289,11 +289,10 @@ vi.mock('@langgenius/dify-ui/dialog', () => ({
DialogCloseButton: () => <button type="button" aria-label="close" />,
}))
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: (
selector: (state: { setShowAccountSettingModal: typeof mockSetAccountSettingModal }) => unknown,
) => selector({ setShowAccountSettingModal: mockSetAccountSettingModal }),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({
default: ({

View File

@ -25,8 +25,3 @@ export function useExpandModelProviderList() {
[set],
)
}
export function useResetModelProviderListExpanded() {
const set = useSetAtom(expandedAtom)
return useCallback(() => set({}), [set])
}

View File

@ -16,16 +16,17 @@ import Popup from '../popup'
let mockLanguage = 'en_US'
const mockSetShowAccountSettingModal = vi.hoisted(() => vi.fn())
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
const mockSearchParams = vi.hoisted(() => ({
current: new URLSearchParams(),
}))
const mockSetSettingsDestination = vi.hoisted(() => vi.fn())
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return {
...actual,
useQueryState: () => [mockSearchParams.current.get('settings'), mockSetSettingsDestination],
}
})
vi.mock('@/next/navigation', () => ({
useSearchParams: () => mockSearchParams.current,
}))
@ -1052,13 +1053,11 @@ describe('Popup', () => {
fireEvent.click(screen.getByText('common.modelProvider.selector.modelProviderSettings'))
expect(onHide).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'provider',
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider')
})
it('should hide provider settings footer when current account settings tab is provider', () => {
mockSearchParams.current = new URLSearchParams('action=showSettings&tab=provider')
it('should hide provider settings footer when provider settings are already open', () => {
mockSearchParams.current = new URLSearchParams('settings=provider')
renderPopup(<PopupHarness modelList={[makeModel()]} onHide={vi.fn()} />)
@ -1090,20 +1089,18 @@ describe('Popup', () => {
fireEvent.click(screen.getByText(/modelProvider\.selector\.configure/))
expect(onHide).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'provider',
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider')
})
it('should only close the empty state selector when current account settings tab is provider', () => {
mockSearchParams.current = new URLSearchParams('action=showSettings&tab=provider')
it('should only close the empty state selector when provider settings are already open', () => {
mockSearchParams.current = new URLSearchParams('settings=provider')
const onHide = vi.fn()
renderPopup(<PopupHarness modelList={[]} onHide={onHide} />)
fireEvent.click(screen.getByText(/modelProvider\.selector\.configure/))
expect(onHide).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).not.toHaveBeenCalled()
expect(mockSetSettingsDestination).not.toHaveBeenCalled()
})
it('should render marketplace providers that are not installed', () => {

View File

@ -34,7 +34,6 @@ type ModelSelectorProps = {
hideProviderSettingsFooter?: boolean
onConfigureEmptyState?: () => void
onOpenMarketplace?: () => void
providerSettingsSource?: 'agent'
showModelMeta?: boolean
modelPredicate?: ModelSelectorModelPredicate
modelSuggestionPredicate?: ModelSelectorModelPredicate
@ -54,7 +53,6 @@ function ModelSelector({
hideProviderSettingsFooter,
onConfigureEmptyState,
onOpenMarketplace,
providerSettingsSource,
showModelMeta,
modelPredicate,
modelSuggestionPredicate,
@ -180,7 +178,6 @@ function ModelSelector({
modelList={modelList}
scopeFeatures={scopeFeatures}
hideProviderSettingsFooter={hideProviderSettingsFooter}
providerSettingsSource={providerSettingsSource}
modelPredicate={modelPredicate}
modelSuggestionPredicate={modelSuggestionPredicate}
onConfigureEmptyState={onConfigureEmptyState ? handleConfigureEmptyState : undefined}

View File

@ -10,19 +10,18 @@ import {
} from '@langgenius/dify-ui/preview-card'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { useQueryState } from 'nuqs'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
ACCOUNT_SETTING_MODAL_ACTION,
ACCOUNT_SETTING_TAB,
} from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
import useWorkspacePluginInstallPermission from '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useSearchParams } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
import {
@ -63,7 +62,6 @@ export type PopupProps = {
modelList: Model[]
scopeFeatures?: ModelFeatureEnum[]
hideProviderSettingsFooter?: boolean
providerSettingsSource?: 'agent'
modelPredicate?: ModelSelectorModelPredicate
modelSuggestionPredicate?: ModelSelectorModelPredicate
onConfigureEmptyState?: () => void
@ -77,7 +75,6 @@ function Popup({
modelList,
scopeFeatures = [],
hideProviderSettingsFooter,
providerSettingsSource,
modelPredicate,
modelSuggestionPredicate,
onConfigureEmptyState,
@ -86,7 +83,10 @@ function Popup({
onHide,
}: PopupProps) {
const { t } = useTranslation()
const searchParams = useSearchParams()
const [settingsDestination, setSettingsDestination] = useQueryState(
settingsQueryParamName,
settingsQueryParser,
)
const { theme } = useTheme()
const language = useLanguage()
const previewCardHandle = useMemo(
@ -95,7 +95,6 @@ function Popup({
)
const [marketplaceCollapsed, setMarketplaceCollapsed] = useState(false)
const [showIncompatibleModels, setShowIncompatibleModels] = useState(false)
const openIntegrationsSetting = useIntegrationsSetting()
const { modelProviders } = useProviderContext()
const { data: enableMarketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
@ -255,17 +254,12 @@ function Popup({
const handleOpenSettings = useCallback(() => {
onHide()
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.PROVIDER,
source: providerSettingsSource,
})
}, [onHide, openIntegrationsSetting, providerSettingsSource])
setSettingsDestination('provider')
}, [onHide, setSettingsDestination])
const handleClosePreviewCard = useCallback(() => {
previewCardHandle.close()
}, [previewCardHandle])
const isProviderSettingsCurrentPage =
searchParams?.get('action') === ACCOUNT_SETTING_MODAL_ACTION &&
searchParams?.get('tab') === ACCOUNT_SETTING_TAB.PROVIDER
const isProviderSettingsCurrentPage = settingsDestination === 'provider'
const handleConfigureEmptyState =
onConfigureEmptyState ?? (isProviderSettingsCurrentPage ? onHide : handleOpenSettings)

View File

@ -0,0 +1,28 @@
import type { inferParserType } from 'nuqs'
import { parseAsStringLiteral } from 'nuqs'
import { INTEGRATION_SECTION_VALUES } from '@/app/components/integrations/routes'
import { ACCOUNT_SETTING_TAB_VALUES } from './constants'
export const settingsQueryParamName = 'settings'
// Opening the full-screen settings surface is the common write. It creates a history entry and
// opts into a Next.js navigation so browser Back updates both the URL and the nuqs snapshot.
// Closing and switching destinations stay shallow and replace history at the modal owner.
export const settingsQueryParser = parseAsStringLiteral([
...ACCOUNT_SETTING_TAB_VALUES,
...INTEGRATION_SECTION_VALUES,
] as const).withOptions({ history: 'push', shallow: false })
export type SettingsDestination = inferParserType<typeof settingsQueryParser>
export const isAccountSettingDestination = (
destination: SettingsDestination | null,
): destination is (typeof ACCOUNT_SETTING_TAB_VALUES)[number] => {
return ACCOUNT_SETTING_TAB_VALUES.some((value) => value === destination)
}
export const isIntegrationSettingDestination = (
destination: SettingsDestination | null,
): destination is (typeof INTEGRATION_SECTION_VALUES)[number] => {
return INTEGRATION_SECTION_VALUES.some((value) => value === destination)
}

View File

@ -0,0 +1,54 @@
'use client'
import type { SettingsDestination } from './query-params'
import { useQueryState } from 'nuqs'
import dynamic from '@/next/dynamic'
import {
isAccountSettingDestination,
isIntegrationSettingDestination,
settingsQueryParamName,
settingsQueryParser,
} from './query-params'
// This controller is mounted globally, so concrete settings surfaces must stay lazy and only
// load after the URL selects their destination.
const AccountSetting = dynamic(() => import('@/app/components/header/account-setting'), {
ssr: false,
})
const IntegrationsSettingModal = dynamic(() => import('@/app/components/integrations/modal'), {
ssr: false,
})
export function SettingsModal() {
const [destination, setDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const handleClose = () => {
setDestination(null, { history: 'replace', shallow: true })
}
const handleDestinationChange = (nextDestination: SettingsDestination) => {
setDestination(nextDestination, { history: 'replace', shallow: true })
}
if (isAccountSettingDestination(destination)) {
return (
<AccountSetting
activeTab={destination}
onCancelAction={handleClose}
onTabChangeAction={handleDestinationChange}
/>
)
}
if (isIntegrationSettingDestination(destination)) {
return (
<IntegrationsSettingModal
section={destination}
onCancel={handleClose}
onSectionChange={handleDestinationChange}
/>
)
}
return null
}

View File

@ -5,17 +5,20 @@ import type { dayjsToTimeOfDay } from '@/app/components/plugins/reference-settin
import type { PluginCategoryEnum } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
import { RadioGroup } from '@langgenius/dify-ui/radio'
import { useQueryState } from 'nuqs'
import { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import TimePicker from '@/app/components/base/date-and-time-picker/time-picker'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import PluginsPicker from '@/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker'
import {
AUTO_UPDATE_MODE,
AUTO_UPDATE_STRATEGY,
} from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types'
import { convertLocalSecondsToUTCDaySeconds } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/utils'
import { useModalContextSelector } from '@/context/modal-context'
import UpdateSettingOptionCard from './update-setting-option-card'
type Option<Value extends string> = {
@ -49,7 +52,10 @@ function SettingTimeZone({
children?: ReactNode
onRequestClose: () => void
}) {
const setShowAccountSettingModal = useModalContextSelector((s) => s.setShowAccountSettingModal)
const [settingsDestination, setSettingsDestination] = useQueryState(
settingsQueryParamName,
settingsQueryParser,
)
return (
<button
@ -57,7 +63,9 @@ function SettingTimeZone({
className="cursor-pointer border-none bg-transparent p-0 text-left body-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={() => {
onRequestClose()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PREFERENCES })
if (settingsDestination)
setSettingsDestination('preferences', { history: 'replace', shallow: true })
else setSettingsDestination('preferences')
}}
>
{children}

View File

@ -1,33 +0,0 @@
'use client'
import type { MovedAccountSettingTab } from './destinations'
import type { IntegrationSection } from '@/app/components/integrations/routes'
import { useCallback } from 'react'
import { useModalContext } from '@/context/modal-context'
import { integrationSectionByMovedAccountSettingTab } from './destinations'
type IntegrationsSettingState =
| { payload: MovedAccountSettingTab; source?: 'agent'; onCancelCallback?: () => void }
| { section: IntegrationSection; source?: 'agent'; onCancelCallback?: () => void }
export const useIntegrationsSetting = () => {
const { setShowAccountSettingModal } = useModalContext()
return useCallback(
(state: IntegrationsSettingState) => {
const section =
'section' in state
? state.section
: integrationSectionByMovedAccountSettingTab[state.payload]
if (section) {
setShowAccountSettingModal({
payload: section,
...(state.source ? { source: state.source } : {}),
...(state.onCancelCallback ? { onCancelCallback: state.onCancelCallback } : {}),
})
}
},
[setShowAccountSettingModal],
)
}

View File

@ -1,29 +1,25 @@
'use client'
import type { IntegrationSection } from '@/app/components/integrations/routes'
import type { IntegrationSection } from './routes'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import MenuDialog from '@/app/components/header/account-setting/menu-dialog'
import IntegrationsPage from '@/app/components/integrations/page'
import { getMarketplaceUrl } from '@/utils/var'
import IntegrationsPage from './page'
type IntegrationsSettingModalProps = {
section: IntegrationSection
source?: 'agent'
onCancel: () => void
onSectionChange: (section: IntegrationSection) => void
}
export default function IntegrationsSettingModal({
section,
source,
onCancel,
onSectionChange,
}: IntegrationsSettingModalProps) {
const { t } = useTranslation()
const isAgentSource = source === 'agent'
const handleSwitchToMarketplace = useCallback((path: string) => {
window.open(
getMarketplaceUrl(path, undefined, { source: window.location.origin }),
@ -33,18 +29,8 @@ export default function IntegrationsSettingModal({
}, [])
return (
<MenuDialog
show
backdropClassName={isAgentSource ? 'bg-background-overlay' : undefined}
className={isAgentSource ? 'bg-transparent backdrop-blur-none' : undefined}
onClose={onCancel}
>
<div
className={cn(
'mx-auto flex h-dvh w-[min(1440px,calc(100vw-48px))] shrink-0 py-6',
isAgentSource && 'w-full p-6',
)}
>
<MenuDialog show onClose={onCancel}>
<div className="mx-auto flex h-dvh w-[min(1440px,calc(100vw-48px))] shrink-0 py-6">
<div className="relative flex min-h-0 w-full shrink-0 overflow-hidden rounded-2xl border border-divider-subtle bg-components-panel-bg shadow-2xl">
<IntegrationsPage
section={section}

View File

@ -343,7 +343,11 @@ vi.mock('@/config', async (importOriginal) => {
const mockPush = vi.fn()
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
const mockUninstall = vi.fn()
const mockUpdatePinStatus = vi.fn()
let mockPathname = '/apps'
@ -537,7 +541,6 @@ describe('MainNav', () => {
} as ProviderContextState)
;(useModalContext as Mock).mockReturnValue({
setShowPricingModal: mockSetShowPricingModal,
setShowAccountSettingModal: mockSetShowAccountSettingModal,
} as unknown as ModalContextState)
;(useGetInstalledApps as Mock).mockImplementation(() => ({
isPending: mockInstalledAppsPending,
@ -1113,24 +1116,18 @@ describe('MainNav', () => {
expect(
screen.getByRole('link', { name: /common\.mainNav\.workspace\.credits|7,500 credits/ }),
).toHaveAttribute('href', '/integrations/model-provider')
expect(mockSetShowAccountSettingModal).not.toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.PROVIDER,
})
expect(mockSetSettingsDestination).not.toHaveBeenCalledWith('provider')
fireEvent.click(screen.getByText('billing.upgradeBtn.plain'))
expect(mockSetShowPricingModal).toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(await screen.findByText('common.mainNav.workspace.settings'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.BILLING,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.BILLING)
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(await screen.findByText('common.mainNav.workspace.inviteMembers'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.MEMBERS,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.MEMBERS)
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(await screen.findByText('Evan Workspace'))
@ -1168,9 +1165,7 @@ describe('MainNav', () => {
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('billing.upgradeBtn.plain'))
expect(mockSetShowPricingModal).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).not.toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.BILLING,
})
expect(mockSetSettingsDestination).not.toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.BILLING)
})
it('limits invite members by member management permission', async () => {

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