mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 08:48:10 +08:00
Merge remote-tracking branch 'origin/main' into deploy/konwledge
This commit is contained in:
commit
360fa45b3e
@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@ -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.")
|
||||
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 = []
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
|
||||
|
||||
@ -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",
|
||||
)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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"" 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"" 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: "")
|
||||
@ -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 "" 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"" in result
|
||||
assert mock_dependencies.storage.saves == [(upload_file.key, jpeg_bytes)]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
):
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
[
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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 ────────────────────────────────────────
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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' })}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 = ''
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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 = ''
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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 = ''
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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,
|
||||
])
|
||||
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 () => {
|
||||
|
||||
@ -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' })}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: ({
|
||||
|
||||
@ -25,8 +25,3 @@ export function useExpandModelProviderList() {
|
||||
[set],
|
||||
)
|
||||
}
|
||||
|
||||
export function useResetModelProviderListExpanded() {
|
||||
const set = useSetAtom(expandedAtom)
|
||||
return useCallback(() => set({}), [set])
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
28
web/app/components/header/account-setting/query-params.ts
Normal file
28
web/app/components/header/account-setting/query-params.ts
Normal 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)
|
||||
}
|
||||
54
web/app/components/header/account-setting/settings-modal.tsx
Normal file
54
web/app/components/header/account-setting/settings-modal.tsx
Normal 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
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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],
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
@ -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
Loading…
Reference in New Issue
Block a user