Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f392b6950 | ||
|
|
b0a3399774 | ||
|
|
2d5186fb28 | ||
|
|
06f076e0ff | ||
|
|
5b79f7e99d | ||
|
|
1cee1a25b6 | ||
|
|
c0f237bf35 | ||
|
|
75d7fc0526 | ||
|
|
c057b5c5ff | ||
|
|
5468c4ec96 | ||
|
|
f4c02e4c6b | ||
|
|
9dc95eeb20 | ||
|
|
76bba64b79 |
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_SHA=${BASE_SHA:-}
|
||||
HEAD_SHA=${HEAD_SHA:-}
|
||||
MAIN_REF=${MAIN_REF:-origin/main}
|
||||
REMEDIATION_HINT="Changes should be made from the main branch using git cherry-pick -x."
|
||||
|
||||
error() {
|
||||
printf 'ERROR: %s\n' "$1" >&2
|
||||
}
|
||||
|
||||
if [[ -z "$BASE_SHA" || -z "$HEAD_SHA" ]]; then
|
||||
error "BASE_SHA and HEAD_SHA are required. $REMEDIATION_HINT"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! git rev-parse --verify "$BASE_SHA^{commit}" > /dev/null 2>&1; then
|
||||
error "Base commit '$BASE_SHA' is not available in the local git checkout."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! git rev-parse --verify "$HEAD_SHA^{commit}" > /dev/null 2>&1; then
|
||||
error "Head commit '$HEAD_SHA' is not available in the local git checkout."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! git rev-parse --verify "$MAIN_REF^{commit}" > /dev/null 2>&1; then
|
||||
error "Main ref '$MAIN_REF' is not available in the local git checkout. $REMEDIATION_HINT"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
failed=0
|
||||
checked=0
|
||||
|
||||
while IFS= read -r commit_sha; do
|
||||
[[ -n "$commit_sha" ]] || continue
|
||||
|
||||
checked=$((checked + 1))
|
||||
subject=$(git log -1 --format=%s "$commit_sha")
|
||||
source_sha=$(
|
||||
git log -1 --format=%B "$commit_sha" \
|
||||
| sed -nE 's/^\(cherry picked from commit ([0-9a-fA-F]{7,64})\)$/\1/p' \
|
||||
| tail -n 1
|
||||
)
|
||||
|
||||
if [[ -z "$source_sha" ]]; then
|
||||
error "Commit $commit_sha ($subject) is missing cherry-pick provenance. $REMEDIATION_HINT"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "$source_sha^{commit}" 2> /dev/null; then
|
||||
error "Commit $commit_sha ($subject) references source $source_sha, but that commit is not available locally. $REMEDIATION_HINT"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! git merge-base --is-ancestor "$source_sha" "$MAIN_REF"; then
|
||||
error "Commit $commit_sha ($subject) references source $source_sha, but that source is not reachable from main ($MAIN_REF). $REMEDIATION_HINT"
|
||||
failed=1
|
||||
fi
|
||||
done < <(git rev-list --reverse "$BASE_SHA..$HEAD_SHA")
|
||||
|
||||
if [[ "$failed" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$checked" -eq 0 ]]; then
|
||||
echo "No PR commits to check."
|
||||
else
|
||||
echo "Verified $checked PR commit(s) include cherry-pick provenance from main."
|
||||
fi
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Hotfix Cherry-Pick Provenance
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'hotfix/**'
|
||||
- 'lts/**'
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- ready_for_review
|
||||
- synchronize
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: hotfix-cherry-pick-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-cherry-pick-provenance:
|
||||
name: Require cherry-pick provenance
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch PR base, PR head, and main
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
git fetch --no-tags --prune origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main" \
|
||||
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" \
|
||||
"+refs/pull/${PR_NUMBER}/head:refs/remotes/pull/${PR_NUMBER}/head"
|
||||
|
||||
- name: Load checker from main
|
||||
run: git show origin/main:.github/scripts/check-hotfix-cherry-picks.sh > "$RUNNER_TEMP/check-hotfix-cherry-picks.sh"
|
||||
|
||||
- name: Check PR commits
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
MAIN_REF: origin/main
|
||||
run: bash "$RUNNER_TEMP/check-hotfix-cherry-picks.sh"
|
||||
@@ -874,6 +874,7 @@ class ToolBuiltinProviderSetDefaultApi(Resource):
|
||||
@console_ns.expect(console_ns.models[BuiltinProviderDefaultCredentialPayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
def post(self, provider):
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
+50
-5
@@ -15,6 +15,7 @@ from openinference.semconv.trace import (
|
||||
SpanAttributes,
|
||||
ToolCallAttributes,
|
||||
)
|
||||
from opentelemetry.context import Context
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as GrpcOTLPSpanExporter
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter as HttpOTLPSpanExporter
|
||||
from opentelemetry.sdk import trace as trace_sdk
|
||||
@@ -45,8 +46,8 @@ from dify_trace_arize_phoenix.config import ArizeConfig, PhoenixConfig
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from graphon.enums import WorkflowNodeExecutionStatus
|
||||
from models.model import EndUser, MessageFile
|
||||
from models.workflow import WorkflowNodeExecutionTriggeredFrom
|
||||
from models.model import App, EndUser, MessageFile
|
||||
from models.workflow import WorkflowNodeExecutionTriggeredFrom, WorkflowRun
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -139,6 +140,48 @@ def _resolve_published_parent_span_context(parent_node_execution_id: str) -> dic
|
||||
return normalized_carrier
|
||||
|
||||
|
||||
def _app_uses_phoenix_provider(app_tracing_config: Mapping[str, Any] | None) -> bool:
|
||||
if not app_tracing_config or not app_tracing_config.get("enabled"):
|
||||
return False
|
||||
return app_tracing_config.get("tracing_provider") in {"arize", "phoenix"}
|
||||
|
||||
|
||||
def _parent_workflow_can_publish_span_context(parent_workflow_run_id: str) -> bool:
|
||||
parent_run = db.session.query(WorkflowRun).where(WorkflowRun.id == parent_workflow_run_id).first()
|
||||
if parent_run is None:
|
||||
return True
|
||||
|
||||
parent_app = db.session.query(App).where(App.id == parent_run.app_id).first()
|
||||
if parent_app is None or not parent_app.tracing:
|
||||
return False
|
||||
|
||||
try:
|
||||
app_tracing_config = json.loads(parent_app.tracing)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return False
|
||||
if not isinstance(app_tracing_config, Mapping):
|
||||
return False
|
||||
|
||||
return _app_uses_phoenix_provider(app_tracing_config)
|
||||
|
||||
|
||||
def _resolve_workflow_parent_carrier(
|
||||
parent_node_execution_id: str, parent_workflow_run_id: str | None
|
||||
) -> dict[str, str] | None:
|
||||
try:
|
||||
return _resolve_published_parent_span_context(parent_node_execution_id)
|
||||
except PendingTraceParentContextError:
|
||||
if parent_workflow_run_id and not _parent_workflow_can_publish_span_context(parent_workflow_run_id):
|
||||
logger.info(
|
||||
"[Arize/Phoenix] Parent workflow cannot publish Phoenix span context; falling back to root span: "
|
||||
"parent_workflow_run_id=%s parent_node_execution_id=%s",
|
||||
parent_workflow_run_id,
|
||||
parent_node_execution_id,
|
||||
)
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def setup_tracer(arize_phoenix_config: ArizeConfig | PhoenixConfig) -> tuple[trace_sdk.Tracer, SimpleSpanProcessor]:
|
||||
"""Configure OpenTelemetry tracer with OTLP exporter for Arize/Phoenix."""
|
||||
try:
|
||||
@@ -581,9 +624,11 @@ class ArizePhoenixDataTrace(BaseTraceInstance):
|
||||
workflow_session_id,
|
||||
)
|
||||
|
||||
workflow_parent_carrier: dict[str, str] | None = None
|
||||
if parent_node_execution_id:
|
||||
workflow_parent_carrier = _resolve_published_parent_span_context(parent_node_execution_id)
|
||||
else:
|
||||
workflow_parent_carrier = _resolve_workflow_parent_carrier(parent_node_execution_id, parent_workflow_run_id)
|
||||
|
||||
if workflow_parent_carrier is None:
|
||||
root_trace_id = _resolve_workflow_root_trace_id(trace_info)
|
||||
workflow_root_span_name: str | None = trace_info.workflow_run_id
|
||||
if not isinstance(workflow_root_span_name, str) or not workflow_root_span_name.strip():
|
||||
@@ -1176,7 +1221,7 @@ class ArizePhoenixDataTrace(BaseTraceInstance):
|
||||
if root_span_attributes:
|
||||
root_span_attributes_dict.update(root_span_attributes)
|
||||
|
||||
root_span = self.tracer.start_span(name=span_name, attributes=root_span_attributes_dict)
|
||||
root_span = self.tracer.start_span(name=span_name, attributes=root_span_attributes_dict, context=Context())
|
||||
|
||||
with use_span(root_span, end_on_exit=False):
|
||||
self.propagator.inject(carrier=carrier)
|
||||
|
||||
+208
-2
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
@@ -8,8 +10,10 @@ import pytest
|
||||
from dify_trace_arize_phoenix.arize_phoenix_trace import (
|
||||
_NODE_TYPE_TO_SPAN_KIND,
|
||||
ArizePhoenixDataTrace,
|
||||
_app_uses_phoenix_provider,
|
||||
_build_graph_parent_index,
|
||||
_get_node_span_kind,
|
||||
_parent_workflow_can_publish_span_context,
|
||||
_phoenix_parent_span_redis_key,
|
||||
_resolve_node_parent,
|
||||
_resolve_published_parent_span_context,
|
||||
@@ -25,9 +29,13 @@ from dify_trace_arize_phoenix.arize_phoenix_trace import (
|
||||
)
|
||||
from dify_trace_arize_phoenix.config import ArizeConfig, PhoenixConfig
|
||||
from openinference.semconv.trace import OpenInferenceSpanKindValues, SpanAttributes
|
||||
from opentelemetry.sdk.trace import Tracer
|
||||
from opentelemetry.context import Context
|
||||
from opentelemetry.sdk import trace as trace_sdk
|
||||
from opentelemetry.sdk.trace import ReadableSpan, Tracer
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult
|
||||
from opentelemetry.semconv.trace import SpanAttributes as OTELSpanAttributes
|
||||
from opentelemetry.trace import StatusCode
|
||||
from opentelemetry.trace import NonRecordingSpan, SpanContext, StatusCode, TraceFlags, TraceState, use_span
|
||||
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
||||
|
||||
from core.ops.entities.trace_entity import (
|
||||
DatasetRetrievalTraceInfo,
|
||||
@@ -96,6 +104,32 @@ def _get_start_span_call(start_span_mock, *, span_name: str):
|
||||
raise AssertionError(f"Could not find start_span call with name={span_name!r}")
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, result):
|
||||
self._result = result
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def where(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self._result
|
||||
|
||||
|
||||
class _CollectingSpanExporter(SpanExporter):
|
||||
def __init__(self):
|
||||
self.spans: list[ReadableSpan] = []
|
||||
|
||||
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
||||
self.spans.extend(spans)
|
||||
return SpanExportResult.SUCCESS
|
||||
|
||||
def shutdown(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _make_node_execution(**kwargs):
|
||||
defaults = {
|
||||
"node_type": "tool",
|
||||
@@ -233,6 +267,45 @@ def test_wrap_span_metadata():
|
||||
assert res == {"a": 1, "b": 2, "created_from": "Dify"}
|
||||
|
||||
|
||||
def test_app_uses_phoenix_provider_only_for_enabled_arize_or_phoenix():
|
||||
assert _app_uses_phoenix_provider({"enabled": True, "tracing_provider": "phoenix"}) is True
|
||||
assert _app_uses_phoenix_provider({"enabled": True, "tracing_provider": "arize"}) is True
|
||||
assert _app_uses_phoenix_provider({"enabled": False, "tracing_provider": "phoenix"}) is False
|
||||
assert _app_uses_phoenix_provider({"enabled": True, "tracing_provider": "langfuse"}) is False
|
||||
assert _app_uses_phoenix_provider(None) is False
|
||||
|
||||
|
||||
def test_parent_workflow_can_publish_span_context_keeps_unknown_parent_retryable(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_arize_phoenix.arize_phoenix_trace.db.session.query",
|
||||
lambda model: _FakeQuery(None),
|
||||
)
|
||||
|
||||
assert _parent_workflow_can_publish_span_context("missing-run") is True
|
||||
|
||||
|
||||
def test_parent_workflow_can_publish_span_context_checks_parent_app_tracing(monkeypatch):
|
||||
parent_run = SimpleNamespace(app_id="parent-app")
|
||||
parent_app = SimpleNamespace(tracing=json.dumps({"enabled": True, "tracing_provider": "phoenix"}))
|
||||
|
||||
def fake_query(model):
|
||||
if getattr(model, "__tablename__", None) == "workflow_runs":
|
||||
return _FakeQuery(parent_run)
|
||||
if getattr(model, "__tablename__", None) == "apps":
|
||||
return _FakeQuery(parent_app)
|
||||
raise AssertionError(f"Unexpected model query: {model}")
|
||||
|
||||
monkeypatch.setattr("dify_trace_arize_phoenix.arize_phoenix_trace.db.session.query", fake_query)
|
||||
|
||||
assert _parent_workflow_can_publish_span_context("parent-run") is True
|
||||
|
||||
parent_app.tracing = json.dumps({"enabled": False, "tracing_provider": "phoenix"})
|
||||
assert _parent_workflow_can_publish_span_context("parent-run") is False
|
||||
|
||||
parent_app.tracing = json.dumps({"enabled": True, "tracing_provider": "langfuse"})
|
||||
assert _parent_workflow_can_publish_span_context("parent-run") is False
|
||||
|
||||
|
||||
class TestGetNodeSpanKind:
|
||||
def test_all_node_types_are_mapped_correctly(self):
|
||||
special_mappings = {
|
||||
@@ -839,6 +912,10 @@ def test_workflow_trace_raises_pending_parent_error_when_parent_node_context_is_
|
||||
|
||||
with (
|
||||
patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()),
|
||||
patch(
|
||||
"dify_trace_arize_phoenix.arize_phoenix_trace._parent_workflow_can_publish_span_context",
|
||||
return_value=True,
|
||||
),
|
||||
patch.object(trace_instance, "ensure_root_span") as mock_ensure_root_span,
|
||||
pytest.raises(PendingTraceParentContextError) as exc_info,
|
||||
):
|
||||
@@ -851,6 +928,102 @@ def test_workflow_trace_raises_pending_parent_error_when_parent_node_context_is_
|
||||
mock_ensure_root_span.assert_not_called()
|
||||
|
||||
|
||||
@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db")
|
||||
@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory")
|
||||
@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker")
|
||||
def test_workflow_trace_falls_back_when_parent_app_tracing_cannot_publish_parent_context(
|
||||
mock_sessionmaker,
|
||||
mock_repo_factory,
|
||||
mock_db,
|
||||
trace_instance,
|
||||
):
|
||||
mock_db.engine = MagicMock()
|
||||
info = _make_workflow_info(
|
||||
message_id="message-1",
|
||||
workflow_run_id="workflow-run-1",
|
||||
metadata={
|
||||
"app_id": "app1",
|
||||
"parent_trace_context": {
|
||||
"parent_workflow_run_id": "outer-workflow-run-1",
|
||||
"parent_node_execution_id": "outer-node-execution-1",
|
||||
},
|
||||
},
|
||||
)
|
||||
repo = MagicMock()
|
||||
repo.get_by_workflow_execution.return_value = []
|
||||
mock_repo_factory.create_workflow_node_execution_repository.return_value = repo
|
||||
trace_instance._mock_redis_client.get.return_value = None
|
||||
|
||||
parent_carrier = {}
|
||||
parent_context = object()
|
||||
|
||||
with (
|
||||
patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()),
|
||||
patch(
|
||||
"dify_trace_arize_phoenix.arize_phoenix_trace._parent_workflow_can_publish_span_context",
|
||||
return_value=False,
|
||||
),
|
||||
patch.object(trace_instance, "ensure_root_span", return_value=parent_carrier) as mock_ensure_root_span,
|
||||
patch.object(trace_instance.propagator, "extract", return_value=parent_context) as mock_extract,
|
||||
):
|
||||
trace_instance.workflow_trace(info)
|
||||
|
||||
mock_ensure_root_span.assert_called_once_with(
|
||||
"outer-workflow-run-1",
|
||||
root_span_name="workflow-run-1",
|
||||
root_span_attributes={
|
||||
SpanAttributes.INPUT_VALUE: safe_json_dumps(info.workflow_run_inputs),
|
||||
SpanAttributes.INPUT_MIME_TYPE: "application/json",
|
||||
SpanAttributes.OUTPUT_VALUE: safe_json_dumps(info.workflow_run_outputs),
|
||||
SpanAttributes.OUTPUT_MIME_TYPE: "application/json",
|
||||
},
|
||||
)
|
||||
mock_extract.assert_called_once_with(carrier=parent_carrier)
|
||||
workflow_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow_workflow-run-1")
|
||||
assert workflow_span_call.kwargs["context"] is parent_context
|
||||
|
||||
|
||||
@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db")
|
||||
@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory")
|
||||
@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker")
|
||||
def test_workflow_trace_still_retries_when_parent_app_can_publish_parent_context(
|
||||
mock_sessionmaker,
|
||||
mock_repo_factory,
|
||||
mock_db,
|
||||
trace_instance,
|
||||
):
|
||||
mock_db.engine = MagicMock()
|
||||
info = _make_workflow_info(
|
||||
message_id="message-1",
|
||||
workflow_run_id="workflow-run-1",
|
||||
metadata={
|
||||
"app_id": "app1",
|
||||
"parent_trace_context": {
|
||||
"parent_workflow_run_id": "outer-workflow-run-1",
|
||||
"parent_node_execution_id": "outer-node-execution-1",
|
||||
},
|
||||
},
|
||||
)
|
||||
repo = MagicMock()
|
||||
repo.get_by_workflow_execution.return_value = []
|
||||
mock_repo_factory.create_workflow_node_execution_repository.return_value = repo
|
||||
trace_instance._mock_redis_client.get.return_value = None
|
||||
|
||||
with (
|
||||
patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()),
|
||||
patch(
|
||||
"dify_trace_arize_phoenix.arize_phoenix_trace._parent_workflow_can_publish_span_context",
|
||||
return_value=True,
|
||||
),
|
||||
patch.object(trace_instance, "ensure_root_span") as mock_ensure_root_span,
|
||||
pytest.raises(PendingTraceParentContextError) as exc_info,
|
||||
):
|
||||
trace_instance.workflow_trace(info)
|
||||
|
||||
assert exc_info.value.parent_node_execution_id == "outer-node-execution-1"
|
||||
mock_ensure_root_span.assert_not_called()
|
||||
|
||||
|
||||
@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db")
|
||||
@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory")
|
||||
@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker")
|
||||
@@ -1544,6 +1717,38 @@ def test_ensure_root_span_basic(trace_instance):
|
||||
assert "tid" in trace_instance.dify_trace_ids
|
||||
|
||||
|
||||
def test_ensure_root_span_ignores_unsampled_ambient_otel_parent():
|
||||
exporter = _CollectingSpanExporter()
|
||||
provider = trace_sdk.TracerProvider()
|
||||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||||
trace_instance = ArizePhoenixDataTrace.__new__(ArizePhoenixDataTrace)
|
||||
trace_instance.tracer = cast(Tracer, provider.get_tracer("test-phoenix-root-span"))
|
||||
trace_instance.propagator = TraceContextTextMapPropagator()
|
||||
trace_instance.project = "p"
|
||||
trace_instance.dify_trace_ids = set()
|
||||
trace_instance.root_span_carriers = {}
|
||||
trace_instance.carrier = {}
|
||||
|
||||
ambient_span_context = SpanContext(
|
||||
trace_id=0x11111111111111111111111111111111,
|
||||
span_id=0x2222222222222222,
|
||||
is_remote=True,
|
||||
trace_flags=TraceFlags(0),
|
||||
trace_state=TraceState(),
|
||||
)
|
||||
|
||||
with use_span(NonRecordingSpan(ambient_span_context), end_on_exit=False):
|
||||
carrier = trace_instance.ensure_root_span("tid")
|
||||
|
||||
assert len(exporter.spans) == 1
|
||||
root_span = exporter.spans[0]
|
||||
root_span_context = root_span.get_span_context()
|
||||
assert root_span_context is not None
|
||||
assert root_span.parent is None
|
||||
assert root_span_context.trace_id != ambient_span_context.trace_id
|
||||
assert carrier["traceparent"].split("-")[1] == f"{root_span_context.trace_id:032x}"
|
||||
|
||||
|
||||
def test_ensure_root_span_uses_custom_name_and_attributes(trace_instance):
|
||||
root_attributes = {
|
||||
SpanAttributes.INPUT_VALUE: '{"input":"value"}',
|
||||
@@ -1561,6 +1766,7 @@ def test_ensure_root_span_uses_custom_name_and_attributes(trace_instance):
|
||||
SpanAttributes.INPUT_VALUE: '{"input":"value"}',
|
||||
SpanAttributes.OUTPUT_VALUE: '{"output":"value"}',
|
||||
},
|
||||
context=Context(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.14.1"
|
||||
version = "1.14.2"
|
||||
requires-python = "~=3.12.0"
|
||||
|
||||
dependencies = [
|
||||
|
||||
@@ -16,6 +16,7 @@ from pydantic import TypeAdapter
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from core.agent.entities import AgentToolEntity
|
||||
from core.helper import marketplace
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
@@ -310,6 +311,8 @@ class PluginMigration:
|
||||
"""
|
||||
Fetch plugin unique identifier using plugin id.
|
||||
"""
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
return None
|
||||
plugin_manifest = marketplace.batch_fetch_plugin_manifests([plugin_id])
|
||||
if not plugin_manifest:
|
||||
return None
|
||||
@@ -542,6 +545,11 @@ class PluginMigration:
|
||||
"""
|
||||
Install plugins for a tenant.
|
||||
"""
|
||||
if plugin_identifiers_map and not dify_config.MARKETPLACE_ENABLED:
|
||||
raise ValueError(
|
||||
"Marketplace disabled in offline mode; cannot bulk-install plugins. "
|
||||
"Pre-upload plugin packages via Console first."
|
||||
)
|
||||
manager = PluginInstaller()
|
||||
|
||||
# download all the plugins and upload
|
||||
|
||||
@@ -73,35 +73,43 @@ class PluginService:
|
||||
cache_not_exists.append(plugin_id)
|
||||
|
||||
if cache_not_exists:
|
||||
manifests = {
|
||||
manifest.plugin_id: manifest
|
||||
for manifest in marketplace.batch_fetch_plugin_manifests(cache_not_exists)
|
||||
}
|
||||
|
||||
for plugin_id, manifest in manifests.items():
|
||||
latest_plugin = PluginService.LatestPluginCache(
|
||||
plugin_id=plugin_id,
|
||||
version=manifest.latest_version,
|
||||
unique_identifier=manifest.latest_package_identifier,
|
||||
status=manifest.status,
|
||||
deprecated_reason=manifest.deprecated_reason,
|
||||
alternative_plugin_id=manifest.alternative_plugin_id,
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
logger.info(
|
||||
"Marketplace disabled; skipping latest-plugins metadata fetch for %d ids",
|
||||
len(cache_not_exists),
|
||||
)
|
||||
for plugin_id in cache_not_exists:
|
||||
result[plugin_id] = None
|
||||
else:
|
||||
manifests = {
|
||||
manifest.plugin_id: manifest
|
||||
for manifest in marketplace.batch_fetch_plugin_manifests(cache_not_exists)
|
||||
}
|
||||
|
||||
# Store in Redis
|
||||
redis_client.setex(
|
||||
f"{PluginService.REDIS_KEY_PREFIX}{plugin_id}",
|
||||
PluginService.REDIS_TTL,
|
||||
latest_plugin.model_dump_json(),
|
||||
)
|
||||
for plugin_id, manifest in manifests.items():
|
||||
latest_plugin = PluginService.LatestPluginCache(
|
||||
plugin_id=plugin_id,
|
||||
version=manifest.latest_version,
|
||||
unique_identifier=manifest.latest_package_identifier,
|
||||
status=manifest.status,
|
||||
deprecated_reason=manifest.deprecated_reason,
|
||||
alternative_plugin_id=manifest.alternative_plugin_id,
|
||||
)
|
||||
|
||||
result[plugin_id] = latest_plugin
|
||||
# Store in Redis
|
||||
redis_client.setex(
|
||||
f"{PluginService.REDIS_KEY_PREFIX}{plugin_id}",
|
||||
PluginService.REDIS_TTL,
|
||||
latest_plugin.model_dump_json(),
|
||||
)
|
||||
|
||||
# pop plugin_id from cache_not_exists
|
||||
cache_not_exists.remove(plugin_id)
|
||||
result[plugin_id] = latest_plugin
|
||||
|
||||
for plugin_id in cache_not_exists:
|
||||
result[plugin_id] = None
|
||||
# pop plugin_id from cache_not_exists
|
||||
cache_not_exists.remove(plugin_id)
|
||||
|
||||
for plugin_id in cache_not_exists:
|
||||
result[plugin_id] = None
|
||||
|
||||
return result
|
||||
except Exception:
|
||||
|
||||
@@ -1350,6 +1350,12 @@ class RagPipelineService:
|
||||
)
|
||||
return workflow_node_execution_db_model
|
||||
|
||||
def _fetch_recommended_plugin_manifests(self, plugin_ids: list[str]) -> list[Any]:
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
logger.info("Marketplace disabled; recommended-plugins list empty")
|
||||
return []
|
||||
return marketplace.batch_fetch_plugin_by_ids(plugin_ids)
|
||||
|
||||
def get_recommended_plugins(self, type: str) -> dict[str, Any]:
|
||||
# Query active recommended plugins
|
||||
stmt = select(PipelineRecommendedPlugin).where(PipelineRecommendedPlugin.active == True)
|
||||
@@ -1372,7 +1378,7 @@ class RagPipelineService:
|
||||
)
|
||||
providers_map = {provider.plugin_id: provider.to_dict() for provider in providers}
|
||||
|
||||
plugin_manifests = marketplace.batch_fetch_plugin_by_ids(plugin_ids)
|
||||
plugin_manifests = self._fetch_recommended_plugin_manifests(plugin_ids)
|
||||
plugin_manifests_map = {manifest["plugin_id"]: manifest for manifest in plugin_manifests}
|
||||
|
||||
installed_plugin_list = []
|
||||
|
||||
@@ -9,6 +9,7 @@ import yaml
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import select
|
||||
|
||||
from configs import dify_config
|
||||
from constants import DOCUMENT_EXTENSIONS
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
|
||||
@@ -273,6 +274,13 @@ class RagPipelineTransformService:
|
||||
plugin_unique_identifier = dependency.get("value", {}).get("plugin_unique_identifier")
|
||||
plugin_id = plugin_unique_identifier.split(":")[0]
|
||||
if plugin_id not in installed_plugins_ids:
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
logger.warning(
|
||||
"Marketplace disabled; skipping auto-install of %s. "
|
||||
"Pre-install via Console if pipeline requires it.",
|
||||
plugin_id,
|
||||
)
|
||||
continue
|
||||
plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(plugin_id) # type: ignore
|
||||
if plugin_unique_identifier:
|
||||
need_install_plugin_unique_identifiers.append(plugin_unique_identifier)
|
||||
|
||||
@@ -496,6 +496,51 @@ def db_session_with_containers(flask_app_with_containers: Flask) -> Generator[Se
|
||||
logger.debug("Database session closed")
|
||||
|
||||
|
||||
def _truncate_container_database(app: Flask) -> None:
|
||||
"""
|
||||
Reset application tables after a container integration test.
|
||||
|
||||
Tests in this package share one PostgreSQL container for performance, while
|
||||
application code may commit through db.session, Session(db.engine), or
|
||||
session_factory-created sessions. Truncating after each test gives the suite
|
||||
a central DB isolation contract that does not depend on which session a test used.
|
||||
This only covers SQLAlchemy application tables in db.metadata for now;
|
||||
Redis, object storage, and custom ad hoc metadata still need their own cleanup.
|
||||
"""
|
||||
with app.app_context():
|
||||
db.session.remove()
|
||||
|
||||
tables = db.metadata.sorted_tables
|
||||
if not tables:
|
||||
return
|
||||
|
||||
preparer = db.engine.dialect.identifier_preparer
|
||||
table_names = ", ".join(preparer.format_table(table) for table in tables)
|
||||
|
||||
with db.engine.begin() as conn:
|
||||
conn.execute(text("SET LOCAL lock_timeout = '5s'"))
|
||||
conn.execute(text(f"TRUNCATE TABLE {table_names} RESTART IDENTITY CASCADE"))
|
||||
|
||||
db.session.remove()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_container_database(request: pytest.FixtureRequest) -> Generator[None, None, None]:
|
||||
"""
|
||||
Clean DB state after tests that use the containerized Flask app.
|
||||
|
||||
This fixture intentionally does not depend on flask_app_with_containers so
|
||||
non-DB tests under this package do not start the full app/container stack.
|
||||
"""
|
||||
yield
|
||||
|
||||
if "flask_app_with_containers" not in request.fixturenames:
|
||||
return
|
||||
|
||||
app = request.getfixturevalue("flask_app_with_containers")
|
||||
_truncate_container_database(app)
|
||||
|
||||
|
||||
@pytest.fixture(scope="package", autouse=True)
|
||||
def mock_ssrf_proxy_requests():
|
||||
"""
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskClient
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -126,7 +127,7 @@ class TestAppApiKeyResource:
|
||||
|
||||
def test_delete_forbidden_for_non_admin(
|
||||
self,
|
||||
flask_app_with_containers,
|
||||
flask_app_with_containers: Flask,
|
||||
) -> None:
|
||||
"""A non-admin member cannot delete API keys via the controller permission check."""
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
+1
-1
@@ -575,7 +575,7 @@ class TestTriggerSubscriptionVerifyApi:
|
||||
assert method(api, "github", "s1") == {"ok": True}
|
||||
|
||||
@pytest.mark.parametrize("raised_exception", [ValueError("bad"), Exception("boom")])
|
||||
def test_verify_errors(self, app, raised_exception):
|
||||
def test_verify_errors(self, app: Flask, raised_exception):
|
||||
api = TriggerSubscriptionVerifyApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
|
||||
@@ -277,7 +277,7 @@ class TestDecodeJwtToken:
|
||||
mock_extract: MagicMock,
|
||||
mock_passport_cls: MagicMock,
|
||||
mock_features: MagicMock,
|
||||
app,
|
||||
app: Flask,
|
||||
) -> None:
|
||||
non_existent_id = str(uuid4())
|
||||
mock_extract.return_value = "jwt-token"
|
||||
|
||||
+13
-12
@@ -5,6 +5,7 @@ from unittest.mock import Mock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.source import DataSourceApiKeyAuthBinding
|
||||
@@ -45,7 +46,7 @@ class TestApiKeyAuthService:
|
||||
return binding
|
||||
|
||||
def test_get_provider_auth_list_success(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session, tenant_id, category, provider
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id, category, provider
|
||||
):
|
||||
self._create_binding(db_session_with_containers, tenant_id=tenant_id, category=category, provider=provider)
|
||||
db_session_with_containers.expire_all()
|
||||
@@ -58,7 +59,7 @@ class TestApiKeyAuthService:
|
||||
assert tenant_results[0].provider == provider
|
||||
|
||||
def test_get_provider_auth_list_empty(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session, tenant_id
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id
|
||||
):
|
||||
result = ApiKeyAuthService.get_provider_auth_list(tenant_id)
|
||||
|
||||
@@ -66,7 +67,7 @@ class TestApiKeyAuthService:
|
||||
assert tenant_results == []
|
||||
|
||||
def test_get_provider_auth_list_filters_disabled(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session, tenant_id, category, provider
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id, category, provider
|
||||
):
|
||||
self._create_binding(
|
||||
db_session_with_containers, tenant_id=tenant_id, category=category, provider=provider, disabled=True
|
||||
@@ -84,7 +85,7 @@ class TestApiKeyAuthService:
|
||||
self,
|
||||
mock_encrypter,
|
||||
mock_factory,
|
||||
flask_app_with_containers,
|
||||
flask_app_with_containers: Flask,
|
||||
db_session_with_containers: Session,
|
||||
tenant_id,
|
||||
mock_args,
|
||||
@@ -106,7 +107,7 @@ class TestApiKeyAuthService:
|
||||
|
||||
@patch("services.auth.api_key_auth_service.ApiKeyAuthFactory")
|
||||
def test_create_provider_auth_validation_failed(
|
||||
self, mock_factory, flask_app_with_containers, db_session_with_containers: Session, tenant_id, mock_args
|
||||
self, mock_factory, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id, mock_args
|
||||
):
|
||||
mock_auth_instance = Mock()
|
||||
mock_auth_instance.validate_credentials.return_value = False
|
||||
@@ -124,7 +125,7 @@ class TestApiKeyAuthService:
|
||||
self,
|
||||
mock_encrypter,
|
||||
mock_factory,
|
||||
flask_app_with_containers,
|
||||
flask_app_with_containers: Flask,
|
||||
db_session_with_containers: Session,
|
||||
tenant_id,
|
||||
mock_args,
|
||||
@@ -144,7 +145,7 @@ class TestApiKeyAuthService:
|
||||
|
||||
def test_get_auth_credentials_success(
|
||||
self,
|
||||
flask_app_with_containers,
|
||||
flask_app_with_containers: Flask,
|
||||
db_session_with_containers: Session,
|
||||
tenant_id,
|
||||
category,
|
||||
@@ -165,14 +166,14 @@ class TestApiKeyAuthService:
|
||||
assert result == mock_credentials
|
||||
|
||||
def test_get_auth_credentials_not_found(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session, tenant_id, category, provider
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id, category, provider
|
||||
):
|
||||
result = ApiKeyAuthService.get_auth_credentials(tenant_id, category, provider)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_get_auth_credentials_json_parsing(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session, tenant_id, category, provider
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id, category, provider
|
||||
):
|
||||
special_credentials = {"auth_type": "api_key", "config": {"api_key": "key_with_中文_and_special_chars_!@#$%"}}
|
||||
self._create_binding(
|
||||
@@ -190,7 +191,7 @@ class TestApiKeyAuthService:
|
||||
assert result["config"]["api_key"] == "key_with_中文_and_special_chars_!@#$%"
|
||||
|
||||
def test_delete_provider_auth_success(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session, tenant_id, category, provider
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id, category, provider
|
||||
):
|
||||
binding = self._create_binding(
|
||||
db_session_with_containers, tenant_id=tenant_id, category=category, provider=provider
|
||||
@@ -205,7 +206,7 @@ class TestApiKeyAuthService:
|
||||
assert remaining is None
|
||||
|
||||
def test_delete_provider_auth_not_found(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session, tenant_id
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id
|
||||
):
|
||||
# Should not raise when binding not found
|
||||
ApiKeyAuthService.delete_provider_auth(tenant_id, str(uuid4()))
|
||||
@@ -275,7 +276,7 @@ class TestApiKeyAuthService:
|
||||
@patch("services.auth.api_key_auth_service.ApiKeyAuthFactory")
|
||||
@patch("services.auth.api_key_auth_service.encrypter")
|
||||
def test_create_provider_auth_database_error_handling(
|
||||
self, mock_encrypter, mock_factory, flask_app_with_containers, tenant_id, mock_args
|
||||
self, mock_encrypter, mock_factory, flask_app_with_containers: Flask, tenant_id, mock_args
|
||||
):
|
||||
mock_auth_instance = Mock()
|
||||
mock_auth_instance.validate_credentials.return_value = True
|
||||
|
||||
+12
-11
@@ -10,6 +10,7 @@ from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.source import DataSourceApiKeyAuthBinding
|
||||
@@ -45,8 +46,8 @@ class TestAuthIntegration:
|
||||
self,
|
||||
mock_encrypt,
|
||||
mock_http,
|
||||
flask_app_with_containers,
|
||||
db_session_with_containers,
|
||||
flask_app_with_containers: Flask,
|
||||
db_session_with_containers: Session,
|
||||
tenant_id_1,
|
||||
category,
|
||||
firecrawl_credentials,
|
||||
@@ -86,8 +87,8 @@ class TestAuthIntegration:
|
||||
mock_jina_http,
|
||||
mock_fc_http,
|
||||
mock_encrypt,
|
||||
flask_app_with_containers,
|
||||
db_session_with_containers,
|
||||
flask_app_with_containers: Flask,
|
||||
db_session_with_containers: Session,
|
||||
tenant_id_1,
|
||||
tenant_id_2,
|
||||
category,
|
||||
@@ -115,7 +116,7 @@ class TestAuthIntegration:
|
||||
assert result2[0].tenant_id == tenant_id_2
|
||||
|
||||
def test_cross_tenant_access_prevention(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session, tenant_id_2, category
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id_2, category
|
||||
):
|
||||
result = ApiKeyAuthService.get_auth_credentials(tenant_id_2, category, AuthType.FIRECRAWL)
|
||||
|
||||
@@ -139,8 +140,8 @@ class TestAuthIntegration:
|
||||
self,
|
||||
mock_encrypt,
|
||||
mock_http,
|
||||
flask_app_with_containers,
|
||||
db_session_with_containers,
|
||||
flask_app_with_containers: Flask,
|
||||
db_session_with_containers: Session,
|
||||
tenant_id_1,
|
||||
category,
|
||||
firecrawl_credentials,
|
||||
@@ -201,8 +202,8 @@ class TestAuthIntegration:
|
||||
def test_network_failure_recovery(
|
||||
self,
|
||||
mock_http,
|
||||
flask_app_with_containers,
|
||||
db_session_with_containers,
|
||||
flask_app_with_containers: Flask,
|
||||
db_session_with_containers: Session,
|
||||
tenant_id_1,
|
||||
category,
|
||||
firecrawl_credentials,
|
||||
@@ -239,8 +240,8 @@ class TestAuthIntegration:
|
||||
self,
|
||||
mock_http,
|
||||
mock_encrypt,
|
||||
flask_app_with_containers,
|
||||
db_session_with_containers,
|
||||
flask_app_with_containers: Flask,
|
||||
db_session_with_containers: Session,
|
||||
tenant_id_1,
|
||||
category,
|
||||
firecrawl_credentials,
|
||||
|
||||
+3
-3
@@ -14,7 +14,7 @@ from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from core.rag.index_processor.constant.index_type import IndexTechniqueType
|
||||
from models import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus
|
||||
from models.dataset import AppDatasetJoin, Dataset, DatasetPermissionEnum
|
||||
from models.enums import DataSourceType
|
||||
from models.model import App
|
||||
@@ -38,13 +38,13 @@ class DatasetUpdateDeleteTestDataFactory:
|
||||
email=f"{uuid4()}@example.com",
|
||||
name=f"user-{uuid4()}",
|
||||
interface_language="en-US",
|
||||
status="active",
|
||||
status=AccountStatus.ACTIVE,
|
||||
)
|
||||
db_session_with_containers.add(account)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
if tenant is None:
|
||||
tenant = Tenant(name=f"tenant-{uuid4()}", status="normal")
|
||||
tenant = Tenant(name=f"tenant-{uuid4()}", status=TenantStatus.NORMAL)
|
||||
db_session_with_containers.add(tenant)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
|
||||
+5
-4
@@ -10,6 +10,7 @@ from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from redis import RedisError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -123,7 +124,7 @@ class TestSyncAccountDeletion:
|
||||
mock_queue_task.assert_not_called()
|
||||
|
||||
def test_sync_account_deletion_multiple_workspaces(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session, mock_queue_task
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers: Session, mock_queue_task
|
||||
):
|
||||
account_id = str(uuid4())
|
||||
tenant_ids = [str(uuid4()) for _ in range(3)]
|
||||
@@ -145,7 +146,7 @@ class TestSyncAccountDeletion:
|
||||
assert queued_workspace_ids == set(tenant_ids)
|
||||
|
||||
def test_sync_account_deletion_no_workspaces(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session, mock_queue_task
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers: Session, mock_queue_task
|
||||
):
|
||||
with patch("services.enterprise.account_deletion_sync.dify_config") as mock_config:
|
||||
mock_config.ENTERPRISE_ENABLED = True
|
||||
@@ -156,7 +157,7 @@ class TestSyncAccountDeletion:
|
||||
mock_queue_task.assert_not_called()
|
||||
|
||||
def test_sync_account_deletion_partial_failure(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session, mock_queue_task
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers: Session, mock_queue_task
|
||||
):
|
||||
account_id = str(uuid4())
|
||||
tenant_ids = [str(uuid4()) for _ in range(3)]
|
||||
@@ -181,7 +182,7 @@ class TestSyncAccountDeletion:
|
||||
assert mock_queue_task.call_count == 3
|
||||
|
||||
def test_sync_account_deletion_all_failures(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session, mock_queue_task
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers: Session, mock_queue_task
|
||||
):
|
||||
account_id = str(uuid4())
|
||||
tenant_id = str(uuid4())
|
||||
|
||||
+5
-4
@@ -11,6 +11,7 @@ from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from core.plugin.entities.plugin_daemon import CredentialType
|
||||
from models.tools import BuiltinToolProvider
|
||||
@@ -49,8 +50,8 @@ class TestGetDynamicSelectOptionsTool:
|
||||
mock_tool_mgr,
|
||||
mock_encrypter_fn,
|
||||
mock_client_cls,
|
||||
flask_app_with_containers,
|
||||
db_session_with_containers,
|
||||
flask_app_with_containers: Flask,
|
||||
db_session_with_containers: Session,
|
||||
):
|
||||
tenant_id = str(uuid4())
|
||||
provider_ctrl = MagicMock()
|
||||
@@ -91,8 +92,8 @@ class TestGetDynamicSelectOptionsTool:
|
||||
self,
|
||||
mock_tool_mgr,
|
||||
mock_encrypter_fn,
|
||||
flask_app_with_containers,
|
||||
db_session_with_containers,
|
||||
flask_app_with_containers: Flask,
|
||||
db_session_with_containers: Session,
|
||||
):
|
||||
provider_ctrl = MagicMock()
|
||||
provider_ctrl.need_credentials = True
|
||||
|
||||
@@ -11,10 +11,13 @@ from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
from core.plugin.entities.plugin_daemon import PluginVerification
|
||||
from models import ProviderType
|
||||
from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider
|
||||
from services.errors.plugin import PluginInstallationForbiddenError
|
||||
from services.feature_service import PluginInstallationScope
|
||||
@@ -346,7 +349,7 @@ class TestUninstall:
|
||||
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
def test_cleans_credentials_when_plugin_found(
|
||||
self, mock_installer_cls, flask_app_with_containers, db_session_with_containers
|
||||
self, mock_installer_cls, flask_app_with_containers: Flask, db_session_with_containers: Session
|
||||
):
|
||||
tenant_id = str(uuid4())
|
||||
plugin_id = "org/myplugin"
|
||||
@@ -374,7 +377,7 @@ class TestUninstall:
|
||||
pref = TenantPreferredModelProvider(
|
||||
tenant_id=tenant_id,
|
||||
provider_name=provider_name,
|
||||
preferred_provider_type="custom",
|
||||
preferred_provider_type=ProviderType.CUSTOM,
|
||||
)
|
||||
db_session_with_containers.add(pref)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
+18
-11
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.model import App, RecommendedApp, Site
|
||||
@@ -10,7 +11,7 @@ from services.recommend_app.database.database_retrieval import DatabaseRecommend
|
||||
from services.recommend_app.recommend_app_type import RecommendAppType
|
||||
|
||||
|
||||
def _create_app(db_session, *, tenant_id: str, is_public: bool = True) -> App:
|
||||
def _create_app(db_session: Session, *, tenant_id: str, is_public: bool = True) -> App:
|
||||
app = App(
|
||||
tenant_id=tenant_id,
|
||||
name=f"app-{uuid4()}",
|
||||
@@ -25,7 +26,7 @@ def _create_app(db_session, *, tenant_id: str, is_public: bool = True) -> App:
|
||||
return app
|
||||
|
||||
|
||||
def _create_site(db_session, *, app_id: str) -> Site:
|
||||
def _create_site(db_session: Session, *, app_id: str) -> Site:
|
||||
site = Site(
|
||||
app_id=app_id,
|
||||
title=f"site-{uuid4()}",
|
||||
@@ -95,7 +96,9 @@ class TestDatabaseRecommendAppRetrieval:
|
||||
|
||||
|
||||
class TestFetchRecommendedAppsFromDb:
|
||||
def test_returns_apps_and_sorted_categories(self, flask_app_with_containers, db_session_with_containers: Session):
|
||||
def test_returns_apps_and_sorted_categories(
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers: Session
|
||||
):
|
||||
tenant_id = str(uuid4())
|
||||
app1 = _create_app(db_session_with_containers, tenant_id=tenant_id)
|
||||
_create_site(db_session_with_containers, app_id=app1.id)
|
||||
@@ -116,7 +119,7 @@ class TestFetchRecommendedAppsFromDb:
|
||||
assert "writing" in result["categories"]
|
||||
|
||||
def test_returns_multiple_categories_for_one_app(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers: Session
|
||||
):
|
||||
tenant_id = str(uuid4())
|
||||
created_app = _create_app(db_session_with_containers, tenant_id=tenant_id)
|
||||
@@ -139,7 +142,7 @@ class TestFetchRecommendedAppsFromDb:
|
||||
|
||||
def test_ignores_legacy_category_when_categories_are_empty(
|
||||
self,
|
||||
flask_app_with_containers,
|
||||
flask_app_with_containers: Flask,
|
||||
db_session_with_containers: Session,
|
||||
):
|
||||
legacy_category = f"legacy-empty-{uuid4()}"
|
||||
@@ -163,7 +166,7 @@ class TestFetchRecommendedAppsFromDb:
|
||||
assert legacy_category not in result["categories"]
|
||||
|
||||
def test_falls_back_to_default_language_when_empty(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers: Session
|
||||
):
|
||||
tenant_id = str(uuid4())
|
||||
app1 = _create_app(db_session_with_containers, tenant_id=tenant_id)
|
||||
@@ -177,7 +180,7 @@ class TestFetchRecommendedAppsFromDb:
|
||||
app_ids = {r["app_id"] for r in result["recommended_apps"]}
|
||||
assert app1.id in app_ids
|
||||
|
||||
def test_skips_non_public_apps(self, flask_app_with_containers, db_session_with_containers: Session):
|
||||
def test_skips_non_public_apps(self, flask_app_with_containers: Flask, db_session_with_containers: Session):
|
||||
tenant_id = str(uuid4())
|
||||
app1 = _create_app(db_session_with_containers, tenant_id=tenant_id, is_public=False)
|
||||
_create_site(db_session_with_containers, app_id=app1.id)
|
||||
@@ -190,7 +193,7 @@ class TestFetchRecommendedAppsFromDb:
|
||||
app_ids = {r["app_id"] for r in result["recommended_apps"]}
|
||||
assert app1.id not in app_ids
|
||||
|
||||
def test_skips_apps_without_site(self, flask_app_with_containers, db_session_with_containers: Session):
|
||||
def test_skips_apps_without_site(self, flask_app_with_containers: Flask, db_session_with_containers: Session):
|
||||
tenant_id = str(uuid4())
|
||||
app1 = _create_app(db_session_with_containers, tenant_id=tenant_id)
|
||||
_create_recommended_app(db_session_with_containers, app_id=app1.id)
|
||||
@@ -204,12 +207,14 @@ class TestFetchRecommendedAppsFromDb:
|
||||
|
||||
|
||||
class TestFetchRecommendedAppDetailFromDb:
|
||||
def test_returns_none_when_not_listed(self, flask_app_with_containers, db_session_with_containers: Session):
|
||||
def test_returns_none_when_not_listed(self, flask_app_with_containers: Flask, db_session_with_containers: Session):
|
||||
result = DatabaseRecommendAppRetrieval.fetch_recommended_app_detail_from_db(str(uuid4()))
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_app_not_public(self, flask_app_with_containers, db_session_with_containers: Session):
|
||||
def test_returns_none_when_app_not_public(
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers: Session
|
||||
):
|
||||
tenant_id = str(uuid4())
|
||||
app1 = _create_app(db_session_with_containers, tenant_id=tenant_id, is_public=False)
|
||||
_create_recommended_app(db_session_with_containers, app_id=app1.id)
|
||||
@@ -221,7 +226,9 @@ class TestFetchRecommendedAppDetailFromDb:
|
||||
assert result is None
|
||||
|
||||
@patch("services.recommend_app.database.database_retrieval.AppDslService")
|
||||
def test_returns_detail_on_success(self, mock_dsl, flask_app_with_containers, db_session_with_containers: Session):
|
||||
def test_returns_detail_on_success(
|
||||
self, mock_dsl, flask_app_with_containers: Flask, db_session_with_containers: Session
|
||||
):
|
||||
tenant_id = str(uuid4())
|
||||
app1 = _create_app(db_session_with_containers, tenant_id=tenant_id)
|
||||
_create_site(db_session_with_containers, app_id=app1.id)
|
||||
|
||||
@@ -6,7 +6,7 @@ from faker import Faker
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.plugin.impl.exc import PluginDaemonClientSideError
|
||||
from models import Account, CreatorUserRole
|
||||
from models import Account, AppMode, CreatorUserRole
|
||||
from models.enums import ConversationFromSource, MessageFileBelongsTo
|
||||
from models.model import AppModelConfig, Conversation, EndUser, Message, MessageAgentThought
|
||||
from services.account_service import AccountService, TenantService
|
||||
@@ -134,7 +134,7 @@ class TestAgentService:
|
||||
app = app_service.create_app(tenant.id, app_args, account)
|
||||
|
||||
# Update the app model config to set agent_mode for agent-chat mode
|
||||
if app.mode == "agent-chat" and app.app_model_config:
|
||||
if app.mode == AppMode.AGENT_CHAT and app.app_model_config:
|
||||
app.app_model_config.agent_mode = json.dumps({"enabled": True, "strategy": "react", "tools": []})
|
||||
|
||||
db_session_with_containers.commit()
|
||||
@@ -272,7 +272,7 @@ class TestAgentService:
|
||||
tool_input=json.dumps({"dataset_tool": {"query": "test_query"}}),
|
||||
observation=json.dumps({"dataset_tool": {"results": "test_results"}}),
|
||||
tokens=30,
|
||||
created_by_role="account",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=message.from_account_id,
|
||||
)
|
||||
db_session_with_containers.add(thought2)
|
||||
|
||||
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
|
||||
import services.api_token_service as api_token_service_module
|
||||
@@ -14,7 +15,7 @@ from services.api_token_service import ApiTokenCache, CachedApiToken
|
||||
|
||||
class TestQueryTokenFromDb:
|
||||
def test_should_return_api_token_and_cache_when_token_exists(
|
||||
self, flask_app_with_containers, db_session_with_containers
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers
|
||||
):
|
||||
tenant_id = str(uuid4())
|
||||
app_id = str(uuid4())
|
||||
@@ -41,7 +42,7 @@ class TestQueryTokenFromDb:
|
||||
mock_record_usage.assert_called_once_with(token_value, "app")
|
||||
|
||||
def test_should_cache_null_and_raise_unauthorized_when_token_not_found(
|
||||
self, flask_app_with_containers, db_session_with_containers
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers
|
||||
):
|
||||
with (
|
||||
patch.object(api_token_service_module.ApiTokenCache, "set") as mock_cache_set,
|
||||
|
||||
+5
-4
@@ -15,6 +15,7 @@ from uuid import uuid4
|
||||
from zipfile import ZipFile
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import services.file_service as file_service_module
|
||||
from extensions.storage.storage_type import StorageType
|
||||
@@ -23,7 +24,7 @@ from models.model import UploadFile
|
||||
from services.file_service import FileService
|
||||
|
||||
|
||||
def _create_upload_file(db_session, *, tenant_id: str, key: str, name: str) -> UploadFile:
|
||||
def _create_upload_file(db_session: Session, *, tenant_id: str, key: str, name: str) -> UploadFile:
|
||||
upload_file = UploadFile(
|
||||
tenant_id=tenant_id,
|
||||
storage_type=StorageType.OPENDAL,
|
||||
@@ -66,12 +67,12 @@ def test_build_upload_files_zip_tempfile_sanitizes_and_dedupes_names(monkeypatch
|
||||
assert zf.read("b (2).txt") == b"three"
|
||||
|
||||
|
||||
def test_get_upload_files_by_ids_returns_empty_when_no_ids(db_session_with_containers) -> None:
|
||||
def test_get_upload_files_by_ids_returns_empty_when_no_ids(db_session_with_containers: Session) -> None:
|
||||
"""Ensure empty input returns an empty mapping without hitting the database."""
|
||||
assert FileService.get_upload_files_by_ids(str(uuid4()), []) == {}
|
||||
|
||||
|
||||
def test_get_upload_files_by_ids_returns_id_keyed_mapping(db_session_with_containers) -> None:
|
||||
def test_get_upload_files_by_ids_returns_id_keyed_mapping(db_session_with_containers: Session) -> None:
|
||||
"""Ensure batch lookup returns a dict keyed by stringified UploadFile ids."""
|
||||
tenant_id = str(uuid4())
|
||||
file1 = _create_upload_file(db_session_with_containers, tenant_id=tenant_id, key="k1", name="file1.txt")
|
||||
@@ -84,7 +85,7 @@ def test_get_upload_files_by_ids_returns_id_keyed_mapping(db_session_with_contai
|
||||
assert result[file2.id].id == file2.id
|
||||
|
||||
|
||||
def test_get_upload_files_by_ids_filters_by_tenant(db_session_with_containers) -> None:
|
||||
def test_get_upload_files_by_ids_filters_by_tenant(db_session_with_containers: Session) -> None:
|
||||
"""Ensure files from other tenants are not returned."""
|
||||
tenant_a = str(uuid4())
|
||||
tenant_b = str(uuid4())
|
||||
|
||||
+6
-3
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -89,7 +90,7 @@ class TestDeliveryTestRegistry:
|
||||
with pytest.raises(DeliveryTestUnsupportedError, match="Delivery method does not support test send."):
|
||||
registry.dispatch(context=context, method=method)
|
||||
|
||||
def test_default(self, flask_app_with_containers, db_session_with_containers: Session):
|
||||
def test_default(self, flask_app_with_containers: Flask, db_session_with_containers: Session):
|
||||
registry = DeliveryTestRegistry.default()
|
||||
assert len(registry._handlers) == 1
|
||||
assert isinstance(registry._handlers[0], EmailDeliveryTestHandler)
|
||||
@@ -261,7 +262,7 @@ class TestEmailDeliveryTestHandler:
|
||||
)
|
||||
assert handler._resolve_recipients(tenant_id="t1", method=method) == ["ext@example.com"]
|
||||
|
||||
def test_resolve_recipients_member(self, flask_app_with_containers, db_session_with_containers: Session):
|
||||
def test_resolve_recipients_member(self, flask_app_with_containers: Flask, db_session_with_containers: Session):
|
||||
tenant_id = str(uuid4())
|
||||
account = Account(name="Test User", email="member@example.com")
|
||||
db_session_with_containers.add(account)
|
||||
@@ -283,7 +284,9 @@ class TestEmailDeliveryTestHandler:
|
||||
)
|
||||
assert handler._resolve_recipients(tenant_id=tenant_id, method=method) == ["member@example.com"]
|
||||
|
||||
def test_resolve_recipients_whole_workspace(self, flask_app_with_containers, db_session_with_containers: Session):
|
||||
def test_resolve_recipients_whole_workspace(
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers: Session
|
||||
):
|
||||
tenant_id = str(uuid4())
|
||||
account1 = Account(name="User 1", email=f"u1-{uuid4()}@example.com")
|
||||
account2 = Account(name="User 2", email=f"u2-{uuid4()}@example.com")
|
||||
|
||||
+26
-6
@@ -4,6 +4,7 @@ from unittest.mock import Mock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -17,7 +18,7 @@ from services.entities.knowledge_entities.knowledge_entities import (
|
||||
from services.metadata_service import MetadataService
|
||||
|
||||
|
||||
def _create_dataset(db_session, *, tenant_id: str, built_in_field_enabled: bool = False) -> Dataset:
|
||||
def _create_dataset(db_session: Session, *, tenant_id: str, built_in_field_enabled: bool = False) -> Dataset:
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id,
|
||||
name=f"dataset-{uuid4()}",
|
||||
@@ -31,7 +32,9 @@ def _create_dataset(db_session, *, tenant_id: str, built_in_field_enabled: bool
|
||||
return dataset
|
||||
|
||||
|
||||
def _create_document(db_session, *, dataset_id: str, tenant_id: str, doc_metadata: dict | None = None) -> Document:
|
||||
def _create_document(
|
||||
db_session: Session, *, dataset_id: str, tenant_id: str, doc_metadata: dict | None = None
|
||||
) -> Document:
|
||||
document = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset_id,
|
||||
@@ -66,7 +69,11 @@ class TestMetadataPartialUpdate:
|
||||
yield account
|
||||
|
||||
def test_partial_update_merges_metadata(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session, tenant_id, mock_current_account
|
||||
self,
|
||||
flask_app_with_containers: Flask,
|
||||
db_session_with_containers: Session,
|
||||
tenant_id: str,
|
||||
mock_current_account,
|
||||
):
|
||||
dataset = _create_dataset(db_session_with_containers, tenant_id=tenant_id)
|
||||
document = _create_document(
|
||||
@@ -93,7 +100,11 @@ class TestMetadataPartialUpdate:
|
||||
assert updated_doc.doc_metadata["new_key"] == "new_value"
|
||||
|
||||
def test_full_update_replaces_metadata(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session, tenant_id, mock_current_account
|
||||
self,
|
||||
flask_app_with_containers: Flask,
|
||||
db_session_with_containers: Session,
|
||||
tenant_id: str,
|
||||
mock_current_account,
|
||||
):
|
||||
dataset = _create_dataset(db_session_with_containers, tenant_id=tenant_id)
|
||||
document = _create_document(
|
||||
@@ -120,7 +131,12 @@ class TestMetadataPartialUpdate:
|
||||
assert "existing_key" not in updated_doc.doc_metadata
|
||||
|
||||
def test_partial_update_skips_existing_binding(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session, tenant_id, user_id, mock_current_account
|
||||
self,
|
||||
flask_app_with_containers: Flask,
|
||||
db_session_with_containers: Session,
|
||||
tenant_id,
|
||||
user_id,
|
||||
mock_current_account,
|
||||
):
|
||||
dataset = _create_dataset(db_session_with_containers, tenant_id=tenant_id)
|
||||
document = _create_document(
|
||||
@@ -160,7 +176,11 @@ class TestMetadataPartialUpdate:
|
||||
assert len(bindings) == 1
|
||||
|
||||
def test_rollback_called_on_commit_failure(
|
||||
self, flask_app_with_containers, db_session_with_containers: Session, tenant_id, mock_current_account
|
||||
self,
|
||||
flask_app_with_containers: Flask,
|
||||
db_session_with_containers: Session,
|
||||
tenant_id: str,
|
||||
mock_current_account,
|
||||
):
|
||||
dataset = _create_dataset(db_session_with_containers, tenant_id=tenant_id)
|
||||
document = _create_document(
|
||||
|
||||
@@ -6,6 +6,7 @@ from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.console.app import app_import as app_import_module
|
||||
from services.app_dsl_service import ImportStatus
|
||||
@@ -48,7 +49,9 @@ class TestAppImportApi:
|
||||
def api(self):
|
||||
return app_import_module.AppImportApi()
|
||||
|
||||
def test_import_post_returns_failed_status_and_rolls_back(self, api, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_import_post_returns_failed_status_and_rolls_back(
|
||||
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
method = _unwrap(api.post)
|
||||
|
||||
_install_features(monkeypatch, enabled=False)
|
||||
@@ -68,7 +71,9 @@ class TestAppImportApi:
|
||||
assert status == 400
|
||||
assert response["status"] == ImportStatus.FAILED
|
||||
|
||||
def test_import_post_returns_pending_status_and_commits(self, api, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_import_post_returns_pending_status_and_commits(
|
||||
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
method = _unwrap(api.post)
|
||||
|
||||
_install_features(monkeypatch, enabled=False)
|
||||
@@ -88,7 +93,9 @@ class TestAppImportApi:
|
||||
assert status == 202
|
||||
assert response["status"] == ImportStatus.PENDING
|
||||
|
||||
def test_import_post_updates_webapp_auth_when_enabled(self, api, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_import_post_updates_webapp_auth_when_enabled(
|
||||
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
method = _unwrap(api.post)
|
||||
|
||||
_install_features(monkeypatch, enabled=True)
|
||||
@@ -118,7 +125,7 @@ class TestAppImportConfirmApi:
|
||||
return app_import_module.AppImportConfirmApi()
|
||||
|
||||
def test_import_confirm_returns_failed_status_and_rolls_back(
|
||||
self, api, app, monkeypatch: pytest.MonkeyPatch
|
||||
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
method = _unwrap(api.post)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.console.app import conversation_variables as conversation_variables_module
|
||||
@@ -20,7 +21,7 @@ def _unwrap(func):
|
||||
return func
|
||||
|
||||
|
||||
def test_get_conversation_variables_returns_paginated_response(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_get_conversation_variables_returns_paginated_response(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = conversation_variables_module.ConversationVariablesApi()
|
||||
method = _unwrap(api.get)
|
||||
|
||||
@@ -63,7 +64,9 @@ def test_get_conversation_variables_returns_paginated_response(app, monkeypatch:
|
||||
assert response["data"][0]["updated_at"] == int(updated_at.timestamp())
|
||||
|
||||
|
||||
def test_get_conversation_variables_normalizes_value_type_and_value(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_get_conversation_variables_normalizes_value_type_and_value(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
api = conversation_variables_module.ConversationVariablesApi()
|
||||
method = _unwrap(api.get)
|
||||
|
||||
|
||||
@@ -20,8 +20,11 @@ from models.workflow import WorkflowRun
|
||||
|
||||
|
||||
def _make_account() -> Account:
|
||||
account = Account(name="tester", email="tester@example.com")
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account = Account(
|
||||
name="tester",
|
||||
email="tester@example.com",
|
||||
status=AccountStatus.ACTIVE,
|
||||
)
|
||||
account.role = TenantAccountRole.OWNER
|
||||
account.id = "account-123" # type: ignore[assignment]
|
||||
account._current_tenant = SimpleNamespace(id="tenant-123") # type: ignore[attr-defined]
|
||||
|
||||
+12
-12
@@ -1,7 +1,7 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Response
|
||||
from flask import Flask, Response
|
||||
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import DraftWorkflowNotExist
|
||||
@@ -46,7 +46,7 @@ def restx_config(app):
|
||||
|
||||
|
||||
class TestRagPipelineVariableCollectionApi:
|
||||
def test_get_variables_success(self, app, fake_db, editor_user, restx_config):
|
||||
def test_get_variables_success(self, app: Flask, fake_db, editor_user, restx_config):
|
||||
api = RagPipelineVariableCollectionApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -80,7 +80,7 @@ class TestRagPipelineVariableCollectionApi:
|
||||
|
||||
assert result["items"] == []
|
||||
|
||||
def test_get_variables_workflow_not_exist(self, app, fake_db, editor_user):
|
||||
def test_get_variables_workflow_not_exist(self, app: Flask, fake_db, editor_user):
|
||||
api = RagPipelineVariableCollectionApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -101,7 +101,7 @@ class TestRagPipelineVariableCollectionApi:
|
||||
with pytest.raises(DraftWorkflowNotExist):
|
||||
method(api, pipeline)
|
||||
|
||||
def test_delete_variables_success(self, app, fake_db, editor_user):
|
||||
def test_delete_variables_success(self, app: Flask, fake_db, editor_user):
|
||||
api = RagPipelineVariableCollectionApi()
|
||||
method = unwrap(api.delete)
|
||||
|
||||
@@ -120,7 +120,7 @@ class TestRagPipelineVariableCollectionApi:
|
||||
|
||||
|
||||
class TestRagPipelineNodeVariableCollectionApi:
|
||||
def test_get_node_variables_success(self, app, fake_db, editor_user, restx_config):
|
||||
def test_get_node_variables_success(self, app: Flask, fake_db, editor_user, restx_config):
|
||||
api = RagPipelineNodeVariableCollectionApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -146,7 +146,7 @@ class TestRagPipelineNodeVariableCollectionApi:
|
||||
|
||||
assert result["items"] == []
|
||||
|
||||
def test_get_node_variables_invalid_node(self, app, editor_user):
|
||||
def test_get_node_variables_invalid_node(self, app: Flask, editor_user):
|
||||
api = RagPipelineNodeVariableCollectionApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -159,7 +159,7 @@ class TestRagPipelineNodeVariableCollectionApi:
|
||||
|
||||
|
||||
class TestRagPipelineVariableApi:
|
||||
def test_get_variable_not_found(self, app, fake_db, editor_user):
|
||||
def test_get_variable_not_found(self, app: Flask, fake_db, editor_user):
|
||||
api = RagPipelineVariableApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -178,7 +178,7 @@ class TestRagPipelineVariableApi:
|
||||
with pytest.raises(NotFoundError):
|
||||
method(api, MagicMock(), "v1")
|
||||
|
||||
def test_patch_variable_invalid_file_payload(self, app, fake_db, editor_user):
|
||||
def test_patch_variable_invalid_file_payload(self, app: Flask, fake_db, editor_user):
|
||||
api = RagPipelineVariableApi()
|
||||
method = unwrap(api.patch)
|
||||
|
||||
@@ -203,7 +203,7 @@ class TestRagPipelineVariableApi:
|
||||
with pytest.raises(InvalidArgumentError):
|
||||
method(api, pipeline, "v1")
|
||||
|
||||
def test_delete_variable_success(self, app, fake_db, editor_user):
|
||||
def test_delete_variable_success(self, app: Flask, fake_db, editor_user):
|
||||
api = RagPipelineVariableApi()
|
||||
method = unwrap(api.delete)
|
||||
|
||||
@@ -228,7 +228,7 @@ class TestRagPipelineVariableApi:
|
||||
|
||||
|
||||
class TestRagPipelineVariableResetApi:
|
||||
def test_reset_variable_success(self, app, fake_db, editor_user):
|
||||
def test_reset_variable_success(self, app: Flask, fake_db, editor_user):
|
||||
api = RagPipelineVariableResetApi()
|
||||
method = unwrap(api.put)
|
||||
|
||||
@@ -266,7 +266,7 @@ class TestRagPipelineVariableResetApi:
|
||||
|
||||
|
||||
class TestSystemAndEnvironmentVariablesApi:
|
||||
def test_system_variables_success(self, app, fake_db, editor_user, restx_config):
|
||||
def test_system_variables_success(self, app: Flask, fake_db, editor_user, restx_config):
|
||||
api = RagPipelineSystemVariableCollectionApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -292,7 +292,7 @@ class TestSystemAndEnvironmentVariablesApi:
|
||||
|
||||
assert result["items"] == []
|
||||
|
||||
def test_environment_variables_success(self, app, editor_user):
|
||||
def test_environment_variables_success(self, app: Flask, editor_user):
|
||||
api = RagPipelineEnvironmentVariableCollectionApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ def patch_permission():
|
||||
|
||||
|
||||
class TestGetProcessRuleApi:
|
||||
def test_get_default_success(self, app, patch_tenant):
|
||||
def test_get_default_success(self, app: Flask, patch_tenant):
|
||||
api = GetProcessRuleApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -104,7 +104,7 @@ class TestGetProcessRuleApi:
|
||||
|
||||
assert "rules" in response
|
||||
|
||||
def test_get_with_document_dataset_not_found(self, app, patch_tenant):
|
||||
def test_get_with_document_dataset_not_found(self, app: Flask, patch_tenant):
|
||||
api = GetProcessRuleApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -126,7 +126,7 @@ class TestGetProcessRuleApi:
|
||||
|
||||
|
||||
class TestDatasetDocumentListApi:
|
||||
def test_get_with_fetch_true_counts_segments(self, app, patch_tenant, patch_dataset, patch_permission):
|
||||
def test_get_with_fetch_true_counts_segments(self, app: Flask, patch_tenant, patch_dataset, patch_permission):
|
||||
api = DatasetDocumentListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -158,7 +158,9 @@ class TestDatasetDocumentListApi:
|
||||
|
||||
assert resp["data"]
|
||||
|
||||
def test_get_with_search_status_and_created_at_sort(self, app, patch_tenant, patch_dataset, patch_permission):
|
||||
def test_get_with_search_status_and_created_at_sort(
|
||||
self, app: Flask, patch_tenant, patch_dataset, patch_permission
|
||||
):
|
||||
api = DatasetDocumentListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -187,7 +189,7 @@ class TestDatasetDocumentListApi:
|
||||
|
||||
assert resp["total"] == 1
|
||||
|
||||
def test_get_success(self, app, patch_tenant, patch_dataset, patch_permission):
|
||||
def test_get_success(self, app: Flask, patch_tenant, patch_dataset, patch_permission):
|
||||
api = DatasetDocumentListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -212,7 +214,7 @@ class TestDatasetDocumentListApi:
|
||||
|
||||
assert response["total"] == 1
|
||||
|
||||
def test_post_success(self, app, patch_tenant, patch_dataset, patch_permission):
|
||||
def test_post_success(self, app: Flask, patch_tenant, patch_dataset, patch_permission):
|
||||
api = DatasetDocumentListApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -261,7 +263,7 @@ class TestDatasetDocumentListApi:
|
||||
with pytest.raises(Forbidden):
|
||||
method(api, "ds-1")
|
||||
|
||||
def test_get_with_fetch_true_and_invalid_fetch(self, app, patch_tenant, patch_dataset, patch_permission):
|
||||
def test_get_with_fetch_true_and_invalid_fetch(self, app: Flask, patch_tenant, patch_dataset, patch_permission):
|
||||
api = DatasetDocumentListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -286,7 +288,7 @@ class TestDatasetDocumentListApi:
|
||||
|
||||
assert response["total"] == 1
|
||||
|
||||
def test_get_sort_hit_count(self, app, patch_tenant, patch_dataset, patch_permission):
|
||||
def test_get_sort_hit_count(self, app: Flask, patch_tenant, patch_dataset, patch_permission):
|
||||
api = DatasetDocumentListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -309,7 +311,7 @@ class TestDatasetDocumentListApi:
|
||||
|
||||
|
||||
class TestDocumentApi:
|
||||
def test_get_success(self, app, patch_tenant):
|
||||
def test_get_success(self, app: Flask, patch_tenant):
|
||||
api = DocumentApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -327,7 +329,7 @@ class TestDocumentApi:
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_get_invalid_metadata(self, app, patch_tenant):
|
||||
def test_get_invalid_metadata(self, app: Flask, patch_tenant):
|
||||
api = DocumentApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -335,7 +337,7 @@ class TestDocumentApi:
|
||||
with pytest.raises(InvalidMetadataError):
|
||||
method(api, "ds-1", "doc-1")
|
||||
|
||||
def test_delete_success(self, app, patch_tenant, patch_dataset):
|
||||
def test_delete_success(self, app: Flask, patch_tenant, patch_dataset):
|
||||
api = DocumentApi()
|
||||
method = unwrap(api.delete)
|
||||
|
||||
@@ -355,7 +357,7 @@ class TestDocumentApi:
|
||||
|
||||
assert status == 204
|
||||
|
||||
def test_delete_indexing_error(self, app, patch_tenant, patch_dataset):
|
||||
def test_delete_indexing_error(self, app: Flask, patch_tenant, patch_dataset):
|
||||
api = DocumentApi()
|
||||
method = unwrap(api.delete)
|
||||
|
||||
@@ -376,7 +378,7 @@ class TestDocumentApi:
|
||||
|
||||
|
||||
class TestDocumentDownloadApi:
|
||||
def test_download_success(self, app, patch_tenant):
|
||||
def test_download_success(self, app: Flask, patch_tenant):
|
||||
api = DocumentDownloadApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -413,7 +415,7 @@ class TestDocumentProcessingApi:
|
||||
with pytest.raises(Forbidden):
|
||||
method(api, "ds-1", "doc-1", "pause")
|
||||
|
||||
def test_resume_from_error_state(self, app, patch_tenant):
|
||||
def test_resume_from_error_state(self, app: Flask, patch_tenant):
|
||||
api = DocumentProcessingApi()
|
||||
method = unwrap(api.patch)
|
||||
|
||||
@@ -431,7 +433,7 @@ class TestDocumentProcessingApi:
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_resume_success(self, app, patch_tenant):
|
||||
def test_resume_success(self, app: Flask, patch_tenant):
|
||||
api = DocumentProcessingApi()
|
||||
method = unwrap(api.patch)
|
||||
|
||||
@@ -449,7 +451,7 @@ class TestDocumentProcessingApi:
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_pause_success(self, app, patch_tenant):
|
||||
def test_pause_success(self, app: Flask, patch_tenant):
|
||||
api = DocumentProcessingApi()
|
||||
method = unwrap(api.patch)
|
||||
|
||||
@@ -467,7 +469,7 @@ class TestDocumentProcessingApi:
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_pause_invalid(self, app, patch_tenant):
|
||||
def test_pause_invalid(self, app: Flask, patch_tenant):
|
||||
api = DocumentProcessingApi()
|
||||
method = unwrap(api.patch)
|
||||
|
||||
@@ -479,7 +481,7 @@ class TestDocumentProcessingApi:
|
||||
|
||||
|
||||
class TestDocumentMetadataApi:
|
||||
def test_put_metadata_schema_filtering(self, app, patch_tenant):
|
||||
def test_put_metadata_schema_filtering(self, app: Flask, patch_tenant):
|
||||
api = DocumentMetadataApi()
|
||||
method = unwrap(api.put)
|
||||
|
||||
@@ -508,7 +510,7 @@ class TestDocumentMetadataApi:
|
||||
|
||||
assert doc.doc_metadata == {"amount": 10}
|
||||
|
||||
def test_put_success(self, app, patch_tenant):
|
||||
def test_put_success(self, app: Flask, patch_tenant):
|
||||
api = DocumentMetadataApi()
|
||||
method = unwrap(api.put)
|
||||
|
||||
@@ -532,7 +534,7 @@ class TestDocumentMetadataApi:
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_put_invalid_payload(self, app, patch_tenant):
|
||||
def test_put_invalid_payload(self, app: Flask, patch_tenant):
|
||||
api = DocumentMetadataApi()
|
||||
method = unwrap(api.put)
|
||||
|
||||
@@ -540,7 +542,7 @@ class TestDocumentMetadataApi:
|
||||
with pytest.raises(ValueError):
|
||||
method(api, "ds-1", "doc-1")
|
||||
|
||||
def test_put_invalid_doc_type(self, app, patch_tenant):
|
||||
def test_put_invalid_doc_type(self, app: Flask, patch_tenant):
|
||||
api = DocumentMetadataApi()
|
||||
method = unwrap(api.put)
|
||||
|
||||
@@ -559,7 +561,7 @@ class TestDocumentMetadataApi:
|
||||
|
||||
|
||||
class TestDocumentStatusApi:
|
||||
def test_patch_success(self, app, patch_tenant, patch_dataset):
|
||||
def test_patch_success(self, app: Flask, patch_tenant, patch_dataset):
|
||||
api = DocumentStatusApi()
|
||||
method = unwrap(api.patch)
|
||||
|
||||
@@ -582,7 +584,7 @@ class TestDocumentStatusApi:
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_patch_invalid_action(self, app, patch_tenant, patch_dataset):
|
||||
def test_patch_invalid_action(self, app: Flask, patch_tenant, patch_dataset):
|
||||
api = DocumentStatusApi()
|
||||
method = unwrap(api.patch)
|
||||
|
||||
@@ -606,7 +608,7 @@ class TestDocumentStatusApi:
|
||||
|
||||
|
||||
class TestDocumentRetryApi:
|
||||
def test_retry_archived_document_skipped(self, app, patch_tenant, patch_dataset):
|
||||
def test_retry_archived_document_skipped(self, app: Flask, patch_tenant, patch_dataset):
|
||||
api = DocumentRetryApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -634,7 +636,7 @@ class TestDocumentRetryApi:
|
||||
assert status == 204
|
||||
retry_mock.assert_called_once_with("ds-1", [])
|
||||
|
||||
def test_retry_success(self, app, patch_tenant, patch_dataset):
|
||||
def test_retry_success(self, app: Flask, patch_tenant, patch_dataset):
|
||||
api = DocumentRetryApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -663,7 +665,7 @@ class TestDocumentRetryApi:
|
||||
assert status == 204
|
||||
retry_mock.assert_called_once_with("ds-1", [document])
|
||||
|
||||
def test_retry_skips_completed_document(self, app, patch_tenant, patch_dataset):
|
||||
def test_retry_skips_completed_document(self, app: Flask, patch_tenant, patch_dataset):
|
||||
api = DocumentRetryApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -690,7 +692,7 @@ class TestDocumentRetryApi:
|
||||
|
||||
|
||||
class TestDocumentPipelineExecutionLogApi:
|
||||
def test_get_log_success(self, app, patch_tenant, patch_dataset):
|
||||
def test_get_log_success(self, app: Flask, patch_tenant, patch_dataset):
|
||||
api = DocumentPipelineExecutionLogApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -718,7 +720,7 @@ class TestDocumentPipelineExecutionLogApi:
|
||||
|
||||
|
||||
class TestDocumentGenerateSummaryApi:
|
||||
def test_generate_summary_missing_documents(self, app, patch_tenant, patch_permission):
|
||||
def test_generate_summary_missing_documents(self, app: Flask, patch_tenant, patch_permission):
|
||||
api = DocumentGenerateSummaryApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -744,7 +746,7 @@ class TestDocumentGenerateSummaryApi:
|
||||
with pytest.raises(NotFound):
|
||||
method(api, "ds-1")
|
||||
|
||||
def test_generate_not_enabled(self, app, patch_tenant, patch_permission):
|
||||
def test_generate_not_enabled(self, app: Flask, patch_tenant, patch_permission):
|
||||
api = DocumentGenerateSummaryApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -763,7 +765,7 @@ class TestDocumentGenerateSummaryApi:
|
||||
with pytest.raises(ValueError):
|
||||
method(api, "ds-1")
|
||||
|
||||
def test_generate_summary_success_with_qa_skip(self, app, patch_tenant, patch_permission):
|
||||
def test_generate_summary_success_with_qa_skip(self, app: Flask, patch_tenant, patch_permission):
|
||||
api = DocumentGenerateSummaryApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -799,7 +801,7 @@ class TestDocumentGenerateSummaryApi:
|
||||
|
||||
|
||||
class TestDocumentSummaryStatusApi:
|
||||
def test_get_success(self, app, patch_tenant, patch_permission):
|
||||
def test_get_success(self, app: Flask, patch_tenant, patch_permission):
|
||||
api = DocumentSummaryStatusApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -820,7 +822,7 @@ class TestDocumentSummaryStatusApi:
|
||||
|
||||
|
||||
class TestDocumentIndexingEstimateApi:
|
||||
def test_indexing_estimate_file_not_found(self, app, patch_tenant):
|
||||
def test_indexing_estimate_file_not_found(self, app: Flask, patch_tenant):
|
||||
api = DocumentIndexingEstimateApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -844,7 +846,7 @@ class TestDocumentIndexingEstimateApi:
|
||||
with pytest.raises(NotFound):
|
||||
method(api, "ds-1", "doc-1")
|
||||
|
||||
def test_indexing_estimate_generic_exception(self, app, patch_tenant):
|
||||
def test_indexing_estimate_generic_exception(self, app: Flask, patch_tenant):
|
||||
api = DocumentIndexingEstimateApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -881,7 +883,7 @@ class TestDocumentIndexingEstimateApi:
|
||||
with pytest.raises(IndexingEstimateError):
|
||||
method(api, "ds-1", "doc-1")
|
||||
|
||||
def test_get_finished(self, app, patch_tenant):
|
||||
def test_get_finished(self, app: Flask, patch_tenant):
|
||||
api = DocumentIndexingEstimateApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -893,7 +895,7 @@ class TestDocumentIndexingEstimateApi:
|
||||
|
||||
|
||||
class TestDocumentBatchDownloadZipApi:
|
||||
def test_post_no_documents(self, app, patch_tenant):
|
||||
def test_post_no_documents(self, app: Flask, patch_tenant):
|
||||
api = DocumentBatchDownloadZipApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -905,7 +907,7 @@ class TestDocumentBatchDownloadZipApi:
|
||||
|
||||
|
||||
class TestDatasetDocumentListApiDelete:
|
||||
def test_delete_success(self, app, patch_tenant, patch_dataset):
|
||||
def test_delete_success(self, app: Flask, patch_tenant, patch_dataset):
|
||||
"""Test successful deletion of documents"""
|
||||
api = DatasetDocumentListApi()
|
||||
method = unwrap(api.delete)
|
||||
@@ -925,7 +927,7 @@ class TestDatasetDocumentListApiDelete:
|
||||
|
||||
assert status == 204
|
||||
|
||||
def test_delete_indexing_error(self, app, patch_tenant, patch_dataset):
|
||||
def test_delete_indexing_error(self, app: Flask, patch_tenant, patch_dataset):
|
||||
"""Test deletion with indexing error"""
|
||||
api = DatasetDocumentListApi()
|
||||
method = unwrap(api.delete)
|
||||
@@ -944,7 +946,7 @@ class TestDatasetDocumentListApiDelete:
|
||||
with pytest.raises(DocumentIndexingError):
|
||||
method(api, "ds-1")
|
||||
|
||||
def test_delete_dataset_not_found(self, app, patch_tenant):
|
||||
def test_delete_dataset_not_found(self, app: Flask, patch_tenant):
|
||||
"""Test deletion when dataset not found"""
|
||||
api = DatasetDocumentListApi()
|
||||
method = unwrap(api.delete)
|
||||
@@ -961,7 +963,7 @@ class TestDatasetDocumentListApiDelete:
|
||||
|
||||
|
||||
class TestDocumentBatchIndexingEstimateApi:
|
||||
def test_batch_indexing_estimate_website(self, app, patch_tenant):
|
||||
def test_batch_indexing_estimate_website(self, app: Flask, patch_tenant):
|
||||
api = DocumentBatchIndexingEstimateApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -990,7 +992,7 @@ class TestDocumentBatchIndexingEstimateApi:
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_batch_indexing_estimate_notion(self, app, patch_tenant):
|
||||
def test_batch_indexing_estimate_notion(self, app: Flask, patch_tenant):
|
||||
api = DocumentBatchIndexingEstimateApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -1018,7 +1020,7 @@ class TestDocumentBatchIndexingEstimateApi:
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_batch_estimate_unsupported_datasource(self, app, patch_tenant):
|
||||
def test_batch_estimate_unsupported_datasource(self, app: Flask, patch_tenant):
|
||||
api = DocumentBatchIndexingEstimateApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -1033,7 +1035,7 @@ class TestDocumentBatchIndexingEstimateApi:
|
||||
with pytest.raises(ValueError):
|
||||
method(api, "ds-1", "batch-1")
|
||||
|
||||
def test_get_batch_estimate_invalid_batch(self, app, patch_tenant):
|
||||
def test_get_batch_estimate_invalid_batch(self, app: Flask, patch_tenant):
|
||||
"""Test batch estimation with invalid batch"""
|
||||
api = DocumentBatchIndexingEstimateApi()
|
||||
method = unwrap(api.get)
|
||||
@@ -1044,7 +1046,7 @@ class TestDocumentBatchIndexingEstimateApi:
|
||||
|
||||
|
||||
class TestDocumentBatchIndexingStatusApi:
|
||||
def test_get_batch_status_invalid_batch(self, app, patch_tenant):
|
||||
def test_get_batch_status_invalid_batch(self, app: Flask, patch_tenant):
|
||||
"""Test batch status with invalid batch"""
|
||||
api = DocumentBatchIndexingStatusApi()
|
||||
method = unwrap(api.get)
|
||||
@@ -1055,7 +1057,7 @@ class TestDocumentBatchIndexingStatusApi:
|
||||
|
||||
|
||||
class TestDocumentIndexingStatusApi:
|
||||
def test_get_status_document_not_found(self, app, patch_tenant):
|
||||
def test_get_status_document_not_found(self, app: Flask, patch_tenant):
|
||||
"""Test getting status for non-existent document"""
|
||||
api = DocumentIndexingStatusApi()
|
||||
method = unwrap(api.get)
|
||||
@@ -1066,7 +1068,7 @@ class TestDocumentIndexingStatusApi:
|
||||
|
||||
|
||||
class TestDocumentApiMetadata:
|
||||
def test_get_with_only_option(self, app, patch_tenant):
|
||||
def test_get_with_only_option(self, app: Flask, patch_tenant):
|
||||
"""Test get with 'only' metadata option"""
|
||||
api = DocumentApi()
|
||||
method = unwrap(api.get)
|
||||
@@ -1085,7 +1087,7 @@ class TestDocumentApiMetadata:
|
||||
|
||||
assert status == 200
|
||||
|
||||
def test_get_with_without_option(self, app, patch_tenant):
|
||||
def test_get_with_without_option(self, app: Flask, patch_tenant):
|
||||
"""Test get with 'without' metadata option"""
|
||||
api = DocumentApi()
|
||||
method = unwrap(api.get)
|
||||
@@ -1106,7 +1108,7 @@ class TestDocumentApiMetadata:
|
||||
|
||||
|
||||
class TestDocumentGenerateSummaryApiSuccess:
|
||||
def test_generate_not_enabled_high_quality(self, app, patch_tenant, patch_permission):
|
||||
def test_generate_not_enabled_high_quality(self, app: Flask, patch_tenant, patch_permission):
|
||||
"""Test summary generation on non-high-quality dataset"""
|
||||
api = DocumentGenerateSummaryApi()
|
||||
method = unwrap(api.post)
|
||||
@@ -1128,7 +1130,7 @@ class TestDocumentGenerateSummaryApiSuccess:
|
||||
|
||||
|
||||
class TestDocumentProcessingApiResume:
|
||||
def test_resume_invalid_status(self, app, patch_tenant):
|
||||
def test_resume_invalid_status(self, app: Flask, patch_tenant):
|
||||
"""Test resume on non-paused document"""
|
||||
api = DocumentProcessingApi()
|
||||
method = unwrap(api.patch)
|
||||
@@ -1141,7 +1143,7 @@ class TestDocumentProcessingApiResume:
|
||||
|
||||
|
||||
class TestDocumentPermissionCases:
|
||||
def test_document_batch_get_permission_denied(self, app, patch_tenant):
|
||||
def test_document_batch_get_permission_denied(self, app: Flask, patch_tenant):
|
||||
api = DocumentBatchIndexingEstimateApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -1159,7 +1161,7 @@ class TestDocumentPermissionCases:
|
||||
with pytest.raises(Forbidden):
|
||||
method(api, "ds-1", "batch-1")
|
||||
|
||||
def test_document_batch_get_documents_not_found(self, app, patch_tenant):
|
||||
def test_document_batch_get_documents_not_found(self, app: Flask, patch_tenant):
|
||||
api = DocumentBatchIndexingEstimateApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -1218,7 +1220,7 @@ class TestDocumentPermissionCases:
|
||||
with pytest.raises(Forbidden):
|
||||
method(api, "ds-1", "doc-1")
|
||||
|
||||
def test_process_rule_get_by_document_success(self, app, patch_tenant):
|
||||
def test_process_rule_get_by_document_success(self, app: Flask, patch_tenant):
|
||||
api = GetProcessRuleApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -1284,7 +1286,7 @@ class TestDocumentPermissionCases:
|
||||
|
||||
|
||||
class TestDocumentListAdvancedCases:
|
||||
def test_document_list_with_multiple_sort_options(self, app, patch_tenant, patch_dataset, patch_permission):
|
||||
def test_document_list_with_multiple_sort_options(self, app: Flask, patch_tenant, patch_dataset, patch_permission):
|
||||
"""Test document list with different sort options"""
|
||||
api = DatasetDocumentListApi()
|
||||
method = unwrap(api.get)
|
||||
@@ -1310,7 +1312,7 @@ class TestDocumentListAdvancedCases:
|
||||
|
||||
assert response["total"] == 1
|
||||
|
||||
def test_document_metadata_with_schema_validation(self, app, patch_tenant):
|
||||
def test_document_metadata_with_schema_validation(self, app: Flask, patch_tenant):
|
||||
"""Test document metadata update with schema validation"""
|
||||
api = DocumentMetadataApi()
|
||||
method = unwrap(api.put)
|
||||
@@ -1342,7 +1344,7 @@ class TestDocumentListAdvancedCases:
|
||||
|
||||
|
||||
class TestDocumentIndexingEdgeCases:
|
||||
def test_document_indexing_with_extraction_setting(self, app, patch_tenant):
|
||||
def test_document_indexing_with_extraction_setting(self, app: Flask, patch_tenant):
|
||||
api = DocumentIndexingEstimateApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
|
||||
@@ -292,7 +292,7 @@ class TestBedrockRetrievalApi:
|
||||
|
||||
|
||||
class TestExternalApiTemplateListApiAdvanced:
|
||||
def test_post_duplicate_name_error(self, app, mock_auth, current_user):
|
||||
def test_post_duplicate_name_error(self, app: Flask, mock_auth, current_user):
|
||||
api = ExternalApiTemplateListApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -310,7 +310,7 @@ class TestExternalApiTemplateListApiAdvanced:
|
||||
with pytest.raises(DatasetNameDuplicateError):
|
||||
method(api)
|
||||
|
||||
def test_get_with_pagination(self, app, mock_auth, current_user):
|
||||
def test_get_with_pagination(self, app: Flask, mock_auth, current_user):
|
||||
api = ExternalApiTemplateListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -331,7 +331,7 @@ class TestExternalApiTemplateListApiAdvanced:
|
||||
|
||||
|
||||
class TestExternalDatasetCreateApiAdvanced:
|
||||
def test_create_forbidden(self, app, mock_auth, current_user):
|
||||
def test_create_forbidden(self, app: Flask, mock_auth, current_user):
|
||||
"""Test creating external dataset without permission"""
|
||||
api = ExternalDatasetCreateApi()
|
||||
method = unwrap(api.post)
|
||||
@@ -351,7 +351,7 @@ class TestExternalDatasetCreateApiAdvanced:
|
||||
|
||||
|
||||
class TestExternalKnowledgeHitTestingApiAdvanced:
|
||||
def test_hit_testing_dataset_not_found(self, app, mock_auth, current_user):
|
||||
def test_hit_testing_dataset_not_found(self, app: Flask, mock_auth, current_user):
|
||||
"""Test hit testing on non-existent dataset"""
|
||||
api = ExternalKnowledgeHitTestingApi()
|
||||
method = unwrap(api.post)
|
||||
@@ -372,7 +372,7 @@ class TestExternalKnowledgeHitTestingApiAdvanced:
|
||||
with pytest.raises(NotFound):
|
||||
method(api, "ds-1")
|
||||
|
||||
def test_hit_testing_with_custom_retrieval_model(self, app, mock_auth, current_user):
|
||||
def test_hit_testing_with_custom_retrieval_model(self, app: Flask, mock_auth, current_user):
|
||||
api = ExternalKnowledgeHitTestingApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -402,7 +402,7 @@ class TestExternalKnowledgeHitTestingApiAdvanced:
|
||||
|
||||
|
||||
class TestBedrockRetrievalApiAdvanced:
|
||||
def test_bedrock_retrieval_with_invalid_setting(self, app, mock_auth, current_user):
|
||||
def test_bedrock_retrieval_with_invalid_setting(self, app: Flask, mock_auth, current_user):
|
||||
api = BedrockRetrievalApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ def bypass_decorators(mocker: MockerFixture):
|
||||
|
||||
|
||||
class TestDatasetMetadataCreateApi:
|
||||
def test_create_metadata_success(self, app, current_user, dataset, dataset_id):
|
||||
def test_create_metadata_success(self, app: Flask, current_user, dataset, dataset_id):
|
||||
api = DatasetMetadataCreateApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -125,7 +125,7 @@ class TestDatasetMetadataCreateApi:
|
||||
assert status == 201
|
||||
assert result["name"] == "author"
|
||||
|
||||
def test_create_metadata_dataset_not_found(self, app, current_user, dataset_id):
|
||||
def test_create_metadata_dataset_not_found(self, app: Flask, current_user, dataset_id):
|
||||
api = DatasetMetadataCreateApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -162,7 +162,7 @@ class TestDatasetMetadataCreateApi:
|
||||
|
||||
|
||||
class TestDatasetMetadataGetApi:
|
||||
def test_get_metadata_success(self, app, dataset, dataset_id):
|
||||
def test_get_metadata_success(self, app: Flask, dataset, dataset_id):
|
||||
api = DatasetMetadataCreateApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -184,7 +184,7 @@ class TestDatasetMetadataGetApi:
|
||||
assert status == 200
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_get_metadata_dataset_not_found(self, app, dataset_id):
|
||||
def test_get_metadata_dataset_not_found(self, app: Flask, dataset_id):
|
||||
api = DatasetMetadataCreateApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -201,7 +201,7 @@ class TestDatasetMetadataGetApi:
|
||||
|
||||
|
||||
class TestDatasetMetadataApi:
|
||||
def test_update_metadata_success(self, app, current_user, dataset, dataset_id, metadata_id):
|
||||
def test_update_metadata_success(self, app: Flask, current_user, dataset, dataset_id, metadata_id):
|
||||
api = DatasetMetadataApi()
|
||||
method = unwrap(api.patch)
|
||||
|
||||
@@ -239,7 +239,7 @@ class TestDatasetMetadataApi:
|
||||
assert status == 200
|
||||
assert result["name"] == "updated-name"
|
||||
|
||||
def test_delete_metadata_success(self, app, current_user, dataset, dataset_id, metadata_id):
|
||||
def test_delete_metadata_success(self, app: Flask, current_user, dataset, dataset_id, metadata_id):
|
||||
api = DatasetMetadataApi()
|
||||
method = unwrap(api.delete)
|
||||
|
||||
@@ -289,7 +289,7 @@ class TestDatasetMetadataBuiltInFieldApi:
|
||||
|
||||
|
||||
class TestDatasetMetadataBuiltInFieldActionApi:
|
||||
def test_enable_built_in_field(self, app, current_user, dataset, dataset_id):
|
||||
def test_enable_built_in_field(self, app: Flask, current_user, dataset, dataset_id):
|
||||
api = DatasetMetadataBuiltInFieldActionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -320,7 +320,7 @@ class TestDatasetMetadataBuiltInFieldActionApi:
|
||||
|
||||
|
||||
class TestDocumentMetadataEditApi:
|
||||
def test_update_document_metadata_success(self, app, current_user, dataset, dataset_id):
|
||||
def test_update_document_metadata_success(self, app: Flask, current_user, dataset, dataset_id):
|
||||
api = DocumentMetadataEditApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ def bypass_auth_and_setup(mocker: MockerFixture):
|
||||
|
||||
|
||||
class TestWebsiteCrawlApi:
|
||||
def test_crawl_success(self, app, mocker: MockerFixture):
|
||||
def test_crawl_success(self, app: Flask, mocker: MockerFixture):
|
||||
api = WebsiteCrawlApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -86,7 +86,7 @@ class TestWebsiteCrawlApi:
|
||||
assert status == 200
|
||||
assert result["job_id"] == "job-1"
|
||||
|
||||
def test_crawl_invalid_payload(self, app, mocker: MockerFixture):
|
||||
def test_crawl_invalid_payload(self, app: Flask, mocker: MockerFixture):
|
||||
api = WebsiteCrawlApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -114,7 +114,7 @@ class TestWebsiteCrawlApi:
|
||||
with pytest.raises(WebsiteCrawlError, match="invalid payload"):
|
||||
method(api)
|
||||
|
||||
def test_crawl_service_error(self, app, mocker: MockerFixture):
|
||||
def test_crawl_service_error(self, app: Flask, mocker: MockerFixture):
|
||||
api = WebsiteCrawlApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -151,7 +151,7 @@ class TestWebsiteCrawlApi:
|
||||
|
||||
|
||||
class TestWebsiteCrawlStatusApi:
|
||||
def test_get_status_success(self, app, mocker: MockerFixture):
|
||||
def test_get_status_success(self, app: Flask, mocker: MockerFixture):
|
||||
api = WebsiteCrawlStatusApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -182,7 +182,7 @@ class TestWebsiteCrawlStatusApi:
|
||||
assert status == 200
|
||||
assert result["status"] == "completed"
|
||||
|
||||
def test_get_status_invalid_provider(self, app, mocker: MockerFixture):
|
||||
def test_get_status_invalid_provider(self, app: Flask, mocker: MockerFixture):
|
||||
api = WebsiteCrawlStatusApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -204,7 +204,7 @@ class TestWebsiteCrawlStatusApi:
|
||||
with pytest.raises(WebsiteCrawlError, match="invalid provider"):
|
||||
method(api, job_id)
|
||||
|
||||
def test_get_status_service_error(self, app, mocker: MockerFixture):
|
||||
def test_get_status_service_error(self, app: Flask, mocker: MockerFixture):
|
||||
api = WebsiteCrawlStatusApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from io import BytesIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
import controllers.console.explore.audio as audio_module
|
||||
@@ -52,7 +53,7 @@ class TestChatAudioApi:
|
||||
self.api = audio_module.ChatAudioApi()
|
||||
self.method = unwrap(self.api.post)
|
||||
|
||||
def test_post_success(self, app, installed_app, audio_file):
|
||||
def test_post_success(self, app: Flask, installed_app, audio_file):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -69,7 +70,7 @@ class TestChatAudioApi:
|
||||
|
||||
assert resp == {"text": "ok"}
|
||||
|
||||
def test_app_unavailable(self, app, installed_app, audio_file):
|
||||
def test_app_unavailable(self, app: Flask, installed_app, audio_file):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -85,7 +86,7 @@ class TestChatAudioApi:
|
||||
with pytest.raises(AppUnavailableError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_no_audio_uploaded(self, app, installed_app, audio_file):
|
||||
def test_no_audio_uploaded(self, app: Flask, installed_app, audio_file):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -101,7 +102,7 @@ class TestChatAudioApi:
|
||||
with pytest.raises(NoAudioUploadedError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_audio_too_large(self, app, installed_app, audio_file):
|
||||
def test_audio_too_large(self, app: Flask, installed_app, audio_file):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -117,7 +118,7 @@ class TestChatAudioApi:
|
||||
with pytest.raises(AudioTooLargeError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_provider_quota_exceeded(self, app, installed_app, audio_file):
|
||||
def test_provider_quota_exceeded(self, app: Flask, installed_app, audio_file):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -133,7 +134,7 @@ class TestChatAudioApi:
|
||||
with pytest.raises(ProviderQuotaExceededError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_unknown_exception(self, app, installed_app, audio_file):
|
||||
def test_unknown_exception(self, app: Flask, installed_app, audio_file):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -149,7 +150,7 @@ class TestChatAudioApi:
|
||||
with pytest.raises(InternalServerError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_unsupported_audio_type(self, app, installed_app, audio_file):
|
||||
def test_unsupported_audio_type(self, app: Flask, installed_app, audio_file):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -165,7 +166,7 @@ class TestChatAudioApi:
|
||||
with pytest.raises(audio_module.UnsupportedAudioTypeError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_provider_not_support_speech_to_text(self, app, installed_app, audio_file):
|
||||
def test_provider_not_support_speech_to_text(self, app: Flask, installed_app, audio_file):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -181,7 +182,7 @@ class TestChatAudioApi:
|
||||
with pytest.raises(audio_module.ProviderNotSupportSpeechToTextError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_provider_not_initialized(self, app, installed_app, audio_file):
|
||||
def test_provider_not_initialized(self, app: Flask, installed_app, audio_file):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -197,7 +198,7 @@ class TestChatAudioApi:
|
||||
with pytest.raises(ProviderNotInitializeError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_model_currently_not_supported(self, app, installed_app, audio_file):
|
||||
def test_model_currently_not_supported(self, app: Flask, installed_app, audio_file):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -213,7 +214,7 @@ class TestChatAudioApi:
|
||||
with pytest.raises(ProviderModelCurrentlyNotSupportError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_invoke_error_asr(self, app, installed_app, audio_file):
|
||||
def test_invoke_error_asr(self, app: Flask, installed_app, audio_file):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -235,7 +236,7 @@ class TestChatTextApi:
|
||||
self.api = audio_module.ChatTextApi()
|
||||
self.method = unwrap(self.api.post)
|
||||
|
||||
def test_post_success(self, app, installed_app):
|
||||
def test_post_success(self, app: Flask, installed_app):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -251,7 +252,7 @@ class TestChatTextApi:
|
||||
|
||||
assert resp == {"audio": "ok"}
|
||||
|
||||
def test_provider_not_initialized(self, app, installed_app):
|
||||
def test_provider_not_initialized(self, app: Flask, installed_app):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -266,7 +267,7 @@ class TestChatTextApi:
|
||||
with pytest.raises(ProviderNotInitializeError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_model_not_supported(self, app, installed_app):
|
||||
def test_model_not_supported(self, app: Flask, installed_app):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -281,7 +282,7 @@ class TestChatTextApi:
|
||||
with pytest.raises(ProviderModelCurrentlyNotSupportError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_invoke_error(self, app, installed_app):
|
||||
def test_invoke_error(self, app: Flask, installed_app):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -296,7 +297,7 @@ class TestChatTextApi:
|
||||
with pytest.raises(CompletionRequestError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_unknown_exception(self, app, installed_app):
|
||||
def test_unknown_exception(self, app: Flask, installed_app):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -311,7 +312,7 @@ class TestChatTextApi:
|
||||
with pytest.raises(InternalServerError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_app_unavailable_tts(self, app, installed_app):
|
||||
def test_app_unavailable_tts(self, app: Flask, installed_app):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -326,7 +327,7 @@ class TestChatTextApi:
|
||||
with pytest.raises(AppUnavailableError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_no_audio_uploaded_tts(self, app, installed_app):
|
||||
def test_no_audio_uploaded_tts(self, app: Flask, installed_app):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -341,7 +342,7 @@ class TestChatTextApi:
|
||||
with pytest.raises(NoAudioUploadedError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_audio_too_large_tts(self, app, installed_app):
|
||||
def test_audio_too_large_tts(self, app: Flask, installed_app):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -356,7 +357,7 @@ class TestChatTextApi:
|
||||
with pytest.raises(AudioTooLargeError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_unsupported_audio_type_tts(self, app, installed_app):
|
||||
def test_unsupported_audio_type_tts(self, app: Flask, installed_app):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -371,7 +372,7 @@ class TestChatTextApi:
|
||||
with pytest.raises(audio_module.UnsupportedAudioTypeError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_provider_not_support_speech_to_text_tts(self, app, installed_app):
|
||||
def test_provider_not_support_speech_to_text_tts(self, app: Flask, installed_app):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
@@ -386,7 +387,7 @@ class TestChatTextApi:
|
||||
with pytest.raises(audio_module.ProviderNotSupportSpeechToTextError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_quota_exceeded_tts(self, app, installed_app):
|
||||
def test_quota_exceeded_tts(self, app: Flask, installed_app):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
import controllers.console.explore.completion as completion_module
|
||||
@@ -51,7 +52,7 @@ def payload_patch(payload_data):
|
||||
|
||||
|
||||
class TestCompletionApi:
|
||||
def test_post_success(self, app, completion_app, user, payload_patch):
|
||||
def test_post_success(self, app: Flask, completion_app, user, payload_patch):
|
||||
api = completion_module.CompletionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -83,7 +84,7 @@ class TestCompletionApi:
|
||||
with pytest.raises(NotCompletionAppError):
|
||||
method(installed_app)
|
||||
|
||||
def test_conversation_completed(self, app, completion_app, user, payload_patch):
|
||||
def test_conversation_completed(self, app: Flask, completion_app, user, payload_patch):
|
||||
api = completion_module.CompletionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -100,7 +101,7 @@ class TestCompletionApi:
|
||||
with pytest.raises(ConversationCompletedError):
|
||||
method(completion_app)
|
||||
|
||||
def test_internal_error(self, app, completion_app, user, payload_patch):
|
||||
def test_internal_error(self, app: Flask, completion_app, user, payload_patch):
|
||||
api = completion_module.CompletionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -117,7 +118,7 @@ class TestCompletionApi:
|
||||
with pytest.raises(InternalServerError):
|
||||
method(completion_app)
|
||||
|
||||
def test_conversation_not_exists(self, app, completion_app, user, payload_patch):
|
||||
def test_conversation_not_exists(self, app: Flask, completion_app, user, payload_patch):
|
||||
api = completion_module.CompletionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -134,7 +135,7 @@ class TestCompletionApi:
|
||||
with pytest.raises(completion_module.NotFound):
|
||||
method(completion_app)
|
||||
|
||||
def test_app_unavailable(self, app, completion_app, user, payload_patch):
|
||||
def test_app_unavailable(self, app: Flask, completion_app, user, payload_patch):
|
||||
api = completion_module.CompletionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -151,7 +152,7 @@ class TestCompletionApi:
|
||||
with pytest.raises(completion_module.AppUnavailableError):
|
||||
method(completion_app)
|
||||
|
||||
def test_provider_not_initialized(self, app, completion_app, user, payload_patch):
|
||||
def test_provider_not_initialized(self, app: Flask, completion_app, user, payload_patch):
|
||||
api = completion_module.CompletionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -168,7 +169,7 @@ class TestCompletionApi:
|
||||
with pytest.raises(completion_module.ProviderNotInitializeError):
|
||||
method(completion_app)
|
||||
|
||||
def test_quota_exceeded(self, app, completion_app, user, payload_patch):
|
||||
def test_quota_exceeded(self, app: Flask, completion_app, user, payload_patch):
|
||||
api = completion_module.CompletionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -185,7 +186,7 @@ class TestCompletionApi:
|
||||
with pytest.raises(completion_module.ProviderQuotaExceededError):
|
||||
method(completion_app)
|
||||
|
||||
def test_model_not_supported(self, app, completion_app, user, payload_patch):
|
||||
def test_model_not_supported(self, app: Flask, completion_app, user, payload_patch):
|
||||
api = completion_module.CompletionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -202,7 +203,7 @@ class TestCompletionApi:
|
||||
with pytest.raises(completion_module.ProviderModelCurrentlyNotSupportError):
|
||||
method(completion_app)
|
||||
|
||||
def test_invoke_error(self, app, completion_app, user, payload_patch):
|
||||
def test_invoke_error(self, app: Flask, completion_app, user, payload_patch):
|
||||
api = completion_module.CompletionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -247,7 +248,7 @@ class TestCompletionStopApi:
|
||||
|
||||
|
||||
class TestChatApi:
|
||||
def test_post_success(self, app, chat_app, user, payload_patch):
|
||||
def test_post_success(self, app: Flask, chat_app, user, payload_patch):
|
||||
api = completion_module.ChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -279,7 +280,7 @@ class TestChatApi:
|
||||
with pytest.raises(NotChatAppError):
|
||||
method(installed_app)
|
||||
|
||||
def test_rate_limit_error(self, app, chat_app, user, payload_patch):
|
||||
def test_rate_limit_error(self, app: Flask, chat_app, user, payload_patch):
|
||||
api = completion_module.ChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -296,7 +297,7 @@ class TestChatApi:
|
||||
with pytest.raises(InvokeRateLimitHttpError):
|
||||
method(chat_app)
|
||||
|
||||
def test_conversation_completed_chat(self, app, chat_app, user, payload_patch):
|
||||
def test_conversation_completed_chat(self, app: Flask, chat_app, user, payload_patch):
|
||||
api = completion_module.ChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -313,7 +314,7 @@ class TestChatApi:
|
||||
with pytest.raises(ConversationCompletedError):
|
||||
method(chat_app)
|
||||
|
||||
def test_conversation_not_exists_chat(self, app, chat_app, user, payload_patch):
|
||||
def test_conversation_not_exists_chat(self, app: Flask, chat_app, user, payload_patch):
|
||||
api = completion_module.ChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -330,7 +331,7 @@ class TestChatApi:
|
||||
with pytest.raises(completion_module.NotFound):
|
||||
method(chat_app)
|
||||
|
||||
def test_app_unavailable_chat(self, app, chat_app, user, payload_patch):
|
||||
def test_app_unavailable_chat(self, app: Flask, chat_app, user, payload_patch):
|
||||
api = completion_module.ChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -347,7 +348,7 @@ class TestChatApi:
|
||||
with pytest.raises(completion_module.AppUnavailableError):
|
||||
method(chat_app)
|
||||
|
||||
def test_provider_not_initialized_chat(self, app, chat_app, user, payload_patch):
|
||||
def test_provider_not_initialized_chat(self, app: Flask, chat_app, user, payload_patch):
|
||||
api = completion_module.ChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -364,7 +365,7 @@ class TestChatApi:
|
||||
with pytest.raises(completion_module.ProviderNotInitializeError):
|
||||
method(chat_app)
|
||||
|
||||
def test_quota_exceeded_chat(self, app, chat_app, user, payload_patch):
|
||||
def test_quota_exceeded_chat(self, app: Flask, chat_app, user, payload_patch):
|
||||
api = completion_module.ChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -381,7 +382,7 @@ class TestChatApi:
|
||||
with pytest.raises(completion_module.ProviderQuotaExceededError):
|
||||
method(chat_app)
|
||||
|
||||
def test_model_not_supported_chat(self, app, chat_app, user, payload_patch):
|
||||
def test_model_not_supported_chat(self, app: Flask, chat_app, user, payload_patch):
|
||||
api = completion_module.ChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -398,7 +399,7 @@ class TestChatApi:
|
||||
with pytest.raises(completion_module.ProviderModelCurrentlyNotSupportError):
|
||||
method(chat_app)
|
||||
|
||||
def test_invoke_error_chat(self, app, chat_app, user, payload_patch):
|
||||
def test_invoke_error_chat(self, app: Flask, chat_app, user, payload_patch):
|
||||
api = completion_module.ChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -415,7 +416,7 @@ class TestChatApi:
|
||||
with pytest.raises(completion_module.CompletionRequestError):
|
||||
method(chat_app)
|
||||
|
||||
def test_internal_error_chat(self, app, chat_app, user, payload_patch):
|
||||
def test_internal_error_chat(self, app: Flask, chat_app, user, payload_patch):
|
||||
api = completion_module.ChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from datetime import datetime
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
|
||||
|
||||
import controllers.console.explore.installed_app as module
|
||||
@@ -51,7 +52,7 @@ def payload_patch():
|
||||
|
||||
|
||||
class TestInstalledAppsListApi:
|
||||
def test_get_installed_apps(self, app, current_user, tenant_id, installed_app):
|
||||
def test_get_installed_apps(self, app: Flask, current_user, tenant_id, installed_app):
|
||||
api = module.InstalledAppsListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -75,7 +76,7 @@ class TestInstalledAppsListApi:
|
||||
assert result["installed_apps"][0]["editable"] is True
|
||||
assert result["installed_apps"][0]["uninstallable"] is False
|
||||
|
||||
def test_get_installed_apps_with_app_id_filter(self, app, current_user, tenant_id):
|
||||
def test_get_installed_apps_with_app_id_filter(self, app: Flask, current_user, tenant_id):
|
||||
api = module.InstalledAppsListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -97,7 +98,7 @@ class TestInstalledAppsListApi:
|
||||
|
||||
assert result == {"installed_apps": []}
|
||||
|
||||
def test_get_installed_apps_with_webapp_auth_enabled(self, app, current_user, tenant_id, installed_app):
|
||||
def test_get_installed_apps_with_webapp_auth_enabled(self, app: Flask, current_user, tenant_id, installed_app):
|
||||
"""Test filtering when webapp_auth is enabled."""
|
||||
api = module.InstalledAppsListApi()
|
||||
method = unwrap(api.get)
|
||||
@@ -133,7 +134,7 @@ class TestInstalledAppsListApi:
|
||||
|
||||
assert len(result["installed_apps"]) == 1
|
||||
|
||||
def test_get_installed_apps_with_webapp_auth_user_denied(self, app, current_user, tenant_id, installed_app):
|
||||
def test_get_installed_apps_with_webapp_auth_user_denied(self, app: Flask, current_user, tenant_id, installed_app):
|
||||
"""Test filtering when user doesn't have access."""
|
||||
api = module.InstalledAppsListApi()
|
||||
method = unwrap(api.get)
|
||||
@@ -169,7 +170,7 @@ class TestInstalledAppsListApi:
|
||||
|
||||
assert result["installed_apps"] == []
|
||||
|
||||
def test_get_installed_apps_with_sso_verified_access(self, app, current_user, tenant_id, installed_app):
|
||||
def test_get_installed_apps_with_sso_verified_access(self, app: Flask, current_user, tenant_id, installed_app):
|
||||
"""Test that sso_verified access mode apps are skipped in filtering."""
|
||||
api = module.InstalledAppsListApi()
|
||||
method = unwrap(api.get)
|
||||
@@ -200,7 +201,7 @@ class TestInstalledAppsListApi:
|
||||
|
||||
assert len(result["installed_apps"]) == 0
|
||||
|
||||
def test_get_installed_apps_filters_null_apps(self, app, current_user, tenant_id):
|
||||
def test_get_installed_apps_filters_null_apps(self, app: Flask, current_user, tenant_id):
|
||||
"""Test that installed apps with null app are filtered out."""
|
||||
api = module.InstalledAppsListApi()
|
||||
method = unwrap(api.get)
|
||||
@@ -226,7 +227,7 @@ class TestInstalledAppsListApi:
|
||||
|
||||
assert result["installed_apps"] == []
|
||||
|
||||
def test_get_installed_apps_current_tenant_none(self, app, tenant_id, installed_app):
|
||||
def test_get_installed_apps_current_tenant_none(self, app: Flask, tenant_id, installed_app):
|
||||
"""Test error when current_user.current_tenant is None."""
|
||||
api = module.InstalledAppsListApi()
|
||||
method = unwrap(api.get)
|
||||
@@ -247,7 +248,7 @@ class TestInstalledAppsListApi:
|
||||
|
||||
|
||||
class TestInstalledAppsCreateApi:
|
||||
def test_post_success(self, app, tenant_id, payload_patch):
|
||||
def test_post_success(self, app: Flask, tenant_id, payload_patch):
|
||||
api = module.InstalledAppsListApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -276,7 +277,7 @@ class TestInstalledAppsCreateApi:
|
||||
assert result == {"message": "App installed successfully"}
|
||||
assert recommended.install_count == 1
|
||||
|
||||
def test_post_recommended_not_found(self, app, payload_patch):
|
||||
def test_post_recommended_not_found(self, app: Flask, payload_patch):
|
||||
api = module.InstalledAppsListApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -291,7 +292,7 @@ class TestInstalledAppsCreateApi:
|
||||
with pytest.raises(NotFound):
|
||||
method(api)
|
||||
|
||||
def test_post_app_not_public(self, app, tenant_id, payload_patch):
|
||||
def test_post_app_not_public(self, app: Flask, tenant_id, payload_patch):
|
||||
api = module.InstalledAppsListApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -315,7 +316,7 @@ class TestInstalledAppsCreateApi:
|
||||
|
||||
|
||||
class TestInstalledAppApi:
|
||||
def test_delete_success(self, tenant_id, installed_app):
|
||||
def test_delete_success(self, tenant_id: str, installed_app):
|
||||
api = module.InstalledAppApi()
|
||||
method = unwrap(api.delete)
|
||||
|
||||
@@ -328,7 +329,7 @@ class TestInstalledAppApi:
|
||||
assert status == 204
|
||||
assert resp["result"] == "success"
|
||||
|
||||
def test_delete_owned_by_current_tenant(self, tenant_id):
|
||||
def test_delete_owned_by_current_tenant(self, tenant_id: str):
|
||||
api = module.InstalledAppApi()
|
||||
method = unwrap(api.delete)
|
||||
|
||||
@@ -338,7 +339,7 @@ class TestInstalledAppApi:
|
||||
with pytest.raises(BadRequest):
|
||||
method(installed_app)
|
||||
|
||||
def test_patch_update_pin(self, app, payload_patch, installed_app):
|
||||
def test_patch_update_pin(self, app: Flask, payload_patch, installed_app):
|
||||
api = module.InstalledAppApi()
|
||||
method = unwrap(api.patch)
|
||||
|
||||
@@ -352,7 +353,7 @@ class TestInstalledAppApi:
|
||||
assert installed_app.is_pinned is True
|
||||
assert result["result"] == "success"
|
||||
|
||||
def test_patch_no_change(self, app, payload_patch, installed_app):
|
||||
def test_patch_no_change(self, app: Flask, payload_patch, installed_app):
|
||||
api = module.InstalledAppApi()
|
||||
method = unwrap(api.patch)
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ class TestSavedMessageListApi:
|
||||
with pytest.raises(NotCompletionAppError):
|
||||
method(installed_app)
|
||||
|
||||
def test_post_success(self, app, payload_patch):
|
||||
def test_post_success(self, app: Flask, payload_patch):
|
||||
api = module.SavedMessageListApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -102,7 +102,7 @@ class TestSavedMessageListApi:
|
||||
save_mock.assert_called_once()
|
||||
assert result == {"result": "success"}
|
||||
|
||||
def test_post_message_not_exists(self, app, payload_patch):
|
||||
def test_post_message_not_exists(self, app: Flask, payload_patch):
|
||||
api = module.SavedMessageListApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ class TestTrialAppWorkflowRunApi:
|
||||
with pytest.raises(NotWorkflowAppError):
|
||||
method(api, MagicMock(mode=AppMode.CHAT))
|
||||
|
||||
def test_success(self, app, trial_app_workflow, account):
|
||||
def test_success(self, app: Flask, trial_app_workflow, account):
|
||||
api = module.TrialAppWorkflowRunApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -116,7 +116,7 @@ class TestTrialAppWorkflowRunApi:
|
||||
|
||||
assert result is not None
|
||||
|
||||
def test_workflow_provider_not_init(self, app, trial_app_workflow, account):
|
||||
def test_workflow_provider_not_init(self, app: Flask, trial_app_workflow, account):
|
||||
api = module.TrialAppWorkflowRunApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -132,7 +132,7 @@ class TestTrialAppWorkflowRunApi:
|
||||
with pytest.raises(ProviderNotInitializeError):
|
||||
method(api, trial_app_workflow)
|
||||
|
||||
def test_workflow_quota_exceeded(self, app, trial_app_workflow, account):
|
||||
def test_workflow_quota_exceeded(self, app: Flask, trial_app_workflow, account):
|
||||
api = module.TrialAppWorkflowRunApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -148,7 +148,7 @@ class TestTrialAppWorkflowRunApi:
|
||||
with pytest.raises(ProviderQuotaExceededError):
|
||||
method(api, trial_app_workflow)
|
||||
|
||||
def test_workflow_model_not_support(self, app, trial_app_workflow, account):
|
||||
def test_workflow_model_not_support(self, app: Flask, trial_app_workflow, account):
|
||||
api = module.TrialAppWorkflowRunApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -164,7 +164,7 @@ class TestTrialAppWorkflowRunApi:
|
||||
with pytest.raises(ProviderModelCurrentlyNotSupportError):
|
||||
method(api, trial_app_workflow)
|
||||
|
||||
def test_workflow_invoke_error(self, app, trial_app_workflow, account):
|
||||
def test_workflow_invoke_error(self, app: Flask, trial_app_workflow, account):
|
||||
api = module.TrialAppWorkflowRunApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -180,7 +180,7 @@ class TestTrialAppWorkflowRunApi:
|
||||
with pytest.raises(CompletionRequestError):
|
||||
method(api, trial_app_workflow)
|
||||
|
||||
def test_workflow_rate_limit_error(self, app, trial_app_workflow, account):
|
||||
def test_workflow_rate_limit_error(self, app: Flask, trial_app_workflow, account):
|
||||
api = module.TrialAppWorkflowRunApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -196,7 +196,7 @@ class TestTrialAppWorkflowRunApi:
|
||||
with pytest.raises(InvokeRateLimitHttpError):
|
||||
method(api, trial_app_workflow)
|
||||
|
||||
def test_workflow_value_error(self, app, trial_app_workflow, account):
|
||||
def test_workflow_value_error(self, app: Flask, trial_app_workflow, account):
|
||||
api = module.TrialAppWorkflowRunApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -212,7 +212,7 @@ class TestTrialAppWorkflowRunApi:
|
||||
with pytest.raises(ValueError):
|
||||
method(api, trial_app_workflow)
|
||||
|
||||
def test_workflow_generic_exception(self, app, trial_app_workflow, account):
|
||||
def test_workflow_generic_exception(self, app: Flask, trial_app_workflow, account):
|
||||
api = module.TrialAppWorkflowRunApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -238,7 +238,7 @@ class TestTrialChatApi:
|
||||
with pytest.raises(NotChatAppError):
|
||||
method(api, MagicMock(mode="completion"))
|
||||
|
||||
def test_success(self, app, trial_app_chat, account):
|
||||
def test_success(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -252,7 +252,7 @@ class TestTrialChatApi:
|
||||
|
||||
assert result is not None
|
||||
|
||||
def test_chat_conversation_not_exists(self, app, trial_app_chat, account):
|
||||
def test_chat_conversation_not_exists(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -268,7 +268,7 @@ class TestTrialChatApi:
|
||||
with pytest.raises(NotFound):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_chat_conversation_completed(self, app, trial_app_chat, account):
|
||||
def test_chat_conversation_completed(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -284,7 +284,7 @@ class TestTrialChatApi:
|
||||
with pytest.raises(ConversationCompletedError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_chat_app_config_broken(self, app, trial_app_chat, account):
|
||||
def test_chat_app_config_broken(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -300,7 +300,7 @@ class TestTrialChatApi:
|
||||
with pytest.raises(AppUnavailableError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_chat_provider_not_init(self, app, trial_app_chat, account):
|
||||
def test_chat_provider_not_init(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -316,7 +316,7 @@ class TestTrialChatApi:
|
||||
with pytest.raises(ProviderNotInitializeError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_chat_quota_exceeded(self, app, trial_app_chat, account):
|
||||
def test_chat_quota_exceeded(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -332,7 +332,7 @@ class TestTrialChatApi:
|
||||
with pytest.raises(ProviderQuotaExceededError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_chat_model_not_support(self, app, trial_app_chat, account):
|
||||
def test_chat_model_not_support(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -348,7 +348,7 @@ class TestTrialChatApi:
|
||||
with pytest.raises(ProviderModelCurrentlyNotSupportError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_chat_invoke_error(self, app, trial_app_chat, account):
|
||||
def test_chat_invoke_error(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -364,7 +364,7 @@ class TestTrialChatApi:
|
||||
with pytest.raises(CompletionRequestError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_chat_rate_limit_error(self, app, trial_app_chat, account):
|
||||
def test_chat_rate_limit_error(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -380,7 +380,7 @@ class TestTrialChatApi:
|
||||
with pytest.raises(InvokeRateLimitHttpError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_chat_value_error(self, app, trial_app_chat, account):
|
||||
def test_chat_value_error(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -396,7 +396,7 @@ class TestTrialChatApi:
|
||||
with pytest.raises(ValueError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_chat_generic_exception(self, app, trial_app_chat, account):
|
||||
def test_chat_generic_exception(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -422,7 +422,7 @@ class TestTrialCompletionApi:
|
||||
with pytest.raises(NotCompletionAppError):
|
||||
method(api, MagicMock(mode=AppMode.CHAT))
|
||||
|
||||
def test_success(self, app, trial_app_completion, account):
|
||||
def test_success(self, app: Flask, trial_app_completion, account):
|
||||
api = module.TrialCompletionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -436,7 +436,7 @@ class TestTrialCompletionApi:
|
||||
|
||||
assert result is not None
|
||||
|
||||
def test_completion_app_config_broken(self, app, trial_app_completion, account):
|
||||
def test_completion_app_config_broken(self, app: Flask, trial_app_completion, account):
|
||||
api = module.TrialCompletionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -452,7 +452,7 @@ class TestTrialCompletionApi:
|
||||
with pytest.raises(AppUnavailableError):
|
||||
method(api, trial_app_completion)
|
||||
|
||||
def test_completion_provider_not_init(self, app, trial_app_completion, account):
|
||||
def test_completion_provider_not_init(self, app: Flask, trial_app_completion, account):
|
||||
api = module.TrialCompletionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -468,7 +468,7 @@ class TestTrialCompletionApi:
|
||||
with pytest.raises(ProviderNotInitializeError):
|
||||
method(api, trial_app_completion)
|
||||
|
||||
def test_completion_quota_exceeded(self, app, trial_app_completion, account):
|
||||
def test_completion_quota_exceeded(self, app: Flask, trial_app_completion, account):
|
||||
api = module.TrialCompletionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -484,7 +484,7 @@ class TestTrialCompletionApi:
|
||||
with pytest.raises(ProviderQuotaExceededError):
|
||||
method(api, trial_app_completion)
|
||||
|
||||
def test_completion_model_not_support(self, app, trial_app_completion, account):
|
||||
def test_completion_model_not_support(self, app: Flask, trial_app_completion, account):
|
||||
api = module.TrialCompletionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -500,7 +500,7 @@ class TestTrialCompletionApi:
|
||||
with pytest.raises(ProviderModelCurrentlyNotSupportError):
|
||||
method(api, trial_app_completion)
|
||||
|
||||
def test_completion_invoke_error(self, app, trial_app_completion, account):
|
||||
def test_completion_invoke_error(self, app: Flask, trial_app_completion, account):
|
||||
api = module.TrialCompletionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -516,7 +516,7 @@ class TestTrialCompletionApi:
|
||||
with pytest.raises(CompletionRequestError):
|
||||
method(api, trial_app_completion)
|
||||
|
||||
def test_completion_rate_limit_error(self, app, trial_app_completion, account):
|
||||
def test_completion_rate_limit_error(self, app: Flask, trial_app_completion, account):
|
||||
api = module.TrialCompletionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -532,7 +532,7 @@ class TestTrialCompletionApi:
|
||||
with pytest.raises(InternalServerError):
|
||||
method(api, trial_app_completion)
|
||||
|
||||
def test_completion_value_error(self, app, trial_app_completion, account):
|
||||
def test_completion_value_error(self, app: Flask, trial_app_completion, account):
|
||||
api = module.TrialCompletionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -548,7 +548,7 @@ class TestTrialCompletionApi:
|
||||
with pytest.raises(ValueError):
|
||||
method(api, trial_app_completion)
|
||||
|
||||
def test_completion_generic_exception(self, app, trial_app_completion, account):
|
||||
def test_completion_generic_exception(self, app: Flask, trial_app_completion, account):
|
||||
api = module.TrialCompletionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -574,7 +574,7 @@ class TestTrialMessageSuggestedQuestionApi:
|
||||
with pytest.raises(NotChatAppError):
|
||||
method(MagicMock(mode="completion"), str(uuid4()))
|
||||
|
||||
def test_success(self, app, trial_app_chat, account):
|
||||
def test_success(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialMessageSuggestedQuestionApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -591,7 +591,7 @@ class TestTrialMessageSuggestedQuestionApi:
|
||||
|
||||
assert result == {"data": ["q1", "q2"]}
|
||||
|
||||
def test_conversation_not_exists(self, app, trial_app_chat, account):
|
||||
def test_conversation_not_exists(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialMessageSuggestedQuestionApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@@ -643,7 +643,7 @@ class TestTrialAppParameterApi:
|
||||
|
||||
|
||||
class TestTrialChatAudioApi:
|
||||
def test_success(self, app, trial_app_chat, account):
|
||||
def test_success(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatAudioApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -662,7 +662,7 @@ class TestTrialChatAudioApi:
|
||||
|
||||
assert result == {"text": "hello"}
|
||||
|
||||
def test_app_config_broken(self, app, trial_app_chat, account):
|
||||
def test_app_config_broken(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatAudioApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -683,7 +683,7 @@ class TestTrialChatAudioApi:
|
||||
with pytest.raises(module.AppUnavailableError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_no_audio_uploaded(self, app, trial_app_chat, account):
|
||||
def test_no_audio_uploaded(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatAudioApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -704,7 +704,7 @@ class TestTrialChatAudioApi:
|
||||
with pytest.raises(module.NoAudioUploadedError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_audio_too_large(self, app, trial_app_chat, account):
|
||||
def test_audio_too_large(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatAudioApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -725,7 +725,7 @@ class TestTrialChatAudioApi:
|
||||
with pytest.raises(module.AudioTooLargeError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_unsupported_audio_type(self, app, trial_app_chat, account):
|
||||
def test_unsupported_audio_type(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatAudioApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -746,7 +746,7 @@ class TestTrialChatAudioApi:
|
||||
with pytest.raises(module.UnsupportedAudioTypeError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_provider_not_support_tts(self, app, trial_app_chat, account):
|
||||
def test_provider_not_support_tts(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatAudioApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -767,7 +767,7 @@ class TestTrialChatAudioApi:
|
||||
with pytest.raises(module.ProviderNotSupportSpeechToTextError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_provider_not_init(self, app, trial_app_chat, account):
|
||||
def test_provider_not_init(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatAudioApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -784,7 +784,7 @@ class TestTrialChatAudioApi:
|
||||
with pytest.raises(ProviderNotInitializeError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_quota_exceeded(self, app, trial_app_chat, account):
|
||||
def test_quota_exceeded(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatAudioApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -803,7 +803,7 @@ class TestTrialChatAudioApi:
|
||||
|
||||
|
||||
class TestTrialChatTextApi:
|
||||
def test_success(self, app, trial_app_chat, account):
|
||||
def test_success(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatTextApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -817,7 +817,7 @@ class TestTrialChatTextApi:
|
||||
|
||||
assert result == {"audio": "base64_data"}
|
||||
|
||||
def test_app_config_broken(self, app, trial_app_chat, account):
|
||||
def test_app_config_broken(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatTextApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -833,7 +833,7 @@ class TestTrialChatTextApi:
|
||||
with pytest.raises(module.AppUnavailableError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_provider_not_support(self, app, trial_app_chat, account):
|
||||
def test_provider_not_support(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatTextApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -849,7 +849,7 @@ class TestTrialChatTextApi:
|
||||
with pytest.raises(module.ProviderNotSupportSpeechToTextError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_audio_too_large(self, app, trial_app_chat, account):
|
||||
def test_audio_too_large(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatTextApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -865,7 +865,7 @@ class TestTrialChatTextApi:
|
||||
with pytest.raises(module.AudioTooLargeError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_no_audio_uploaded(self, app, trial_app_chat, account):
|
||||
def test_no_audio_uploaded(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatTextApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -881,7 +881,7 @@ class TestTrialChatTextApi:
|
||||
with pytest.raises(module.NoAudioUploadedError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_provider_not_init(self, app, trial_app_chat, account):
|
||||
def test_provider_not_init(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatTextApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -893,7 +893,7 @@ class TestTrialChatTextApi:
|
||||
with pytest.raises(ProviderNotInitializeError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_quota_exceeded(self, app, trial_app_chat, account):
|
||||
def test_quota_exceeded(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatTextApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -905,7 +905,7 @@ class TestTrialChatTextApi:
|
||||
with pytest.raises(ProviderQuotaExceededError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_model_not_support(self, app, trial_app_chat, account):
|
||||
def test_model_not_support(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatTextApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -917,7 +917,7 @@ class TestTrialChatTextApi:
|
||||
with pytest.raises(ProviderModelCurrentlyNotSupportError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_invoke_error(self, app, trial_app_chat, account):
|
||||
def test_invoke_error(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatTextApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -931,7 +931,7 @@ class TestTrialChatTextApi:
|
||||
|
||||
|
||||
class TestTrialAppWorkflowTaskStopApi:
|
||||
def test_not_workflow_app(self, app, trial_app_chat):
|
||||
def test_not_workflow_app(self, app: Flask, trial_app_chat):
|
||||
api = module.TrialAppWorkflowTaskStopApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -939,7 +939,7 @@ class TestTrialAppWorkflowTaskStopApi:
|
||||
with pytest.raises(NotWorkflowAppError):
|
||||
method(api, trial_app_chat, str(uuid4()))
|
||||
|
||||
def test_success(self, app, trial_app_workflow, account):
|
||||
def test_success(self, app: Flask, trial_app_workflow, account):
|
||||
api = module.TrialAppWorkflowTaskStopApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -1009,7 +1009,7 @@ class TestTrialSitApi:
|
||||
|
||||
|
||||
class TestTrialChatAudioApiExceptionHandlers:
|
||||
def test_provider_not_init(self, app, trial_app_chat, account):
|
||||
def test_provider_not_init(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatAudioApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -1030,7 +1030,7 @@ class TestTrialChatAudioApiExceptionHandlers:
|
||||
with pytest.raises(ProviderNotInitializeError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_quota_exceeded(self, app, trial_app_chat, account):
|
||||
def test_quota_exceeded(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatAudioApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -1051,7 +1051,7 @@ class TestTrialChatAudioApiExceptionHandlers:
|
||||
with pytest.raises(ProviderQuotaExceededError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_invoke_error(self, app, trial_app_chat, account):
|
||||
def test_invoke_error(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatAudioApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -1074,7 +1074,7 @@ class TestTrialChatAudioApiExceptionHandlers:
|
||||
|
||||
|
||||
class TestTrialChatTextApiExceptionHandlers:
|
||||
def test_app_config_broken(self, app, trial_app_chat, account):
|
||||
def test_app_config_broken(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatTextApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -1090,7 +1090,7 @@ class TestTrialChatTextApiExceptionHandlers:
|
||||
with pytest.raises(module.AppUnavailableError):
|
||||
method(api, trial_app_chat)
|
||||
|
||||
def test_unsupported_audio_type(self, app, trial_app_chat, account):
|
||||
def test_unsupported_audio_type(self, app: Flask, trial_app_chat, account):
|
||||
api = module.TrialChatTextApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ def payload():
|
||||
|
||||
|
||||
class TestInstalledAppWorkflowRunApi:
|
||||
def test_not_workflow_app(self, app, non_workflow_installed_app):
|
||||
def test_not_workflow_app(self, app: Flask, non_workflow_installed_app):
|
||||
api = InstalledAppWorkflowRunApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -71,7 +71,7 @@ class TestInstalledAppWorkflowRunApi:
|
||||
with pytest.raises(NotWorkflowAppError):
|
||||
method(non_workflow_installed_app)
|
||||
|
||||
def test_success(self, app, installed_workflow_app, user, payload):
|
||||
def test_success(self, app: Flask, installed_workflow_app, user, payload):
|
||||
api = InstalledAppWorkflowRunApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -91,7 +91,7 @@ class TestInstalledAppWorkflowRunApi:
|
||||
generate_mock.assert_called_once()
|
||||
assert result is not None
|
||||
|
||||
def test_rate_limit_error(self, app, installed_workflow_app, user, payload):
|
||||
def test_rate_limit_error(self, app: Flask, installed_workflow_app, user, payload):
|
||||
api = InstalledAppWorkflowRunApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -109,7 +109,7 @@ class TestInstalledAppWorkflowRunApi:
|
||||
with pytest.raises(InvokeRateLimitHttpError):
|
||||
method(installed_workflow_app)
|
||||
|
||||
def test_unexpected_exception(self, app, installed_workflow_app, user, payload):
|
||||
def test_unexpected_exception(self, app: Flask, installed_workflow_app, user, payload):
|
||||
api = InstalledAppWorkflowRunApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ class TestTagListApi:
|
||||
assert status == 200
|
||||
assert result == [{"id": "1", "name": "tag", "type": "knowledge", "binding_count": "1"}]
|
||||
|
||||
def test_post_success(self, app, admin_user, tag, payload_patch):
|
||||
def test_post_success(self, app: Flask, admin_user, tag, payload_patch):
|
||||
api = TagListApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -144,7 +144,7 @@ class TestTagListApi:
|
||||
|
||||
|
||||
class TestTagUpdateDeleteApi:
|
||||
def test_patch_success(self, app, admin_user, tag, payload_patch):
|
||||
def test_patch_success(self, app: Flask, admin_user, tag, payload_patch):
|
||||
api = TagUpdateDeleteApi()
|
||||
method = unwrap(api.patch)
|
||||
|
||||
@@ -191,7 +191,7 @@ class TestTagUpdateDeleteApi:
|
||||
with pytest.raises(Forbidden):
|
||||
method(api, "tag-1")
|
||||
|
||||
def test_delete_success(self, app, admin_user):
|
||||
def test_delete_success(self, app: Flask, admin_user):
|
||||
api = TagUpdateDeleteApi()
|
||||
method = unwrap(api.delete)
|
||||
|
||||
@@ -210,7 +210,7 @@ class TestTagUpdateDeleteApi:
|
||||
|
||||
|
||||
class TestTagBindingCollectionApi:
|
||||
def test_create_success(self, app, admin_user, payload_patch):
|
||||
def test_create_success(self, app: Flask, admin_user, payload_patch):
|
||||
api = TagBindingCollectionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -252,7 +252,7 @@ class TestTagBindingCollectionApi:
|
||||
|
||||
|
||||
class TestTagBindingRemoveApi:
|
||||
def test_remove_success(self, app, admin_user, payload_patch):
|
||||
def test_remove_success(self, app: Flask, admin_user, payload_patch):
|
||||
api = TagBindingRemoveApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ class TestFileApiGet:
|
||||
|
||||
|
||||
class TestFileApiPost:
|
||||
def test_no_file_uploaded(self, app, mock_account_context):
|
||||
def test_no_file_uploaded(self, app: Flask, mock_account_context):
|
||||
api = FileApi()
|
||||
post_method = unwrap(api.post)
|
||||
|
||||
@@ -103,7 +103,7 @@ class TestFileApiPost:
|
||||
with pytest.raises(NoFileUploadedError):
|
||||
post_method(api)
|
||||
|
||||
def test_too_many_files(self, app, mock_account_context):
|
||||
def test_too_many_files(self, app: Flask, mock_account_context):
|
||||
api = FileApi()
|
||||
post_method = unwrap(api.post)
|
||||
|
||||
@@ -120,7 +120,7 @@ class TestFileApiPost:
|
||||
with pytest.raises(TooManyFilesError):
|
||||
post_method(api)
|
||||
|
||||
def test_filename_missing(self, app, mock_account_context):
|
||||
def test_filename_missing(self, app: Flask, mock_account_context):
|
||||
api = FileApi()
|
||||
post_method = unwrap(api.post)
|
||||
|
||||
@@ -132,7 +132,7 @@ class TestFileApiPost:
|
||||
with pytest.raises(FilenameNotExistsError):
|
||||
post_method(api)
|
||||
|
||||
def test_dataset_upload_without_permission(self, app, mock_current_user):
|
||||
def test_dataset_upload_without_permission(self, app: Flask, mock_current_user):
|
||||
mock_current_user.is_dataset_editor = False
|
||||
|
||||
with patch(
|
||||
@@ -151,7 +151,7 @@ class TestFileApiPost:
|
||||
with pytest.raises(Forbidden):
|
||||
post_method(api)
|
||||
|
||||
def test_successful_upload(self, app, mock_account_context, mock_file_service):
|
||||
def test_successful_upload(self, app: Flask, mock_account_context, mock_file_service):
|
||||
api = FileApi()
|
||||
post_method = unwrap(api.post)
|
||||
|
||||
@@ -185,7 +185,7 @@ class TestFileApiPost:
|
||||
assert response["id"] == "file-id-123"
|
||||
assert response["name"] == "test.txt"
|
||||
|
||||
def test_upload_with_invalid_source(self, app, mock_account_context, mock_file_service):
|
||||
def test_upload_with_invalid_source(self, app: Flask, mock_account_context, mock_file_service):
|
||||
"""Test that invalid source parameter gets normalized to None"""
|
||||
api = FileApi()
|
||||
post_method = unwrap(api.post)
|
||||
@@ -225,7 +225,7 @@ class TestFileApiPost:
|
||||
call_kwargs = mock_file_service.upload_file.call_args[1]
|
||||
assert call_kwargs["source"] is None
|
||||
|
||||
def test_file_too_large_error(self, app, mock_account_context, mock_file_service):
|
||||
def test_file_too_large_error(self, app: Flask, mock_account_context, mock_file_service):
|
||||
api = FileApi()
|
||||
post_method = unwrap(api.post)
|
||||
|
||||
@@ -242,7 +242,7 @@ class TestFileApiPost:
|
||||
with pytest.raises(FileTooLargeError):
|
||||
post_method(api)
|
||||
|
||||
def test_unsupported_file_type(self, app, mock_account_context, mock_file_service):
|
||||
def test_unsupported_file_type(self, app: Flask, mock_account_context, mock_file_service):
|
||||
api = FileApi()
|
||||
post_method = unwrap(api.post)
|
||||
|
||||
@@ -259,7 +259,7 @@ class TestFileApiPost:
|
||||
with pytest.raises(UnsupportedFileTypeError):
|
||||
post_method(api)
|
||||
|
||||
def test_blocked_extension(self, app, mock_account_context, mock_file_service):
|
||||
def test_blocked_extension(self, app: Flask, mock_account_context, mock_file_service):
|
||||
api = FileApi()
|
||||
post_method = unwrap(api.post)
|
||||
|
||||
@@ -278,7 +278,7 @@ class TestFileApiPost:
|
||||
|
||||
|
||||
class TestFilePreviewApi:
|
||||
def test_get_preview(self, app, mock_account_context, mock_file_service):
|
||||
def test_get_preview(self, app: Flask, mock_account_context, mock_file_service):
|
||||
api = FilePreviewApi()
|
||||
get_method = unwrap(api.get)
|
||||
mock_file_service.get_file_preview.return_value = "preview text"
|
||||
|
||||
@@ -114,7 +114,7 @@ class TestAccountUpdateApis:
|
||||
(AccountTimezoneApi, {"timezone": "UTC"}),
|
||||
],
|
||||
)
|
||||
def test_update_success(self, app, api_cls, payload):
|
||||
def test_update_success(self, app: Flask, api_cls, payload):
|
||||
api = api_cls()
|
||||
method = unwrap(api.post)
|
||||
|
||||
|
||||
@@ -302,7 +302,7 @@ class TestPluginFetchPermissionApi:
|
||||
|
||||
|
||||
class TestPluginFetchDynamicSelectOptionsApi:
|
||||
def test_fetch_dynamic_options(self, app, user):
|
||||
def test_fetch_dynamic_options(self, app: Flask, user):
|
||||
api = PluginFetchDynamicSelectOptionsApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask_restx.api import HTTPStatus
|
||||
|
||||
from controllers.service_api.app.annotation import (
|
||||
@@ -163,7 +164,7 @@ class TestAnnotationErrorPatterns:
|
||||
|
||||
|
||||
class TestAnnotationReplyActionApi:
|
||||
def test_enable(self, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_enable(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
enable_mock = Mock()
|
||||
monkeypatch.setattr(AppAnnotationService, "enable_app_annotation", enable_mock)
|
||||
|
||||
@@ -181,7 +182,7 @@ class TestAnnotationReplyActionApi:
|
||||
assert status == 200
|
||||
enable_mock.assert_called_once()
|
||||
|
||||
def test_disable(self, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_disable(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
disable_mock = Mock()
|
||||
monkeypatch.setattr(AppAnnotationService, "disable_app_annotation", disable_mock)
|
||||
|
||||
@@ -231,7 +232,7 @@ class TestAnnotationReplyActionStatusApi:
|
||||
|
||||
|
||||
class TestAnnotationListApi:
|
||||
def test_get(self, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_get(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
annotation = SimpleNamespace(id="a1", question="q", content="a", created_at=0)
|
||||
monkeypatch.setattr(
|
||||
AppAnnotationService,
|
||||
@@ -248,7 +249,7 @@ class TestAnnotationListApi:
|
||||
|
||||
assert response["total"] == 1
|
||||
|
||||
def test_create(self, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_create(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
annotation = SimpleNamespace(id="a1", question="q", content="a", created_at=0)
|
||||
monkeypatch.setattr(
|
||||
AppAnnotationService,
|
||||
@@ -268,7 +269,7 @@ class TestAnnotationListApi:
|
||||
|
||||
|
||||
class TestAnnotationUpdateDeleteApi:
|
||||
def test_update_delete(self, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_update_delete(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
annotation = SimpleNamespace(id="a1", question="q", content="a", created_at=0)
|
||||
monkeypatch.setattr(
|
||||
AppAnnotationService,
|
||||
|
||||
@@ -415,7 +415,7 @@ class TestChatRequestPayloadController:
|
||||
|
||||
|
||||
class TestCompletionApiController:
|
||||
def test_wrong_mode(self, app) -> None:
|
||||
def test_wrong_mode(self, app: Flask) -> None:
|
||||
api = CompletionApi()
|
||||
handler = _unwrap(api.post)
|
||||
app_model = SimpleNamespace(mode=AppMode.CHAT.value)
|
||||
@@ -425,7 +425,7 @@ class TestCompletionApiController:
|
||||
with pytest.raises(AppUnavailableError):
|
||||
handler(api, app_model=app_model, end_user=end_user)
|
||||
|
||||
def test_conversation_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_conversation_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
AppGenerateService,
|
||||
"generate",
|
||||
@@ -443,7 +443,7 @@ class TestCompletionApiController:
|
||||
|
||||
|
||||
class TestCompletionStopApiController:
|
||||
def test_wrong_mode(self, app) -> None:
|
||||
def test_wrong_mode(self, app: Flask) -> None:
|
||||
api = CompletionStopApi()
|
||||
handler = _unwrap(api.post)
|
||||
app_model = SimpleNamespace(mode=AppMode.CHAT.value)
|
||||
@@ -453,7 +453,7 @@ class TestCompletionStopApiController:
|
||||
with pytest.raises(AppUnavailableError):
|
||||
handler(api, app_model=app_model, end_user=end_user, task_id="t1")
|
||||
|
||||
def test_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stop_mock = Mock()
|
||||
monkeypatch.setattr(AppTaskService, "stop_task", stop_mock)
|
||||
|
||||
@@ -470,7 +470,7 @@ class TestCompletionStopApiController:
|
||||
|
||||
|
||||
class TestChatApiController:
|
||||
def test_wrong_mode(self, app) -> None:
|
||||
def test_wrong_mode(self, app: Flask) -> None:
|
||||
api = ChatApi()
|
||||
handler = _unwrap(api.post)
|
||||
app_model = SimpleNamespace(mode=AppMode.COMPLETION.value)
|
||||
@@ -480,7 +480,7 @@ class TestChatApiController:
|
||||
with pytest.raises(NotChatAppError):
|
||||
handler(api, app_model=app_model, end_user=end_user)
|
||||
|
||||
def test_workflow_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_workflow_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
AppGenerateService,
|
||||
"generate",
|
||||
@@ -496,7 +496,7 @@ class TestChatApiController:
|
||||
with pytest.raises(NotFound):
|
||||
handler(api, app_model=app_model, end_user=end_user)
|
||||
|
||||
def test_draft_workflow(self, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_draft_workflow(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
AppGenerateService,
|
||||
"generate",
|
||||
@@ -514,10 +514,10 @@ class TestChatApiController:
|
||||
|
||||
|
||||
class TestChatStopApiController:
|
||||
def test_wrong_mode(self, app) -> None:
|
||||
def test_wrong_mode(self, app: Flask) -> None:
|
||||
api = ChatStopApi()
|
||||
handler = _unwrap(api.post)
|
||||
app_model = SimpleNamespace(mode=AppMode.COMPLETION.value)
|
||||
app_model = SimpleNamespace(mode=AppMode.COMPLETION)
|
||||
end_user = SimpleNamespace(id="u1")
|
||||
|
||||
with app.test_request_context("/chat-messages/1/stop", method="POST"):
|
||||
|
||||
@@ -495,7 +495,7 @@ class TestConversationPayloadsController:
|
||||
|
||||
|
||||
class TestConversationApiController:
|
||||
def test_list_not_chat(self, app) -> None:
|
||||
def test_list_not_chat(self, app: Flask) -> None:
|
||||
api = ConversationApi()
|
||||
handler = _unwrap(api.get)
|
||||
app_model = SimpleNamespace(mode=AppMode.COMPLETION)
|
||||
@@ -543,7 +543,7 @@ class TestConversationApiController:
|
||||
|
||||
|
||||
class TestConversationDetailApiController:
|
||||
def test_delete_not_chat(self, app) -> None:
|
||||
def test_delete_not_chat(self, app: Flask) -> None:
|
||||
api = ConversationDetailApi()
|
||||
handler = _unwrap(api.delete)
|
||||
app_model = SimpleNamespace(mode=AppMode.COMPLETION)
|
||||
@@ -593,7 +593,7 @@ class TestConversationRenameApiController:
|
||||
|
||||
|
||||
class TestConversationVariablesApiController:
|
||||
def test_not_chat(self, app) -> None:
|
||||
def test_not_chat(self, app: Flask) -> None:
|
||||
api = ConversationVariablesApi()
|
||||
handler = _unwrap(api.get)
|
||||
app_model = SimpleNamespace(mode=AppMode.COMPLETION)
|
||||
|
||||
@@ -238,7 +238,7 @@ class TestFileApiPost:
|
||||
self,
|
||||
mock_db,
|
||||
mock_file_svc_cls,
|
||||
app,
|
||||
app: Flask,
|
||||
mock_app_model,
|
||||
mock_end_user,
|
||||
):
|
||||
@@ -342,7 +342,7 @@ class TestFileApiPost:
|
||||
self,
|
||||
mock_db,
|
||||
mock_file_svc_cls,
|
||||
app,
|
||||
app: Flask,
|
||||
mock_app_model,
|
||||
mock_end_user,
|
||||
):
|
||||
@@ -374,7 +374,7 @@ class TestFileApiPost:
|
||||
self,
|
||||
mock_db,
|
||||
mock_file_svc_cls,
|
||||
app,
|
||||
app: Flask,
|
||||
mock_app_model,
|
||||
mock_end_user,
|
||||
):
|
||||
|
||||
@@ -66,7 +66,7 @@ class TestFilePreviewApi:
|
||||
return message
|
||||
|
||||
def test_validate_file_ownership_success(
|
||||
self, file_preview_api, mock_app, mock_upload_file, mock_message_file, mock_message
|
||||
self, file_preview_api: FilePreviewApi, mock_app, mock_upload_file, mock_message_file, mock_message
|
||||
):
|
||||
"""Test successful file ownership validation"""
|
||||
file_id = str(uuid.uuid4())
|
||||
@@ -97,7 +97,7 @@ class TestFilePreviewApi:
|
||||
assert result_message_file == mock_message_file
|
||||
assert result_upload_file == mock_upload_file
|
||||
|
||||
def test_validate_file_ownership_file_not_found(self, file_preview_api):
|
||||
def test_validate_file_ownership_file_not_found(self, file_preview_api: FilePreviewApi):
|
||||
"""Test file ownership validation when MessageFile not found"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
@@ -112,7 +112,7 @@ class TestFilePreviewApi:
|
||||
|
||||
assert "File not found in message context" in str(exc_info.value)
|
||||
|
||||
def test_validate_file_ownership_access_denied(self, file_preview_api, mock_message_file):
|
||||
def test_validate_file_ownership_access_denied(self, file_preview_api: FilePreviewApi, mock_message_file):
|
||||
"""Test file ownership validation when Message not owned by app"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
@@ -130,7 +130,9 @@ class TestFilePreviewApi:
|
||||
|
||||
assert "not owned by requesting app" in str(exc_info.value)
|
||||
|
||||
def test_validate_file_ownership_upload_file_not_found(self, file_preview_api, mock_message_file, mock_message):
|
||||
def test_validate_file_ownership_upload_file_not_found(
|
||||
self, file_preview_api: FilePreviewApi, mock_message_file, mock_message
|
||||
):
|
||||
"""Test file ownership validation when UploadFile not found"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
@@ -151,7 +153,7 @@ class TestFilePreviewApi:
|
||||
assert "Upload file record not found" in str(exc_info.value)
|
||||
|
||||
def test_validate_file_ownership_tenant_mismatch(
|
||||
self, file_preview_api, mock_app, mock_upload_file, mock_message_file, mock_message
|
||||
self, file_preview_api: FilePreviewApi, mock_app, mock_upload_file, mock_message_file, mock_message
|
||||
):
|
||||
"""Test file ownership validation with tenant mismatch"""
|
||||
file_id = str(uuid.uuid4())
|
||||
@@ -182,7 +184,7 @@ class TestFilePreviewApi:
|
||||
|
||||
assert "tenant mismatch" in str(exc_info.value)
|
||||
|
||||
def test_validate_file_ownership_invalid_input(self, file_preview_api):
|
||||
def test_validate_file_ownership_invalid_input(self, file_preview_api: FilePreviewApi):
|
||||
"""Test file ownership validation with invalid input"""
|
||||
|
||||
# Test with empty file_id
|
||||
@@ -195,7 +197,7 @@ class TestFilePreviewApi:
|
||||
file_preview_api._validate_file_ownership("file_id", "")
|
||||
assert "Invalid file or app identifier" in str(exc_info.value)
|
||||
|
||||
def test_build_file_response_basic(self, file_preview_api, mock_upload_file):
|
||||
def test_build_file_response_basic(self, file_preview_api: FilePreviewApi, mock_upload_file):
|
||||
"""Test basic file response building"""
|
||||
mock_generator = Mock()
|
||||
|
||||
@@ -207,7 +209,7 @@ class TestFilePreviewApi:
|
||||
assert response.headers["Content-Length"] == str(mock_upload_file.size)
|
||||
assert "Cache-Control" in response.headers
|
||||
|
||||
def test_build_file_response_as_attachment(self, file_preview_api, mock_upload_file):
|
||||
def test_build_file_response_as_attachment(self, file_preview_api: FilePreviewApi, mock_upload_file):
|
||||
"""Test file response building with attachment flag"""
|
||||
mock_generator = Mock()
|
||||
|
||||
@@ -218,7 +220,7 @@ class TestFilePreviewApi:
|
||||
assert mock_upload_file.name in response.headers["Content-Disposition"]
|
||||
assert response.headers["Content-Type"] == "application/octet-stream"
|
||||
|
||||
def test_build_file_response_html_forces_attachment(self, file_preview_api, mock_upload_file):
|
||||
def test_build_file_response_html_forces_attachment(self, file_preview_api: FilePreviewApi, mock_upload_file):
|
||||
"""Test HTML files are forced to download"""
|
||||
mock_generator = Mock()
|
||||
mock_upload_file.mime_type = "text/html"
|
||||
@@ -231,7 +233,7 @@ class TestFilePreviewApi:
|
||||
assert response.headers["Content-Type"] == "application/octet-stream"
|
||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||
|
||||
def test_build_file_response_audio_video(self, file_preview_api, mock_upload_file):
|
||||
def test_build_file_response_audio_video(self, file_preview_api: FilePreviewApi, mock_upload_file):
|
||||
"""Test file response building for audio/video files"""
|
||||
mock_generator = Mock()
|
||||
mock_upload_file.mime_type = "video/mp4"
|
||||
@@ -241,7 +243,7 @@ class TestFilePreviewApi:
|
||||
# Check Range support for media files
|
||||
assert response.headers["Accept-Ranges"] == "bytes"
|
||||
|
||||
def test_build_file_response_no_size(self, file_preview_api, mock_upload_file):
|
||||
def test_build_file_response_no_size(self, file_preview_api: FilePreviewApi, mock_upload_file):
|
||||
"""Test file response building when size is unknown"""
|
||||
mock_generator = Mock()
|
||||
mock_upload_file.size = 0 # Unknown size
|
||||
@@ -253,7 +255,14 @@ class TestFilePreviewApi:
|
||||
|
||||
@patch("controllers.service_api.app.file_preview.storage")
|
||||
def test_get_method_integration(
|
||||
self, mock_storage, file_preview_api, mock_app, mock_end_user, mock_upload_file, mock_message_file, mock_message
|
||||
self,
|
||||
mock_storage,
|
||||
file_preview_api: FilePreviewApi,
|
||||
mock_app,
|
||||
mock_end_user,
|
||||
mock_upload_file,
|
||||
mock_message_file,
|
||||
mock_message,
|
||||
):
|
||||
"""Test the full GET method integration (without decorator)"""
|
||||
file_id = str(uuid.uuid4())
|
||||
@@ -295,7 +304,13 @@ class TestFilePreviewApi:
|
||||
|
||||
@patch("controllers.service_api.app.file_preview.storage")
|
||||
def test_storage_error_handling(
|
||||
self, mock_storage, file_preview_api, mock_app, mock_upload_file, mock_message_file, mock_message
|
||||
self,
|
||||
mock_storage,
|
||||
file_preview_api: FilePreviewApi,
|
||||
mock_app,
|
||||
mock_upload_file,
|
||||
mock_message_file,
|
||||
mock_message,
|
||||
):
|
||||
"""Test storage error handling in the core logic"""
|
||||
file_id = str(uuid.uuid4())
|
||||
@@ -334,7 +349,7 @@ class TestFilePreviewApi:
|
||||
assert "Storage error" in str(exc_info.value)
|
||||
|
||||
@patch("controllers.service_api.app.file_preview.logger")
|
||||
def test_validate_file_ownership_unexpected_error_logging(self, mock_logger, file_preview_api):
|
||||
def test_validate_file_ownership_unexpected_error_logging(self, mock_logger, file_preview_api: FilePreviewApi):
|
||||
"""Test that unexpected errors are logged properly"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
|
||||
@@ -249,7 +249,9 @@ def _build_resumption_context(task_id: str) -> WorkflowResumptionContext:
|
||||
|
||||
class TestHitlServiceApi:
|
||||
# Service API event-stream continuation
|
||||
def test_workflow_events_continue_on_pause_keeps_stream_open(self, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_workflow_events_continue_on_pause_keeps_stream_open(
|
||||
self, app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
workflow_run = SimpleNamespace(
|
||||
id="run-1",
|
||||
app_id="app-1",
|
||||
|
||||
@@ -9,6 +9,7 @@ from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.service_api.app.human_input_form import WorkflowHumanInputFormApi
|
||||
@@ -17,7 +18,7 @@ from tests.unit_tests.controllers.service_api.conftest import _unwrap
|
||||
|
||||
|
||||
class TestWorkflowHumanInputFormApi:
|
||||
def test_get_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_get_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
definition = SimpleNamespace(
|
||||
model_dump=lambda: {
|
||||
"rendered_content": "Rendered form content",
|
||||
@@ -57,7 +58,7 @@ class TestWorkflowHumanInputFormApi:
|
||||
service_mock.get_form_by_token.assert_called_once_with("token-1")
|
||||
service_mock.ensure_form_active.assert_called_once_with(form)
|
||||
|
||||
def test_get_form_not_in_app(self, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_get_form_not_in_app(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
form = SimpleNamespace(
|
||||
app_id="another-app",
|
||||
tenant_id="tenant-1",
|
||||
@@ -87,7 +88,7 @@ class TestWorkflowHumanInputFormApi:
|
||||
],
|
||||
)
|
||||
def test_get_rejects_non_service_api_recipient_types(
|
||||
self, app, monkeypatch: pytest.MonkeyPatch, recipient_type: RecipientType
|
||||
self, app: Flask, monkeypatch: pytest.MonkeyPatch, recipient_type: RecipientType
|
||||
) -> None:
|
||||
form = SimpleNamespace(
|
||||
app_id="app-1",
|
||||
@@ -111,7 +112,7 @@ class TestWorkflowHumanInputFormApi:
|
||||
|
||||
service_mock.ensure_form_active.assert_not_called()
|
||||
|
||||
def test_post_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_post_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
form = SimpleNamespace(
|
||||
app_id="app-1",
|
||||
tenant_id="tenant-1",
|
||||
@@ -155,7 +156,7 @@ class TestWorkflowHumanInputFormApi:
|
||||
],
|
||||
)
|
||||
def test_post_rejects_non_service_api_recipient_types(
|
||||
self, app, monkeypatch: pytest.MonkeyPatch, recipient_type: RecipientType
|
||||
self, app: Flask, monkeypatch: pytest.MonkeyPatch, recipient_type: RecipientType
|
||||
) -> None:
|
||||
form = SimpleNamespace(
|
||||
app_id="app-1",
|
||||
|
||||
@@ -381,7 +381,7 @@ class TestMessageService:
|
||||
|
||||
|
||||
class TestMessageListApi:
|
||||
def test_not_chat_app(self, app) -> None:
|
||||
def test_not_chat_app(self, app: Flask) -> None:
|
||||
api = MessageListApi()
|
||||
handler = _unwrap(api.get)
|
||||
app_model = SimpleNamespace(mode=AppMode.COMPLETION.value)
|
||||
@@ -467,7 +467,7 @@ class TestAppGetFeedbacksApi:
|
||||
|
||||
|
||||
class TestMessageSuggestedApi:
|
||||
def test_not_chat(self, app) -> None:
|
||||
def test_not_chat(self, app: Flask) -> None:
|
||||
api = MessageSuggestedApi()
|
||||
handler = _unwrap(api.get)
|
||||
app_model = SimpleNamespace(mode=AppMode.COMPLETION.value)
|
||||
|
||||
@@ -32,7 +32,7 @@ def _mock_repo_for_run(monkeypatch: pytest.MonkeyPatch, workflow_run):
|
||||
|
||||
|
||||
class TestWorkflowEventsApi:
|
||||
def test_wrong_app_mode(self, app) -> None:
|
||||
def test_wrong_app_mode(self, app: Flask) -> None:
|
||||
api = WorkflowEventsApi()
|
||||
handler = _unwrap(api.get)
|
||||
app_model = SimpleNamespace(mode=AppMode.CHAT.value)
|
||||
|
||||
@@ -19,6 +19,7 @@ import uuid
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from controllers.service_api.dataset.document import (
|
||||
@@ -550,7 +551,7 @@ class TestDocumentApiGet:
|
||||
@patch("controllers.service_api.dataset.document.DatasetService")
|
||||
@patch("controllers.service_api.dataset.document.DocumentService")
|
||||
def test_get_document_success_with_all_metadata(
|
||||
self, mock_doc_svc, mock_dataset_svc, app, mock_tenant, mock_doc_detail
|
||||
self, mock_doc_svc, mock_dataset_svc, app: Flask, mock_tenant, mock_doc_detail
|
||||
):
|
||||
"""Test successful document retrieval with metadata='all'."""
|
||||
# Arrange
|
||||
@@ -579,7 +580,7 @@ class TestDocumentApiGet:
|
||||
assert "doc_metadata" in response
|
||||
|
||||
@patch("controllers.service_api.dataset.document.DocumentService")
|
||||
def test_get_document_not_found(self, mock_doc_svc, app, mock_tenant):
|
||||
def test_get_document_not_found(self, mock_doc_svc, app: Flask, mock_tenant):
|
||||
"""Test 404 when document is not found."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid.uuid4())
|
||||
@@ -599,7 +600,7 @@ class TestDocumentApiGet:
|
||||
api.get(tenant_id=mock_tenant.id, dataset_id=dataset_id, document_id="nonexistent")
|
||||
|
||||
@patch("controllers.service_api.dataset.document.DocumentService")
|
||||
def test_get_document_forbidden_wrong_tenant(self, mock_doc_svc, app, mock_tenant, mock_doc_detail):
|
||||
def test_get_document_forbidden_wrong_tenant(self, mock_doc_svc, app: Flask, mock_tenant, mock_doc_detail):
|
||||
"""Test 403 when document tenant doesn't match request tenant."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid.uuid4())
|
||||
@@ -620,7 +621,7 @@ class TestDocumentApiGet:
|
||||
api.get(tenant_id=mock_tenant.id, dataset_id=dataset_id, document_id=mock_doc_detail.id)
|
||||
|
||||
@patch("controllers.service_api.dataset.document.DocumentService")
|
||||
def test_get_document_metadata_only(self, mock_doc_svc, app, mock_tenant, mock_doc_detail):
|
||||
def test_get_document_metadata_only(self, mock_doc_svc, app: Flask, mock_tenant, mock_doc_detail):
|
||||
"""Test document retrieval with metadata='only'."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid.uuid4())
|
||||
@@ -647,7 +648,9 @@ class TestDocumentApiGet:
|
||||
|
||||
@patch("controllers.service_api.dataset.document.DatasetService")
|
||||
@patch("controllers.service_api.dataset.document.DocumentService")
|
||||
def test_get_document_metadata_without(self, mock_doc_svc, mock_dataset_svc, app, mock_tenant, mock_doc_detail):
|
||||
def test_get_document_metadata_without(
|
||||
self, mock_doc_svc, mock_dataset_svc, app: Flask, mock_tenant, mock_doc_detail
|
||||
):
|
||||
"""Test document retrieval with metadata='without'."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid.uuid4())
|
||||
@@ -674,7 +677,7 @@ class TestDocumentApiGet:
|
||||
assert "name" in response
|
||||
|
||||
@patch("controllers.service_api.dataset.document.DocumentService")
|
||||
def test_get_document_invalid_metadata_value(self, mock_doc_svc, app, mock_tenant, mock_doc_detail):
|
||||
def test_get_document_invalid_metadata_value(self, mock_doc_svc, app: Flask, mock_tenant, mock_doc_detail):
|
||||
"""Test error when metadata parameter has invalid value."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid.uuid4())
|
||||
@@ -713,7 +716,7 @@ class TestDocumentApiDelete:
|
||||
|
||||
@patch("controllers.service_api.dataset.document.DocumentService")
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
def test_delete_document_success(self, mock_db, mock_doc_svc, app, mock_tenant, mock_document):
|
||||
def test_delete_document_success(self, mock_db, mock_doc_svc, app: Flask, mock_tenant, mock_document):
|
||||
"""Test successful document deletion."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid.uuid4())
|
||||
@@ -741,7 +744,7 @@ class TestDocumentApiDelete:
|
||||
|
||||
@patch("controllers.service_api.dataset.document.DocumentService")
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
def test_delete_document_not_found(self, mock_db, mock_doc_svc, app, mock_tenant):
|
||||
def test_delete_document_not_found(self, mock_db, mock_doc_svc, app: Flask, mock_tenant):
|
||||
"""Test 404 when document not found."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid.uuid4())
|
||||
@@ -763,7 +766,7 @@ class TestDocumentApiDelete:
|
||||
|
||||
@patch("controllers.service_api.dataset.document.DocumentService")
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
def test_delete_document_archived_forbidden(self, mock_db, mock_doc_svc, app, mock_tenant, mock_document):
|
||||
def test_delete_document_archived_forbidden(self, mock_db, mock_doc_svc, app: Flask, mock_tenant, mock_document):
|
||||
"""Test ArchivedDocumentImmutableError when deleting archived document."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid.uuid4())
|
||||
@@ -785,7 +788,7 @@ class TestDocumentApiDelete:
|
||||
|
||||
@patch("controllers.service_api.dataset.document.DocumentService")
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
def test_delete_document_dataset_not_found(self, mock_db, mock_doc_svc, app, mock_tenant):
|
||||
def test_delete_document_dataset_not_found(self, mock_db, mock_doc_svc, app: Flask, mock_tenant):
|
||||
"""Test ValueError when dataset not found."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid.uuid4())
|
||||
@@ -808,7 +811,7 @@ class TestDocumentListApi:
|
||||
@patch("controllers.service_api.dataset.document.marshal")
|
||||
@patch("controllers.service_api.dataset.document.DocumentService")
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
def test_list_documents_success(self, mock_db, mock_doc_svc, mock_marshal, app, mock_tenant, mock_dataset):
|
||||
def test_list_documents_success(self, mock_db, mock_doc_svc, mock_marshal, app: Flask, mock_tenant, mock_dataset):
|
||||
"""Test successful document list retrieval."""
|
||||
# Arrange
|
||||
mock_db.session.scalar.return_value = mock_dataset
|
||||
@@ -837,7 +840,7 @@ class TestDocumentListApi:
|
||||
assert response["total"] == 2
|
||||
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
def test_list_documents_dataset_not_found(self, mock_db, app, mock_tenant, mock_dataset):
|
||||
def test_list_documents_dataset_not_found(self, mock_db, app: Flask, mock_tenant, mock_dataset):
|
||||
"""Test 404 when dataset not found."""
|
||||
# Arrange
|
||||
mock_db.session.scalar.return_value = None
|
||||
@@ -858,7 +861,9 @@ class TestDocumentIndexingStatusApi:
|
||||
@patch("controllers.service_api.dataset.document.marshal")
|
||||
@patch("controllers.service_api.dataset.document.DocumentService")
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
def test_get_indexing_status_success(self, mock_db, mock_doc_svc, mock_marshal, app, mock_tenant, mock_dataset):
|
||||
def test_get_indexing_status_success(
|
||||
self, mock_db, mock_doc_svc, mock_marshal, app: Flask, mock_tenant, mock_dataset
|
||||
):
|
||||
"""Test successful indexing status retrieval."""
|
||||
# Arrange
|
||||
batch_id = "batch_123"
|
||||
@@ -894,7 +899,7 @@ class TestDocumentIndexingStatusApi:
|
||||
assert len(response["data"]) == 1
|
||||
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
def test_get_indexing_status_dataset_not_found(self, mock_db, app, mock_tenant, mock_dataset):
|
||||
def test_get_indexing_status_dataset_not_found(self, mock_db, app: Flask, mock_tenant, mock_dataset):
|
||||
"""Test 404 when dataset not found."""
|
||||
# Arrange
|
||||
batch_id = "batch_123"
|
||||
@@ -911,7 +916,9 @@ class TestDocumentIndexingStatusApi:
|
||||
|
||||
@patch("controllers.service_api.dataset.document.DocumentService")
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
def test_get_indexing_status_documents_not_found(self, mock_db, mock_doc_svc, app, mock_tenant, mock_dataset):
|
||||
def test_get_indexing_status_documents_not_found(
|
||||
self, mock_db, mock_doc_svc, app: Flask, mock_tenant, mock_dataset
|
||||
):
|
||||
"""Test 404 when no documents found for batch."""
|
||||
# Arrange
|
||||
batch_id = "batch_empty"
|
||||
@@ -978,7 +985,7 @@ class TestDocumentAddByTextApi:
|
||||
mock_knowledge_config,
|
||||
mock_doc_svc,
|
||||
mock_marshal,
|
||||
app,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
):
|
||||
@@ -1029,7 +1036,7 @@ class TestDocumentAddByTextApi:
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
def test_create_document_dataset_not_found(
|
||||
self, mock_db, mock_validate_token, mock_feature_svc, app, mock_tenant, mock_dataset
|
||||
self, mock_db, mock_validate_token, mock_feature_svc, app: Flask, mock_tenant, mock_dataset
|
||||
):
|
||||
"""Test ValueError when dataset not found."""
|
||||
# Arrange — neutralise billing decorators
|
||||
@@ -1052,7 +1059,7 @@ class TestDocumentAddByTextApi:
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
def test_create_document_missing_indexing_technique(
|
||||
self, mock_db, mock_validate_token, mock_feature_svc, app, mock_tenant, mock_dataset
|
||||
self, mock_db, mock_validate_token, mock_feature_svc, app: Flask, mock_tenant, mock_dataset
|
||||
):
|
||||
"""Test error when both dataset and payload lack indexing_technique.
|
||||
|
||||
@@ -1161,7 +1168,7 @@ class TestDocumentUpdateByTextApiPost:
|
||||
mock_file_svc_cls,
|
||||
mock_doc_svc,
|
||||
mock_marshal,
|
||||
app,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
):
|
||||
@@ -1206,7 +1213,7 @@ class TestDocumentUpdateByTextApiPost:
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_db,
|
||||
app,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
):
|
||||
@@ -1245,7 +1252,7 @@ class TestDocumentAddByFileApiPost:
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_db,
|
||||
app,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
):
|
||||
@@ -1275,7 +1282,7 @@ class TestDocumentAddByFileApiPost:
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_db,
|
||||
app,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
):
|
||||
@@ -1306,7 +1313,7 @@ class TestDocumentAddByFileApiPost:
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_db,
|
||||
app,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
):
|
||||
@@ -1338,7 +1345,7 @@ class TestDocumentAddByFileApiPost:
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_db,
|
||||
app,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
):
|
||||
@@ -1381,7 +1388,7 @@ class TestDocumentUpdateByFileApiPatch:
|
||||
mock_feature_svc,
|
||||
mock_update_document_by_file,
|
||||
route_name,
|
||||
app,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
):
|
||||
@@ -1418,7 +1425,7 @@ class TestDocumentUpdateByFileApiPatch:
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_db,
|
||||
app,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
):
|
||||
@@ -1453,7 +1460,7 @@ class TestDocumentUpdateByFileApiPatch:
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_db,
|
||||
app,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
):
|
||||
@@ -1497,7 +1504,7 @@ class TestDocumentUpdateByFileApiPatch:
|
||||
mock_file_svc_cls,
|
||||
mock_doc_svc,
|
||||
mock_marshal,
|
||||
app,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
):
|
||||
|
||||
@@ -18,6 +18,7 @@ import uuid
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
import services
|
||||
@@ -91,7 +92,7 @@ class TestHitTestingApiPost:
|
||||
mock_hit_svc,
|
||||
mock_marshal,
|
||||
mock_ns,
|
||||
app,
|
||||
app: Flask,
|
||||
):
|
||||
"""Test successful hit testing request."""
|
||||
dataset_id = str(uuid.uuid4())
|
||||
@@ -129,7 +130,7 @@ class TestHitTestingApiPost:
|
||||
mock_hit_svc,
|
||||
mock_marshal,
|
||||
mock_ns,
|
||||
app,
|
||||
app: Flask,
|
||||
):
|
||||
"""Test hit testing with custom retrieval model."""
|
||||
dataset_id = str(uuid.uuid4())
|
||||
@@ -183,7 +184,7 @@ class TestHitTestingApiPost:
|
||||
mock_hit_svc,
|
||||
mock_marshal,
|
||||
mock_ns,
|
||||
app,
|
||||
app: Flask,
|
||||
):
|
||||
"""Service API retrieval payload should not drop metadata filters."""
|
||||
dataset_id = str(uuid.uuid4())
|
||||
@@ -239,7 +240,7 @@ class TestHitTestingApiPost:
|
||||
mock_hit_svc,
|
||||
mock_marshal,
|
||||
mock_ns,
|
||||
app,
|
||||
app: Flask,
|
||||
):
|
||||
"""Test service API prepares nullable list fields from marshalled records."""
|
||||
dataset_id = str(uuid.uuid4())
|
||||
@@ -286,7 +287,7 @@ class TestHitTestingApiPost:
|
||||
mock_current_user,
|
||||
mock_dataset_svc,
|
||||
mock_ns,
|
||||
app,
|
||||
app: Flask,
|
||||
):
|
||||
"""Test hit testing with non-existent dataset."""
|
||||
dataset_id = str(uuid.uuid4())
|
||||
@@ -308,7 +309,7 @@ class TestHitTestingApiPost:
|
||||
mock_current_user,
|
||||
mock_dataset_svc,
|
||||
mock_ns,
|
||||
app,
|
||||
app: Flask,
|
||||
):
|
||||
"""Test hit testing when user lacks dataset permission."""
|
||||
dataset_id = str(uuid.uuid4())
|
||||
|
||||
@@ -54,7 +54,7 @@ class TestIndexApi:
|
||||
assert isinstance(response["server_version"], str)
|
||||
|
||||
@pytest.mark.parametrize("version", ["0.0.1", "1.0.0", "2.0.0-beta", "1.11.4"])
|
||||
def test_get_returns_correct_version(self, app, version):
|
||||
def test_get_returns_correct_version(self, app: Flask, version):
|
||||
"""Test that server_version matches config version."""
|
||||
# Arrange
|
||||
mock_config = MagicMock()
|
||||
|
||||
@@ -44,7 +44,7 @@ class TestEmailCodeLoginSendEmailApi:
|
||||
self,
|
||||
mock_get_user,
|
||||
mock_send_email,
|
||||
app,
|
||||
app: Flask,
|
||||
):
|
||||
mock_account = MagicMock()
|
||||
mock_get_user.return_value = mock_account
|
||||
@@ -75,7 +75,7 @@ class TestEmailCodeLoginApi:
|
||||
mock_get_user,
|
||||
mock_login,
|
||||
mock_reset_login_rate,
|
||||
app,
|
||||
app: Flask,
|
||||
):
|
||||
mock_get_token_data.return_value = {"email": "User@Example.com", "code": "123456"}
|
||||
mock_get_user.return_value = MagicMock()
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from services.plugin.plugin_migration import PluginMigration
|
||||
|
||||
MIGRATION_MODULE = "services.plugin.plugin_migration"
|
||||
|
||||
|
||||
def test_fetch_plugin_unique_identifier_returns_none_when_disabled(mocker: MockerFixture) -> None:
|
||||
mocker.patch("services.plugin.plugin_migration.dify_config.MARKETPLACE_ENABLED", False)
|
||||
batch_fetch = mocker.patch("services.plugin.plugin_migration.marketplace.batch_fetch_plugin_manifests")
|
||||
|
||||
result = PluginMigration._fetch_plugin_unique_identifier("langgenius/openai")
|
||||
|
||||
assert result is None
|
||||
batch_fetch.assert_not_called()
|
||||
|
||||
|
||||
def test_fetch_plugin_unique_identifier_calls_marketplace_when_enabled(mocker: MockerFixture) -> None:
|
||||
mocker.patch("services.plugin.plugin_migration.dify_config.MARKETPLACE_ENABLED", True)
|
||||
manifest = mocker.MagicMock()
|
||||
manifest.latest_package_identifier = "langgenius/openai:1.0.0@abc"
|
||||
mocker.patch(
|
||||
"services.plugin.plugin_migration.marketplace.batch_fetch_plugin_manifests",
|
||||
return_value=[manifest],
|
||||
)
|
||||
|
||||
result = PluginMigration._fetch_plugin_unique_identifier("langgenius/openai")
|
||||
|
||||
assert result == "langgenius/openai:1.0.0@abc"
|
||||
|
||||
|
||||
class TestHandlePluginInstanceInstall:
|
||||
def test_raises_when_disabled_and_map_nonempty(self) -> None:
|
||||
with patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg:
|
||||
mock_cfg.MARKETPLACE_ENABLED = False
|
||||
|
||||
with pytest.raises(ValueError, match="Marketplace disabled"):
|
||||
PluginMigration.handle_plugin_instance_install(
|
||||
"tenant1", {"langgenius/openai": "langgenius/openai:1.0.0@abc"}
|
||||
)
|
||||
|
||||
def test_no_raise_when_disabled_and_map_empty(self) -> None:
|
||||
with (
|
||||
patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = False
|
||||
mock_installer = MagicMock()
|
||||
mock_installer_cls.return_value = mock_installer
|
||||
mock_installer.install_from_identifiers.return_value = MagicMock(all_installed=True)
|
||||
|
||||
result = PluginMigration.handle_plugin_instance_install("tenant1", {})
|
||||
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_proceeds_when_enabled(self) -> None:
|
||||
with (
|
||||
patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MIGRATION_MODULE}.marketplace") as mock_marketplace,
|
||||
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = True
|
||||
mock_marketplace.download_plugin_pkg.return_value = b"pkg_data"
|
||||
mock_installer = MagicMock()
|
||||
mock_installer_cls.return_value = mock_installer
|
||||
mock_installer.install_from_identifiers.return_value = MagicMock(all_installed=True)
|
||||
|
||||
result = PluginMigration.handle_plugin_instance_install(
|
||||
"tenant1", {"langgenius/openai": "langgenius/openai:1.0.0@abc"}
|
||||
)
|
||||
|
||||
mock_marketplace.download_plugin_pkg.assert_called_once()
|
||||
assert "success" in result or "failed" in result
|
||||
@@ -0,0 +1,50 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
MODULE = "services.plugin.plugin_service"
|
||||
|
||||
|
||||
class TestFetchLatestPluginVersion:
|
||||
def test_skips_marketplace_fetch_when_disabled(self) -> None:
|
||||
"""Cache misses stay None; marketplace is never called when disabled."""
|
||||
with (
|
||||
patch(f"{MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MODULE}.redis_client") as mock_redis,
|
||||
patch(f"{MODULE}.marketplace") as mock_marketplace,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = False
|
||||
mock_redis.get.return_value = None # all cache misses
|
||||
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_latest_plugin_version(["langgenius/openai", "langgenius/anthropic"])
|
||||
|
||||
mock_marketplace.batch_fetch_plugin_manifests.assert_not_called()
|
||||
assert result == {"langgenius/openai": None, "langgenius/anthropic": None}
|
||||
|
||||
def test_calls_marketplace_fetch_when_enabled(self) -> None:
|
||||
"""Cache misses trigger marketplace fetch when enabled."""
|
||||
manifest = MagicMock()
|
||||
manifest.plugin_id = "langgenius/openai"
|
||||
manifest.latest_version = "1.0.0"
|
||||
manifest.latest_package_identifier = "langgenius/openai:1.0.0@abc"
|
||||
manifest.status = "active"
|
||||
manifest.deprecated_reason = ""
|
||||
manifest.alternative_plugin_id = ""
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MODULE}.redis_client") as mock_redis,
|
||||
patch(f"{MODULE}.marketplace") as mock_marketplace,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = True
|
||||
mock_redis.get.return_value = None
|
||||
mock_marketplace.batch_fetch_plugin_manifests.return_value = [manifest]
|
||||
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_latest_plugin_version(["langgenius/openai"])
|
||||
|
||||
# The list arg is mutated by remove() after the call, so check call count + result.
|
||||
mock_marketplace.batch_fetch_plugin_manifests.assert_called_once()
|
||||
assert result["langgenius/openai"] is not None
|
||||
assert result["langgenius/openai"].version == "1.0.0"
|
||||
@@ -0,0 +1,36 @@
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from services.rag_pipeline.rag_pipeline import RagPipelineService
|
||||
|
||||
|
||||
def _make_service() -> RagPipelineService:
|
||||
return RagPipelineService.__new__(RagPipelineService)
|
||||
|
||||
|
||||
def test_fetch_recommended_plugin_manifests_returns_empty_when_disabled(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.MARKETPLACE_ENABLED", False)
|
||||
batch_fetch = mocker.patch("services.rag_pipeline.rag_pipeline.marketplace.batch_fetch_plugin_by_ids")
|
||||
|
||||
service = _make_service()
|
||||
result = service._fetch_recommended_plugin_manifests(["langgenius/openai"])
|
||||
|
||||
assert result == []
|
||||
batch_fetch.assert_not_called()
|
||||
|
||||
|
||||
def test_fetch_recommended_plugin_manifests_returns_data_when_enabled(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.MARKETPLACE_ENABLED", True)
|
||||
expected = [{"plugin_id": "langgenius/openai", "name": "OpenAI"}]
|
||||
mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline.marketplace.batch_fetch_plugin_by_ids",
|
||||
return_value=expected,
|
||||
)
|
||||
|
||||
service = _make_service()
|
||||
result = service._fetch_recommended_plugin_manifests(["langgenius/openai"])
|
||||
|
||||
assert result == expected
|
||||
@@ -1,8 +1,10 @@
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from models.dataset import Dataset
|
||||
from services.entities.knowledge_entities.rag_pipeline_entities import KnowledgeConfiguration
|
||||
@@ -514,3 +516,64 @@ def test_deal_document_data_upload_file_with_existing_file(mocker) -> None:
|
||||
assert document.data_source_type == "local_file"
|
||||
assert "real_file_id" in document.data_source_info
|
||||
assert add_mock.call_count >= 2
|
||||
|
||||
|
||||
def _make_service():
|
||||
return RagPipelineTransformService.__new__(RagPipelineTransformService)
|
||||
|
||||
|
||||
def test_deal_dependencies_skips_marketplace_when_disabled(mocker: MockerFixture, caplog) -> None:
|
||||
mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.dify_config.MARKETPLACE_ENABLED",
|
||||
False,
|
||||
)
|
||||
installer = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller").return_value
|
||||
installer.list_plugins.return_value = []
|
||||
mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration")
|
||||
install_call = mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.PluginService.install_from_marketplace_pkg"
|
||||
)
|
||||
|
||||
pipeline_yaml = {
|
||||
"dependencies": [
|
||||
{
|
||||
"type": "marketplace",
|
||||
"value": {"plugin_unique_identifier": "langgenius/openai:1.0.0@abc"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
service = _make_service()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
service._deal_dependencies(pipeline_yaml, "tenant-1")
|
||||
|
||||
install_call.assert_not_called()
|
||||
assert any("Marketplace disabled" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_deal_dependencies_installs_when_enabled(mocker: MockerFixture) -> None:
|
||||
mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.dify_config.MARKETPLACE_ENABLED",
|
||||
True,
|
||||
)
|
||||
installer = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller").return_value
|
||||
installer.list_plugins.return_value = []
|
||||
migration = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration").return_value
|
||||
migration._fetch_plugin_unique_identifier.return_value = "langgenius/openai:1.0.0@abc"
|
||||
install_call = mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.PluginService.install_from_marketplace_pkg"
|
||||
)
|
||||
|
||||
pipeline_yaml = {
|
||||
"dependencies": [
|
||||
{
|
||||
"type": "marketplace",
|
||||
"value": {"plugin_unique_identifier": "langgenius/openai:1.0.0@abc"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
service = _make_service()
|
||||
service._deal_dependencies(pipeline_yaml, "tenant-1")
|
||||
|
||||
install_call.assert_called_once_with("tenant-1", ["langgenius/openai:1.0.0@abc"])
|
||||
|
||||
Generated
+1
-1
@@ -1323,7 +1323,7 @@ docs = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.14.1"
|
||||
version = "1.14.2"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aliyun-log-python-sdk" },
|
||||
|
||||
+28
-26
@@ -5,7 +5,7 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
|
||||
### What's Updated
|
||||
|
||||
- **Certbot Container**: `docker-compose.yaml` now contains `certbot` for managing SSL certificates. This container automatically renews certificates and ensures secure HTTPS connections.\
|
||||
For more information, refer `docker/certbot/README.md`.
|
||||
For more information, refer to `docker/certbot/README.md`.
|
||||
|
||||
- **Persistent Environment Variables**: Essential startup defaults are provided in `.env.example`, while local values are stored in `.env`, ensuring that your configurations persist across deployments.
|
||||
|
||||
@@ -17,26 +17,26 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
|
||||
### How to Deploy Dify with `docker-compose.yaml`
|
||||
|
||||
1. **Prerequisites**: Ensure Docker and Docker Compose are installed on your system.
|
||||
1. **Environment Setup**:
|
||||
2. **Environment Setup**:
|
||||
- Navigate to the `docker` directory.
|
||||
- Copy `.env.example` to `.env`.
|
||||
- Customize `.env` when you need to change essential startup defaults. Copy optional files from `envs/` without the `.example` suffix when you need advanced settings.
|
||||
- **Optional (for advanced deployments)**:
|
||||
If you maintain a full `.env` file copied from `.env.example`, you may use the environment synchronization tool to keep it aligned with the latest `.env.example` updates while preserving your custom settings.
|
||||
See the [Environment Variables Synchronization](#environment-variables-synchronization) section below.
|
||||
1. **Running the Services**:
|
||||
3. **Running the Services**:
|
||||
- Execute `docker compose up -d` from the `docker` directory to start the services.
|
||||
- To specify a vector database, set the `VECTOR_STORE` variable in your `.env` file to your desired vector database service, such as `milvus`, `weaviate`, or `opensearch`.
|
||||
- To specify a vector database, set the `VECTOR_STORE` variable in your `.env` file to your desired vector database service, such as `milvus`, `weaviate`, or `opensearch`. See `envs/vectorstores/` for the full list of supported options.
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
1. **SSL Certificate Setup**:
|
||||
- Refer `docker/certbot/README.md` to set up SSL certificates using Certbot.
|
||||
1. **OpenTelemetry Collector Setup**:
|
||||
- Change `ENABLE_OTEL` to `true` in `.env`.
|
||||
- Configure `OTLP_BASE_ENDPOINT` properly.
|
||||
4. **SSL Certificate Setup**:
|
||||
- Refer to `docker/certbot/README.md` to set up SSL certificates using Certbot.
|
||||
5. **OpenTelemetry Collector Setup**:
|
||||
- Copy `envs/core-services/shared.env.example` to `envs/core-services/shared.env`.
|
||||
- Set `ENABLE_OTEL=true` and configure `OTLP_BASE_ENDPOINT`. Tune the other `OTEL_*` knobs in the same file if needed.
|
||||
|
||||
### How to Deploy Middleware for Developing Dify
|
||||
|
||||
@@ -44,7 +44,7 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
|
||||
- Use the `docker-compose.middleware.yaml` for setting up essential middleware services like databases and caches.
|
||||
- Navigate to the `docker` directory.
|
||||
- Ensure the `middleware.env` file is created by running `cp envs/middleware.env.example middleware.env` (refer to the `envs/middleware.env.example` file).
|
||||
1. **Running Middleware Services**:
|
||||
2. **Running Middleware Services**:
|
||||
- Navigate to the `docker` directory.
|
||||
- Execute `docker compose --env-file middleware.env -f docker-compose.middleware.yaml -p dify up -d` to start PostgreSQL/MySQL (per `DB_TYPE`) plus the bundled Weaviate instance.
|
||||
|
||||
@@ -55,9 +55,9 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
|
||||
For users migrating from the `docker-legacy` setup:
|
||||
|
||||
1. **Review Changes**: Familiarize yourself with the new `.env` configuration and Docker Compose setup.
|
||||
1. **Transfer Customizations**:
|
||||
2. **Transfer Customizations**:
|
||||
- If you have customized configurations such as `docker-compose.yaml`, `ssrf_proxy/squid.conf`, or `nginx/conf.d/default.conf`, you will need to reflect these changes in the `.env` file you create.
|
||||
1. **Data Migration**:
|
||||
3. **Data Migration**:
|
||||
- Ensure that data from services like databases and caches is backed up and migrated appropriately to the new structure if necessary.
|
||||
|
||||
### Overview of `.env`, `.env.example`, and `envs/`
|
||||
@@ -80,49 +80,51 @@ The root `.env.example` file contains the essential startup settings. Optional a
|
||||
|
||||
1. **Common Variables**:
|
||||
|
||||
- `CONSOLE_API_URL`, `SERVICE_API_URL`: URLs for different API services.
|
||||
- `APP_WEB_URL`: Frontend application URL.
|
||||
- `FILES_URL`: Base URL for file downloads and previews.
|
||||
- `CONSOLE_API_URL`, `CONSOLE_WEB_URL`, `SERVICE_API_URL`, `APP_API_URL`, `APP_WEB_URL`: URLs for the API and frontend services.
|
||||
- `FILES_URL`, `INTERNAL_FILES_URL`: Public and internal base URLs for file downloads and previews.
|
||||
- `ENDPOINT_URL_TEMPLATE`, `NEXT_PUBLIC_SOCKET_URL`, `TRIGGER_URL`: Additional service URLs.
|
||||
|
||||
See `.env.example` for the full list.
|
||||
|
||||
1. **Server Configuration**:
|
||||
2. **Server Configuration**:
|
||||
|
||||
- `LOG_LEVEL`, `DEBUG`, `FLASK_DEBUG`: Logging and debug settings.
|
||||
- `SECRET_KEY`: A key for signing sessions, JWTs, and file URLs. Leave it empty to let Dify generate a persistent key in the storage directory, or set a unique value yourself.
|
||||
|
||||
1. **Database Configuration**:
|
||||
3. **Database Configuration**:
|
||||
|
||||
- `DB_USERNAME`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`, `DB_DATABASE`: PostgreSQL database credentials and connection details.
|
||||
|
||||
1. **Redis Configuration**:
|
||||
4. **Redis Configuration**:
|
||||
|
||||
- `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`: Redis server connection settings.
|
||||
- `REDIS_KEY_PREFIX`: Optional global namespace prefix for Redis keys, topics, streams, and Celery Redis transport artifacts.
|
||||
|
||||
1. **Celery Configuration**:
|
||||
5. **Celery Configuration**:
|
||||
|
||||
- `CELERY_BROKER_URL`: Configuration for Celery message broker.
|
||||
|
||||
1. **Storage Configuration**:
|
||||
6. **Storage Configuration**:
|
||||
|
||||
- `STORAGE_TYPE`, `OPENDAL_SCHEME`, `OPENDAL_FS_ROOT`: Default local file storage settings. Optional storage backends are configured from the files under `envs/`.
|
||||
|
||||
1. **Vector Database Configuration**:
|
||||
7. **Vector Database Configuration**:
|
||||
|
||||
- `VECTOR_STORE`: Type of vector database (e.g., `weaviate`, `milvus`).
|
||||
- `VECTOR_STORE`: Type of vector database (e.g., `weaviate`, `milvus`). See `envs/vectorstores/` for the full list of supported options.
|
||||
- Specific settings for each vector store like `WEAVIATE_ENDPOINT`, `MILVUS_URI`.
|
||||
|
||||
1. **CORS Configuration**:
|
||||
8. **CORS Configuration**:
|
||||
|
||||
- `WEB_API_CORS_ALLOW_ORIGINS`, `CONSOLE_CORS_ALLOW_ORIGINS`: Settings for cross-origin resource sharing.
|
||||
|
||||
1. **OpenTelemetry Configuration**:
|
||||
9. **OpenTelemetry Configuration**:
|
||||
|
||||
- `ENABLE_OTEL`: Enable OpenTelemetry collector in api.
|
||||
- `OTLP_BASE_ENDPOINT`: Endpoint for your OTLP exporter.
|
||||
|
||||
1. **Other Service-Specific Environment Variables**:
|
||||
10. **Other Service-Specific Environment Variables**:
|
||||
|
||||
- Each service like `nginx`, `redis`, `db`, and vector databases have specific environment variables that are directly referenced in the `docker-compose.yaml`.
|
||||
- Each service like `nginx`, `redis`, `db`, and vector databases have specific environment variables that are directly referenced in the `docker-compose.yaml`.
|
||||
|
||||
### Environment Variables Synchronization
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ services:
|
||||
# API service
|
||||
api:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.2
|
||||
environment:
|
||||
MODE: api
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -264,7 +264,7 @@ services:
|
||||
# WebSocket service for workflow collaboration.
|
||||
api_websocket:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.2
|
||||
profiles:
|
||||
- collaboration
|
||||
environment:
|
||||
@@ -290,7 +290,7 @@ services:
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
<<: *shared-worker-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.2
|
||||
environment:
|
||||
MODE: worker
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -333,7 +333,7 @@ services:
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
<<: *shared-worker-beat-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.2
|
||||
environment:
|
||||
MODE: beat
|
||||
depends_on:
|
||||
@@ -366,7 +366,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.14.1
|
||||
image: langgenius/dify-web:1.14.2
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/web.env
|
||||
|
||||
@@ -226,7 +226,7 @@ services:
|
||||
# API service
|
||||
api:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.2
|
||||
environment:
|
||||
MODE: api
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -270,7 +270,7 @@ services:
|
||||
# WebSocket service for workflow collaboration.
|
||||
api_websocket:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.2
|
||||
profiles:
|
||||
- collaboration
|
||||
environment:
|
||||
@@ -296,7 +296,7 @@ services:
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
<<: *shared-worker-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.2
|
||||
environment:
|
||||
MODE: worker
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -339,7 +339,7 @@ services:
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
<<: *shared-worker-beat-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.2
|
||||
environment:
|
||||
MODE: beat
|
||||
depends_on:
|
||||
@@ -372,7 +372,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.14.1
|
||||
image: langgenius/dify-web:1.14.2
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/web.env
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
|
||||
Snapshot generated from `packages/contracts/generated/api/readiness.json` after running `pnpm -C packages/contracts gen-api-contract-from-openapi`.
|
||||
|
||||
Are we OpenAPI ready? **No.** Current generated API contracts are **16.6% ready**.
|
||||
Are we OpenAPI ready? **No.** Current generated API contracts are **16.7% ready**.
|
||||
|
||||
| Surface | Ready | Not ready | Total | Ready % |
|
||||
| --------- | ------: | --------: | ------: | --------: |
|
||||
| console | 95 | 475 | 570 | 16.7% |
|
||||
| console | 96 | 474 | 570 | 16.8% |
|
||||
| service | 16 | 72 | 88 | 18.2% |
|
||||
| web | 5 | 36 | 41 | 12.2% |
|
||||
| **total** | **116** | **583** | **699** | **16.6%** |
|
||||
| **total** | **117** | **582** | **699** | **16.7%** |
|
||||
|
||||
Readiness here means the generated contract operation is not marked with:
|
||||
|
||||
|
||||
@@ -426,16 +426,10 @@ export const imports = {
|
||||
|
||||
/**
|
||||
* Get workflow online users
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const post3 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Get workflow online users\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
description: 'Get workflow online users',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAppsWorkflowsOnlineUsers',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"surfaces": {
|
||||
"console": {
|
||||
"notReady": 475,
|
||||
"notReady": 474,
|
||||
"total": 570
|
||||
},
|
||||
"service": {
|
||||
|
||||
@@ -236,8 +236,8 @@ describe('Explore App List Flow', () => {
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => {
|
||||
options.onPending?.()
|
||||
})
|
||||
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
|
||||
})
|
||||
|
||||
renderAppList(true, onSuccess)
|
||||
|
||||
@@ -247,7 +247,9 @@ describe('Apps', () => {
|
||||
})
|
||||
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({
|
||||
source: 'studio_template_list',
|
||||
appMode: AppModeEnum.CHAT,
|
||||
templateId: 'Alpha',
|
||||
})
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated')
|
||||
expect(onSuccess).toHaveBeenCalled()
|
||||
|
||||
@@ -127,7 +127,7 @@ const Apps = ({
|
||||
icon_background,
|
||||
description,
|
||||
})
|
||||
trackCreateApp({ appMode: mode })
|
||||
trackCreateApp({ source: 'studio_template_list', appMode: mode, templateId: currApp?.app_id })
|
||||
|
||||
setIsShowCreateModal(false)
|
||||
toast.success(t('newApp.appCreated', { ns: 'app' }))
|
||||
|
||||
@@ -170,7 +170,7 @@ describe('CreateAppModal', () => {
|
||||
mode: AppModeEnum.ADVANCED_CHAT,
|
||||
}))
|
||||
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.ADVANCED_CHAT })
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_blank', appMode: AppModeEnum.ADVANCED_CHAT })
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated')
|
||||
expect(onSuccess).toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
|
||||
@@ -79,7 +79,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
||||
mode: appMode,
|
||||
})
|
||||
|
||||
trackCreateApp({ appMode: app.mode })
|
||||
trackCreateApp({ source: 'studio_blank', appMode: app.mode })
|
||||
|
||||
toast.success(t('newApp.appCreated', { ns: 'app' }))
|
||||
onSuccess()
|
||||
|
||||
@@ -197,7 +197,7 @@ describe('CreateFromDSLModal', () => {
|
||||
mode: DSLImportMode.YAML_URL,
|
||||
yaml_url: 'https://example.com/app.yml',
|
||||
})
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.CHAT })
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_upload', appMode: AppModeEnum.CHAT })
|
||||
expect(handleSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
expect(localStorage.getItem(NEED_REFRESH_APP_LIST_KEY)).toBe('1')
|
||||
@@ -304,7 +304,7 @@ describe('CreateFromDSLModal', () => {
|
||||
expect(mockImportDSLConfirm).toHaveBeenCalledWith({
|
||||
import_id: 'import-3',
|
||||
})
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.WORKFLOW })
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_upload', appMode: AppModeEnum.WORKFLOW })
|
||||
})
|
||||
|
||||
it('should close the DSL mismatch modal when dialog requests close', async () => {
|
||||
|
||||
@@ -110,7 +110,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
return
|
||||
const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version } = response
|
||||
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
|
||||
trackCreateApp({ appMode: app_mode })
|
||||
trackCreateApp({ source: 'studio_upload', appMode: app_mode })
|
||||
|
||||
if (onSuccess)
|
||||
onSuccess()
|
||||
@@ -171,7 +171,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
const { status, app_id, app_mode } = response
|
||||
|
||||
if (status === DSLImportStatus.COMPLETED) {
|
||||
trackCreateApp({ appMode: app_mode })
|
||||
trackCreateApp({ source: 'studio_upload', appMode: app_mode })
|
||||
if (onSuccess)
|
||||
onSuccess()
|
||||
if (onClose)
|
||||
|
||||
@@ -262,8 +262,8 @@ describe('Apps', () => {
|
||||
})
|
||||
|
||||
it('should track template preview creation after a successful import', async () => {
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
|
||||
})
|
||||
|
||||
renderWithClient(<Apps />)
|
||||
@@ -275,7 +275,9 @@ describe('Apps', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAppDetail).toHaveBeenCalledWith('template-1')
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({
|
||||
source: 'studio_template_preview',
|
||||
appMode: AppModeEnum.CHAT,
|
||||
templateId: 'template-1',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -284,8 +286,8 @@ describe('Apps', () => {
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => {
|
||||
options.onPending?.()
|
||||
})
|
||||
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.WORKFLOW })
|
||||
})
|
||||
|
||||
renderWithClient(<Apps />)
|
||||
@@ -299,7 +301,9 @@ describe('Apps', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({
|
||||
appMode: AppModeEnum.CHAT,
|
||||
source: 'studio_template_preview',
|
||||
appMode: AppModeEnum.WORKFLOW,
|
||||
templateId: 'template-1',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -365,8 +369,8 @@ describe('Apps', () => {
|
||||
})
|
||||
|
||||
it('should import DSL from marketplace template on confirm', async () => {
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
|
||||
})
|
||||
mockSearchParams = new URLSearchParams('template-id=tpl-42')
|
||||
renderWithClient(<Apps />)
|
||||
@@ -378,14 +382,22 @@ describe('Apps', () => {
|
||||
{ mode: 'yaml-content', yaml_content: 'yaml-dsl-content' },
|
||||
expect.objectContaining({ onSuccess: expect.any(Function) }),
|
||||
)
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({
|
||||
source: 'external',
|
||||
appMode: AppModeEnum.CHAT,
|
||||
templateId: 'tpl-42',
|
||||
})
|
||||
expect(mockReplace).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show DSL confirm modal when marketplace import is pending', async () => {
|
||||
it('should track marketplace template creation after confirming a pending import', async () => {
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => {
|
||||
options.onPending?.()
|
||||
})
|
||||
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.WORKFLOW })
|
||||
})
|
||||
mockSearchParams = new URLSearchParams('template-id=tpl-42')
|
||||
renderWithClient(<Apps />)
|
||||
|
||||
@@ -395,6 +407,16 @@ describe('Apps', () => {
|
||||
expect(screen.getByTestId('dsl-confirm-modal')).toBeInTheDocument()
|
||||
expect(mockReplace).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-dsl'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({
|
||||
source: 'external',
|
||||
appMode: AppModeEnum.WORKFLOW,
|
||||
templateId: 'tpl-42',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import type { CreateAppModalProps } from '../explore/create-app-modal'
|
||||
import type { TryAppSelection } from '@/types/try-app'
|
||||
import type { TrackCreateAppParams } from '@/utils/create-app-tracking'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEducationInit } from '@/app/education-apply/hooks'
|
||||
@@ -31,6 +32,7 @@ const Apps = () => {
|
||||
|
||||
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
|
||||
const currentCreateAppModeRef = useRef<TryAppSelection['app']['app']['mode'] | null>(null)
|
||||
const currentCreateAppTrackingRef = useRef<Pick<TrackCreateAppParams, 'source' | 'templateId'> | null>(null)
|
||||
const currApp = currentTryAppParams?.app
|
||||
const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false)
|
||||
const hideTryAppPanel = useCallback(() => {
|
||||
@@ -46,13 +48,24 @@ const Apps = () => {
|
||||
const [isShowCreateModal, setIsShowCreateModal] = useState(false)
|
||||
|
||||
const handleShowFromTryApp = useCallback(() => {
|
||||
currentCreateAppTrackingRef.current = {
|
||||
source: 'studio_template_preview',
|
||||
templateId: currentTryAppParams?.appId || currentTryAppParams?.app.app_id,
|
||||
}
|
||||
setIsShowCreateModal(true)
|
||||
}, [])
|
||||
const trackCurrentCreateApp = useCallback(() => {
|
||||
if (!currentCreateAppModeRef.current)
|
||||
}, [currentTryAppParams?.app.app_id, currentTryAppParams?.appId])
|
||||
const trackCurrentCreateApp = useCallback((appMode?: TryAppSelection['app']['app']['mode'] | null) => {
|
||||
const currentCreateAppTracking = currentCreateAppTrackingRef.current
|
||||
const resolvedAppMode = appMode ?? currentCreateAppModeRef.current
|
||||
if (!resolvedAppMode || !currentCreateAppTracking)
|
||||
return
|
||||
|
||||
trackCreateApp({ appMode: currentCreateAppModeRef.current })
|
||||
trackCreateApp({
|
||||
...currentCreateAppTracking,
|
||||
appMode: resolvedAppMode,
|
||||
})
|
||||
currentCreateAppTrackingRef.current = null
|
||||
currentCreateAppModeRef.current = null
|
||||
}, [])
|
||||
|
||||
const [controlRefreshList, setControlRefreshList] = useState(0)
|
||||
@@ -81,19 +94,25 @@ const Apps = () => {
|
||||
|
||||
const onConfirmDSL = useCallback(async () => {
|
||||
await handleImportDSLConfirm({
|
||||
onSuccess: () => {
|
||||
trackCurrentCreateApp()
|
||||
onSuccess: (response) => {
|
||||
trackCurrentCreateApp(response.app_mode)
|
||||
onSuccess()
|
||||
},
|
||||
})
|
||||
}, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp])
|
||||
|
||||
const handleMarketplaceTemplateConfirm = useCallback(async (dslContent: string) => {
|
||||
currentCreateAppModeRef.current = null
|
||||
currentCreateAppTrackingRef.current = {
|
||||
source: 'external',
|
||||
templateId: templateId || undefined,
|
||||
}
|
||||
await handleImportDSL({
|
||||
mode: DSLImportMode.YAML_CONTENT,
|
||||
yaml_content: dslContent,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
onSuccess: (response) => {
|
||||
trackCurrentCreateApp(response.app_mode)
|
||||
handleCloseTemplateModal()
|
||||
onSuccess()
|
||||
},
|
||||
@@ -102,7 +121,7 @@ const Apps = () => {
|
||||
setShowDSLConfirmModal(true)
|
||||
},
|
||||
})
|
||||
}, [handleImportDSL, handleCloseTemplateModal, onSuccess])
|
||||
}, [handleImportDSL, handleCloseTemplateModal, onSuccess, templateId, trackCurrentCreateApp])
|
||||
|
||||
const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({
|
||||
name,
|
||||
@@ -127,8 +146,8 @@ const Apps = () => {
|
||||
description,
|
||||
}
|
||||
await handleImportDSL(payload, {
|
||||
onSuccess: () => {
|
||||
trackCurrentCreateApp()
|
||||
onSuccess: (response) => {
|
||||
trackCurrentCreateApp(response.app_mode)
|
||||
setIsShowCreateModal(false)
|
||||
},
|
||||
onPending: () => {
|
||||
|
||||
+32
-3
@@ -40,7 +40,7 @@ vi.mock('../score-slider', () => ({
|
||||
<input
|
||||
role="slider"
|
||||
type="range"
|
||||
min={80}
|
||||
min={0}
|
||||
max={100}
|
||||
value={value}
|
||||
onChange={e => onChange(Number((e.target as HTMLInputElement).value))}
|
||||
@@ -272,7 +272,7 @@ describe('ConfigParamModal', () => {
|
||||
)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('min', '80')
|
||||
expect(slider).toHaveAttribute('min', '0')
|
||||
expect(slider).toHaveAttribute('max', '100')
|
||||
expect(slider).toHaveValue('90')
|
||||
})
|
||||
@@ -375,7 +375,7 @@ describe('ConfigParamModal', () => {
|
||||
it('should use ANNOTATION_DEFAULT score_threshold when config has no score_threshold', () => {
|
||||
const configWithoutThreshold = {
|
||||
...defaultAnnotationConfig,
|
||||
score_threshold: 0,
|
||||
score_threshold: undefined as unknown as number,
|
||||
}
|
||||
render(
|
||||
<ConfigParamModal
|
||||
@@ -390,6 +390,35 @@ describe('ConfigParamModal', () => {
|
||||
expect(screen.getByRole('slider')).toHaveValue('90')
|
||||
})
|
||||
|
||||
it('should preserve zero score threshold instead of falling back to default', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={onSave}
|
||||
annotationConfig={{
|
||||
...defaultAnnotationConfig,
|
||||
score_threshold: 0,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('slider')).toHaveValue('0')
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
|
||||
fireEvent.click(saveBtn!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ embedding_provider_name: 'openai' }),
|
||||
0,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should set loading state while saving', async () => {
|
||||
let resolveOnSave: () => void
|
||||
const onSave = vi.fn().mockImplementation(() => new Promise<void>((resolve) => {
|
||||
|
||||
+16
@@ -175,6 +175,22 @@ describe('AnnotationReply', () => {
|
||||
expect(screen.getByText('text-embedding-ada-002')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show zero score threshold when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
expect(screen.getByText('text-embedding-ada-002')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show dash when score threshold is not set', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
|
||||
+30
-1
@@ -1,6 +1,6 @@
|
||||
import type { AnnotationReplyConfig } from '@/models/debug'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { queryAnnotationJobStatus } from '@/service/annotation'
|
||||
import { queryAnnotationJobStatus, updateAnnotationStatus } from '@/service/annotation'
|
||||
import { sleep } from '@/utils'
|
||||
import useAnnotationConfig from '../use-annotation-config'
|
||||
|
||||
@@ -162,6 +162,35 @@ describe('useAnnotationConfig', () => {
|
||||
expect(updatedConfig.score_threshold).toBe(0.85)
|
||||
})
|
||||
|
||||
it('should preserve zero score threshold when enabling annotation', async () => {
|
||||
const zeroScoreConfig = { ...defaultConfig, score_threshold: 0 }
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: zeroScoreConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleEnableAnnotation({
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-small',
|
||||
}, 0)
|
||||
})
|
||||
|
||||
expect(updateAnnotationStatus).toHaveBeenCalledWith(
|
||||
'test-app',
|
||||
'enable',
|
||||
{
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-small',
|
||||
},
|
||||
0,
|
||||
)
|
||||
const updatedConfig = setAnnotationConfig.mock.calls[0]![0]
|
||||
expect(updatedConfig.score_threshold).toBe(0)
|
||||
})
|
||||
|
||||
it('should set score and embedding model together', () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ const ConfigParamModal: FC<Props> = ({ isShow, onHide: doHide, onSave, isInit, a
|
||||
<Item title={t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })} tooltip={t('feature.annotation.scoreThreshold.description', { ns: 'appDebug' })}>
|
||||
<ScoreSlider
|
||||
className="mt-1"
|
||||
value={(annotationConfig.score_threshold || ANNOTATION_DEFAULT.score_threshold) * 100}
|
||||
value={(annotationConfig.score_threshold ?? ANNOTATION_DEFAULT.score_threshold) * 100}
|
||||
onChange={(val) => {
|
||||
setAnnotationConfig({
|
||||
...annotationConfig,
|
||||
|
||||
@@ -100,7 +100,7 @@ const AnnotationReply = ({
|
||||
<div className="flex items-center gap-4 pt-0.5">
|
||||
<div className="">
|
||||
<div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary">{t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })}</div>
|
||||
<div className="system-xs-regular text-text-secondary">{annotationReply.score_threshold || '-'}</div>
|
||||
<div className="system-xs-regular text-text-secondary">{annotationReply.score_threshold ?? '-'}</div>
|
||||
</div>
|
||||
<div className="h-[27px] w-px rotate-12 bg-divider-subtle"></div>
|
||||
<div className="">
|
||||
|
||||
+8
-1
@@ -17,7 +17,7 @@ describe('ScoreSlider', () => {
|
||||
it('should display easy match and accurate match labels', () => {
|
||||
render(<ScoreSlider value={90} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('0.8')).toBeInTheDocument()
|
||||
expect(screen.getByText('0.0')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.0')).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.annotation\.scoreThreshold\.easyMatch/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.annotation\.scoreThreshold\.accurateMatch/)).toBeInTheDocument()
|
||||
@@ -36,4 +36,11 @@ describe('ScoreSlider', () => {
|
||||
expect(getSliderInput()).toHaveValue('95')
|
||||
expect(screen.getByText('0.95')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should allow zero as the minimum score threshold', () => {
|
||||
render(<ScoreSlider value={0} onChange={vi.fn()} />)
|
||||
|
||||
expect(getSliderInput()).toHaveValue('0')
|
||||
expect(screen.getByText('0.00')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
+8
-5
@@ -17,13 +17,16 @@ const clamp = (value: number, min: number, max: number) => {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
const SCORE_MIN = 0
|
||||
const SCORE_MAX = 100
|
||||
|
||||
const ScoreSlider: FC<Props> = ({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const safeValue = clamp(value, 80, 100)
|
||||
const safeValue = clamp(value, SCORE_MIN, SCORE_MAX)
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
@@ -31,8 +34,8 @@ const ScoreSlider: FC<Props> = ({
|
||||
<Slider
|
||||
className="w-full"
|
||||
value={safeValue}
|
||||
min={80}
|
||||
max={100}
|
||||
min={SCORE_MIN}
|
||||
max={SCORE_MAX}
|
||||
step={1}
|
||||
onValueChange={onChange}
|
||||
aria-label={t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })}
|
||||
@@ -40,7 +43,7 @@ const ScoreSlider: FC<Props> = ({
|
||||
<div
|
||||
className="pointer-events-none absolute top-[-16px] system-sm-semibold text-text-primary"
|
||||
style={{
|
||||
left: `calc(4px + ${(safeValue - 80) / 20} * (100% - 8px))`,
|
||||
left: `calc(4px + ${safeValue / SCORE_MAX} * (100% - 8px))`,
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
@@ -49,7 +52,7 @@ const ScoreSlider: FC<Props> = ({
|
||||
</div>
|
||||
<div className="mt-[10px] flex items-center justify-between system-xs-semibold-uppercase">
|
||||
<div className="flex space-x-1 text-util-colors-cyan-cyan-500">
|
||||
<div>0.8</div>
|
||||
<div>0.0</div>
|
||||
<div>·</div>
|
||||
<div>{t('feature.annotation.scoreThreshold.easyMatch', { ns: 'appDebug' })}</div>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ const useAnnotationConfig = ({
|
||||
setAnnotationConfig(produce(annotationConfig, (draft: AnnotationReplyConfig) => {
|
||||
draft.enabled = true
|
||||
draft.embedding_model = embeddingModel
|
||||
if (!draft.score_threshold)
|
||||
if (draft.score_threshold === undefined || draft.score_threshold === null)
|
||||
draft.score_threshold = ANNOTATION_DEFAULT.score_threshold
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
@reference "../../../../styles/globals.css";
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
@apply flex items-center place-content-between h-8;
|
||||
}
|
||||
@@ -19,7 +15,7 @@
|
||||
background-size: 16px;
|
||||
}
|
||||
|
||||
.modal .tip {
|
||||
.tip {
|
||||
@apply mt-1 mb-8 text-text-tertiary;
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -53,7 +53,7 @@ const EmptyDatasetCreationModal = ({ show = false, onHide }: IProps) => {
|
||||
onHide()
|
||||
}}
|
||||
>
|
||||
<DialogContent className={cn('w-full overflow-hidden! border-none text-left align-middle', cn(s.modal, '!max-w-[520px]', 'px-8'))}>
|
||||
<DialogContent className="w-full max-w-[520px]! overflow-hidden! border-none px-8 text-left align-middle">
|
||||
|
||||
<div className={s.modalHeader}>
|
||||
<div className={s.title}>{t('stepOne.modal.title', { ns: 'datasetCreation' })}</div>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
@reference "../../../../styles/globals.css";
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
}
|
||||
.modal .icon {
|
||||
.icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.9) center no-repeat url(../assets/annotation-info.svg);
|
||||
@@ -12,7 +9,7 @@
|
||||
box-shadow: 0px 20px 24px -4px rgba(16, 24, 40, 0.08), 0px 8px 8px -4px rgba(16, 24, 40, 0.03);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.modal .close {
|
||||
.close {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 16px;
|
||||
@@ -23,14 +20,14 @@
|
||||
background-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal .title {
|
||||
.title {
|
||||
@apply mt-3 mb-1;
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
line-height: 30px;
|
||||
color: #101828;
|
||||
}
|
||||
.modal .content {
|
||||
.content {
|
||||
@apply mb-10;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -39,7 +39,7 @@ const StopEmbeddingModal = ({
|
||||
onHide()
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className={cn(s.modal, 'max-w-[480px]! overflow-hidden! border-none px-8 py-6 text-left align-middle shadow-xl')}>
|
||||
<AlertDialogContent className="max-w-[480px]! overflow-hidden! border-none px-8 py-6 text-left align-middle shadow-xl">
|
||||
<div className={s.icon} />
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -239,8 +239,8 @@ describe('AppList', () => {
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => {
|
||||
options.onPending?.()
|
||||
})
|
||||
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
|
||||
})
|
||||
|
||||
renderAppList(true, onSuccess)
|
||||
@@ -257,7 +257,9 @@ describe('AppList', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({
|
||||
source: 'explore_template_list',
|
||||
appMode: AppModeEnum.CHAT,
|
||||
templateId: 'app-1',
|
||||
})
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -351,8 +353,8 @@ describe('AppList', () => {
|
||||
allList: [createApp()],
|
||||
};
|
||||
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT })
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
|
||||
})
|
||||
|
||||
renderAppList(true)
|
||||
@@ -417,8 +419,8 @@ describe('AppList', () => {
|
||||
allList: [createApp()],
|
||||
};
|
||||
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT })
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
|
||||
})
|
||||
|
||||
renderAppList(true)
|
||||
@@ -429,7 +431,9 @@ describe('AppList', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({
|
||||
source: 'explore_template_preview',
|
||||
appMode: AppModeEnum.CHAT,
|
||||
templateId: 'app-1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { App } from '@/models/explore'
|
||||
import type { TryAppSelection } from '@/types/try-app'
|
||||
import type { TrackCreateAppParams } from '@/utils/create-app-tracking'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
@@ -107,6 +108,7 @@ const Apps = ({
|
||||
|
||||
const [currentTryApp, setCurrentTryApp] = useState<TryAppSelection | undefined>(undefined)
|
||||
const currentCreateAppModeRef = useRef<App['app']['mode'] | null>(null)
|
||||
const currentCreateAppTrackingRef = useRef<Pick<TrackCreateAppParams, 'source' | 'templateId'> | null>(null)
|
||||
const isShowTryAppPanel = !!currentTryApp
|
||||
const hideTryAppPanel = useCallback(() => {
|
||||
setCurrentTryApp(undefined)
|
||||
@@ -116,13 +118,24 @@ const Apps = ({
|
||||
}, [])
|
||||
const handleShowFromTryApp = useCallback(() => {
|
||||
setCurrApp(currentTryApp?.app || null)
|
||||
currentCreateAppTrackingRef.current = {
|
||||
source: 'explore_template_preview',
|
||||
templateId: currentTryApp?.appId || currentTryApp?.app.app_id,
|
||||
}
|
||||
setIsShowCreateModal(true)
|
||||
}, [currentTryApp?.app])
|
||||
const trackCurrentCreateApp = useCallback(() => {
|
||||
if (!currentCreateAppModeRef.current)
|
||||
}, [currentTryApp?.app, currentTryApp?.appId])
|
||||
const trackCurrentCreateApp = useCallback((appMode?: App['app']['mode'] | null) => {
|
||||
const currentCreateAppTracking = currentCreateAppTrackingRef.current
|
||||
const resolvedAppMode = appMode ?? currentCreateAppModeRef.current
|
||||
if (!resolvedAppMode || !currentCreateAppTracking)
|
||||
return
|
||||
|
||||
trackCreateApp({ appMode: currentCreateAppModeRef.current })
|
||||
trackCreateApp({
|
||||
...currentCreateAppTracking,
|
||||
appMode: resolvedAppMode,
|
||||
})
|
||||
currentCreateAppTrackingRef.current = null
|
||||
currentCreateAppModeRef.current = null
|
||||
}, [])
|
||||
|
||||
const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({
|
||||
@@ -148,8 +161,8 @@ const Apps = ({
|
||||
description,
|
||||
}
|
||||
await handleImportDSL(payload, {
|
||||
onSuccess: () => {
|
||||
trackCurrentCreateApp()
|
||||
onSuccess: (response) => {
|
||||
trackCurrentCreateApp(response.app_mode)
|
||||
setIsShowCreateModal(false)
|
||||
},
|
||||
onPending: () => {
|
||||
@@ -160,8 +173,8 @@ const Apps = ({
|
||||
|
||||
const onConfirmDSL = useCallback(async () => {
|
||||
await handleImportDSLConfirm({
|
||||
onSuccess: () => {
|
||||
trackCurrentCreateApp()
|
||||
onSuccess: (response) => {
|
||||
trackCurrentCreateApp(response.app_mode)
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
@@ -242,6 +255,10 @@ const Apps = ({
|
||||
app={app}
|
||||
canCreate={hasEditPermission}
|
||||
onCreate={() => {
|
||||
currentCreateAppTrackingRef.current = {
|
||||
source: 'explore_template_list',
|
||||
templateId: app.app_id,
|
||||
}
|
||||
setCurrApp(app)
|
||||
setIsShowCreateModal(true)
|
||||
}}
|
||||
|
||||
+59
-5
@@ -13,6 +13,7 @@ let parameterRules: Array<Record<string, unknown>> | undefined = [
|
||||
},
|
||||
]
|
||||
let isRulesLoading = false
|
||||
let isRulesPending = false
|
||||
let currentProvider: Record<string, unknown> | undefined = { provider: 'openai', label: { en_US: 'OpenAI' } }
|
||||
let currentModel: Record<string, unknown> | undefined = {
|
||||
model: 'gpt-3.5-turbo',
|
||||
@@ -49,7 +50,7 @@ vi.mock('@/service/use-common', () => ({
|
||||
data: parameterRules,
|
||||
},
|
||||
isLoading: isRulesLoading,
|
||||
isPending: isRulesLoading,
|
||||
isPending: isRulesPending,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -92,9 +93,21 @@ vi.mock('../../model-selector', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../presets-parameter', () => ({
|
||||
default: ({ onSelect }: { onSelect: (id: number) => void }) => (
|
||||
<button onClick={() => onSelect(1)}>Preset 1</button>
|
||||
),
|
||||
default: ({ onSelect, supportedParameterNames }: { onSelect: (id: number) => void, supportedParameterNames?: string[] }) => {
|
||||
if (supportedParameterNames && !supportedParameterNames.includes('temperature'))
|
||||
return null
|
||||
|
||||
return <button onClick={() => onSelect(1)}>Preset 1</button>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../presets-parameter-utils', () => ({
|
||||
getSupportedPresetConfig: (_toneId: number, supportedParameterNames?: string[]) => {
|
||||
if (supportedParameterNames && !supportedParameterNames.includes('temperature'))
|
||||
return {}
|
||||
|
||||
return { temperature: 0.8 }
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../trigger', () => ({
|
||||
@@ -126,6 +139,7 @@ describe('ModelParameterModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isRulesLoading = false
|
||||
isRulesPending = false
|
||||
parameterRules = [
|
||||
{
|
||||
name: 'temperature',
|
||||
@@ -194,7 +208,28 @@ describe('ModelParameterModal', () => {
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
fireEvent.click(screen.getByText('Preset 1'))
|
||||
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalled()
|
||||
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
...defaultProps.completionParams,
|
||||
temperature: 0.8,
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render preset control when visible parameters do not support preset keys', () => {
|
||||
parameterRules = [
|
||||
{
|
||||
name: 'max_tokens',
|
||||
label: { en_US: 'Max Tokens' },
|
||||
type: 'int',
|
||||
default: 256,
|
||||
min: 1,
|
||||
max: 4096,
|
||||
},
|
||||
]
|
||||
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
|
||||
expect(screen.queryByText('Preset 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setModel when model selector picks another model', () => {
|
||||
@@ -219,11 +254,29 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
it('should render loading state when parameter rules are loading', () => {
|
||||
isRulesLoading = true
|
||||
isRulesPending = true
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render parameter loading when model is not configured and parameter rules query is pending but disabled', () => {
|
||||
isRulesPending = true
|
||||
parameterRules = []
|
||||
|
||||
render(
|
||||
<ModelParameterModal
|
||||
{...defaultProps}
|
||||
provider=""
|
||||
modelId=""
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not open content when readonly is true', () => {
|
||||
render(<ModelParameterModal {...defaultProps} readonly />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
@@ -299,6 +352,7 @@ describe('ModelParameterModal', () => {
|
||||
it('should render the empty loading fallback when rules resolve to an empty list', () => {
|
||||
parameterRules = []
|
||||
isRulesLoading = true
|
||||
isRulesPending = true
|
||||
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
|
||||
+19
@@ -1,6 +1,7 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import PresetsParameter from '../presets-parameter'
|
||||
import { getSupportedPresetConfig } from '../presets-parameter-utils'
|
||||
|
||||
describe('PresetsParameter', () => {
|
||||
beforeEach(() => {
|
||||
@@ -47,4 +48,22 @@ describe('PresetsParameter', () => {
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('should render presets when at least one preset parameter is supported', () => {
|
||||
render(<PresetsParameter onSelect={vi.fn()} supportedParameterNames={['temperature']} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render presets when no preset parameters are supported', () => {
|
||||
render(<PresetsParameter onSelect={vi.fn()} supportedParameterNames={['max_tokens']} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: /common\.modelProvider\.loadPresets/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return only supported preset config keys', () => {
|
||||
expect(getSupportedPresetConfig(1, ['temperature'])).toEqual({
|
||||
temperature: 0.8,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+14
-11
@@ -24,7 +24,7 @@ import { useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE, TONE_LIST } from '@/config'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE } from '@/config'
|
||||
import { useModelParameterRules } from '@/service/use-common'
|
||||
import {
|
||||
useTextGenerationCurrentProviderAndModelAndModelList,
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import ModelSelector from '../model-selector'
|
||||
import ParameterItem from './parameter-item'
|
||||
import PresetsParameter from './presets-parameter'
|
||||
import { getSupportedPresetConfig } from './presets-parameter-utils'
|
||||
import Trigger from './trigger'
|
||||
|
||||
export type ModelParameterModalProps = {
|
||||
@@ -75,10 +76,9 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
const settingsIconRef = useRef<HTMLDivElement>(null)
|
||||
const {
|
||||
data: parameterRulesData,
|
||||
isPending,
|
||||
isLoading,
|
||||
} = useModelParameterRules(provider, modelId)
|
||||
const isRulesLoading = isPending || isLoading
|
||||
const isRulesLoading = !!provider && !!modelId && isLoading
|
||||
const {
|
||||
currentProvider,
|
||||
currentModel,
|
||||
@@ -90,6 +90,9 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
const parameterRules: ModelParameterRule[] = useMemo(() => {
|
||||
return parameterRulesData?.data || []
|
||||
}, [parameterRulesData])
|
||||
const supportedPresetParameterNames = useMemo(() => {
|
||||
return parameterRules.map(parameterRule => parameterRule.name)
|
||||
}, [parameterRules])
|
||||
|
||||
const handleParamChange = (key: string, value: ParameterValue) => {
|
||||
onCompletionParamsChange({
|
||||
@@ -125,13 +128,10 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
}
|
||||
|
||||
const handleSelectPresetParameter = (toneId: number) => {
|
||||
const tone = TONE_LIST.find(tone => tone.id === toneId)
|
||||
if (tone) {
|
||||
onCompletionParamsChange({
|
||||
...completionParams,
|
||||
...tone.config,
|
||||
})
|
||||
}
|
||||
onCompletionParamsChange({
|
||||
...completionParams,
|
||||
...getSupportedPresetConfig(toneId, supportedPresetParameterNames),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -199,7 +199,10 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
<div className="flex flex-1 items-center system-sm-semibold-uppercase text-text-secondary">{t('modelProvider.parameters', { ns: 'common' })}</div>
|
||||
{
|
||||
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
|
||||
<PresetsParameter onSelect={handleSelectPresetParameter} />
|
||||
<PresetsParameter
|
||||
onSelect={handleSelectPresetParameter}
|
||||
supportedParameterNames={supportedPresetParameterNames}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { TONE_LIST } from '@/config'
|
||||
|
||||
export const getSupportedPresetConfig = (toneId: number, supportedParameterNames?: string[]) => {
|
||||
const tone = TONE_LIST.find(tone => tone.id === toneId)
|
||||
if (!tone?.config)
|
||||
return {}
|
||||
|
||||
if (!supportedParameterNames)
|
||||
return { ...tone.config }
|
||||
|
||||
const supportedParameterNameSet = new Set(supportedParameterNames)
|
||||
|
||||
return Object.entries(tone.config).reduce<Record<string, number>>((acc, [key, value]) => {
|
||||
if (supportedParameterNameSet.has(key))
|
||||
acc[key] = value
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
+12
-2
@@ -12,6 +12,8 @@ import { Scales02 } from '@/app/components/base/icons/src/vender/solid/FinanceAn
|
||||
import { Target04 } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import { TONE_LIST } from '@/config'
|
||||
|
||||
const PRESET_TONE_LIST = TONE_LIST.slice(0, 3)
|
||||
|
||||
const toneI18nKeyMap = {
|
||||
Creative: 'model.tone.Creative',
|
||||
Balanced: 'model.tone.Balanced',
|
||||
@@ -27,10 +29,18 @@ const TONE_ICONS: Record<number, ReactNode> = {
|
||||
|
||||
type PresetsParameterProps = {
|
||||
onSelect: (toneId: number) => void
|
||||
supportedParameterNames?: string[]
|
||||
}
|
||||
|
||||
function PresetsParameter({ onSelect }: PresetsParameterProps) {
|
||||
function PresetsParameter({ onSelect, supportedParameterNames }: PresetsParameterProps) {
|
||||
const { t } = useTranslation()
|
||||
const supportedParameterNameSet = supportedParameterNames ? new Set(supportedParameterNames) : undefined
|
||||
const visiblePresetTones = supportedParameterNameSet
|
||||
? PRESET_TONE_LIST.filter(tone => Object.keys(tone.config ?? {}).some(key => supportedParameterNameSet.has(key)))
|
||||
: PRESET_TONE_LIST
|
||||
|
||||
if (!visiblePresetTones.length)
|
||||
return null
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -47,7 +57,7 @@ function PresetsParameter({ onSelect }: PresetsParameterProps) {
|
||||
<span className="ml-0.5 i-ri-arrow-down-s-line h-3.5 w-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{TONE_LIST.slice(0, 3).map(tone => (
|
||||
{visiblePresetTones.map(tone => (
|
||||
<DropdownMenuItem key={tone.id} onClick={() => onSelect(tone.id)}>
|
||||
{TONE_ICONS[tone.id]}
|
||||
{t(toneI18nKeyMap[tone.name], { ns: 'common' })}
|
||||
|
||||
+130
-14
@@ -75,14 +75,58 @@ vi.mock('@/config', () => ({
|
||||
|
||||
// Mock PresetsParameter component
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter', () => ({
|
||||
default: ({ onSelect }: { onSelect: (toneId: number) => void }) => (
|
||||
<div data-testid="presets-parameter">
|
||||
<button data-testid="preset-creative" onClick={() => onSelect(1)}>Creative</button>
|
||||
<button data-testid="preset-balanced" onClick={() => onSelect(2)}>Balanced</button>
|
||||
<button data-testid="preset-precise" onClick={() => onSelect(3)}>Precise</button>
|
||||
<button data-testid="preset-custom" onClick={() => onSelect(4)}>Custom</button>
|
||||
</div>
|
||||
),
|
||||
default: ({ onSelect, supportedParameterNames }: { onSelect: (toneId: number) => void, supportedParameterNames?: string[] }) => {
|
||||
const hasSupportedParameter = !supportedParameterNames || supportedParameterNames.some(name => ['temperature', 'top_p', 'presence_penalty', 'frequency_penalty'].includes(name))
|
||||
if (!hasSupportedParameter)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="presets-parameter">
|
||||
<button data-testid="preset-creative" onClick={() => onSelect(1)}>Creative</button>
|
||||
<button data-testid="preset-balanced" onClick={() => onSelect(2)}>Balanced</button>
|
||||
<button data-testid="preset-precise" onClick={() => onSelect(3)}>Precise</button>
|
||||
<button data-testid="preset-custom" onClick={() => onSelect(4)}>Custom</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter-utils', () => ({
|
||||
getSupportedPresetConfig: (toneId: number, supportedParameterNames?: string[]) => {
|
||||
const toneConfigMap: Record<number, Record<string, number> | undefined> = {
|
||||
1: {
|
||||
temperature: 0.8,
|
||||
top_p: 0.9,
|
||||
presence_penalty: 0.1,
|
||||
frequency_penalty: 0.1,
|
||||
},
|
||||
2: {
|
||||
temperature: 0.5,
|
||||
top_p: 0.85,
|
||||
presence_penalty: 0.2,
|
||||
frequency_penalty: 0.3,
|
||||
},
|
||||
3: {
|
||||
temperature: 0.2,
|
||||
top_p: 0.75,
|
||||
presence_penalty: 0.5,
|
||||
frequency_penalty: 0.5,
|
||||
},
|
||||
}
|
||||
const toneConfig = toneConfigMap[toneId]
|
||||
if (!toneConfig)
|
||||
return {}
|
||||
|
||||
if (!supportedParameterNames)
|
||||
return toneConfig
|
||||
|
||||
return Object.entries(toneConfig).reduce<Record<string, number>>((acc, [key, value]) => {
|
||||
if (supportedParameterNames.includes(key))
|
||||
acc[key] = value
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock ParameterItem component
|
||||
@@ -148,10 +192,12 @@ const createDefaultProps = (overrides: Partial<{
|
||||
const setupModelParameterRulesMock = (config: {
|
||||
data?: ModelParameterRule[]
|
||||
isPending?: boolean
|
||||
isLoading?: boolean
|
||||
} = {}) => {
|
||||
mockUseModelParameterRules.mockReturnValue({
|
||||
data: config.data ? { data: config.data } : undefined,
|
||||
isPending: config.isPending ?? false,
|
||||
isLoading: config.isLoading ?? config.isPending ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -188,6 +234,19 @@ describe('LLMParamsPanel', () => {
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render loading state when model is not configured and parameter rules query is pending but disabled', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ isPending: true, isLoading: false })
|
||||
const props = createDefaultProps({ provider: '', modelId: '' })
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.parameters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render parameters header', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
@@ -202,7 +261,7 @@ describe('LLMParamsPanel', () => {
|
||||
|
||||
it('should render PresetsParameter for openai provider', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
|
||||
const props = createDefaultProps({ provider: 'langgenius/openai/openai' })
|
||||
|
||||
// Act
|
||||
@@ -214,7 +273,7 @@ describe('LLMParamsPanel', () => {
|
||||
|
||||
it('should render PresetsParameter for azure_openai provider', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
|
||||
const props = createDefaultProps({ provider: 'langgenius/azure_openai/azure_openai' })
|
||||
|
||||
// Act
|
||||
@@ -224,6 +283,18 @@ describe('LLMParamsPanel', () => {
|
||||
expect(screen.getByTestId('presets-parameter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render PresetsParameter when no visible parameter supports presets', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'max_tokens', type: 'int' })], isPending: false })
|
||||
const props = createDefaultProps({ provider: 'langgenius/openai/openai' })
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('presets-parameter')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render PresetsParameter for non-preset providers', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
@@ -360,7 +431,15 @@ describe('LLMParamsPanel', () => {
|
||||
it('should apply Creative preset config', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({
|
||||
data: [
|
||||
createParameterRule({ name: 'temperature' }),
|
||||
createParameterRule({ name: 'top_p' }),
|
||||
createParameterRule({ name: 'presence_penalty' }),
|
||||
createParameterRule({ name: 'frequency_penalty' }),
|
||||
],
|
||||
isPending: false,
|
||||
})
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
@@ -384,7 +463,15 @@ describe('LLMParamsPanel', () => {
|
||||
it('should apply Balanced preset config', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({
|
||||
data: [
|
||||
createParameterRule({ name: 'temperature' }),
|
||||
createParameterRule({ name: 'top_p' }),
|
||||
createParameterRule({ name: 'presence_penalty' }),
|
||||
createParameterRule({ name: 'frequency_penalty' }),
|
||||
],
|
||||
isPending: false,
|
||||
})
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
@@ -407,7 +494,15 @@ describe('LLMParamsPanel', () => {
|
||||
it('should apply Precise preset config', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({
|
||||
data: [
|
||||
createParameterRule({ name: 'temperature' }),
|
||||
createParameterRule({ name: 'top_p' }),
|
||||
createParameterRule({ name: 'presence_penalty' }),
|
||||
createParameterRule({ name: 'frequency_penalty' }),
|
||||
],
|
||||
isPending: false,
|
||||
})
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
@@ -430,7 +525,7 @@ describe('LLMParamsPanel', () => {
|
||||
it('should apply empty config for Custom preset (spreads undefined)', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
@@ -444,6 +539,27 @@ describe('LLMParamsPanel', () => {
|
||||
// Assert - Custom preset has no config, so only existing params are kept
|
||||
expect(onCompletionParamsChange).toHaveBeenCalledWith({ existing: 'value' })
|
||||
})
|
||||
|
||||
it('should apply only preset config keys supported by visible parameters', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
completionParams: { existing: 'value' },
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
fireEvent.click(screen.getByTestId('preset-creative'))
|
||||
|
||||
// Assert
|
||||
expect(onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
existing: 'value',
|
||||
temperature: 0.8,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleParamChange', () => {
|
||||
|
||||
+16
-11
@@ -10,7 +10,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import ParameterItem from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item'
|
||||
import PresetsParameter from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE, TONE_LIST } from '@/config'
|
||||
import { getSupportedPresetConfig } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter-utils'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE } from '@/config'
|
||||
import { useModelParameterRules } from '@/service/use-common'
|
||||
|
||||
type Props = {
|
||||
@@ -29,20 +30,21 @@ const LLMParamsPanel = ({
|
||||
onCompletionParamsChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: parameterRulesData, isPending: isLoading } = useModelParameterRules(provider, modelId)
|
||||
const { data: parameterRulesData, isLoading } = useModelParameterRules(provider, modelId)
|
||||
const isRulesLoading = !!provider && !!modelId && isLoading
|
||||
|
||||
const parameterRules: ModelParameterRule[] = useMemo(() => {
|
||||
return parameterRulesData?.data || []
|
||||
}, [parameterRulesData])
|
||||
const supportedPresetParameterNames = useMemo(() => {
|
||||
return parameterRules.map(parameterRule => parameterRule.name)
|
||||
}, [parameterRules])
|
||||
|
||||
const handleSelectPresetParameter = (toneId: number) => {
|
||||
const tone = TONE_LIST.find(tone => tone.id === toneId)
|
||||
if (tone) {
|
||||
onCompletionParamsChange({
|
||||
...completionParams,
|
||||
...tone.config,
|
||||
})
|
||||
}
|
||||
onCompletionParamsChange({
|
||||
...completionParams,
|
||||
...getSupportedPresetConfig(toneId, supportedPresetParameterNames),
|
||||
})
|
||||
}
|
||||
const handleParamChange = (key: string, value: ParameterValue) => {
|
||||
onCompletionParamsChange({
|
||||
@@ -65,7 +67,7 @@ const LLMParamsPanel = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
if (isRulesLoading) {
|
||||
return (
|
||||
<div className="mt-5"><Loading /></div>
|
||||
)
|
||||
@@ -77,7 +79,10 @@ const LLMParamsPanel = ({
|
||||
<div className={cn('flex h-6 items-center system-sm-semibold text-text-secondary')}>{t('modelProvider.parameters', { ns: 'common' })}</div>
|
||||
{
|
||||
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
|
||||
<PresetsParameter onSelect={handleSelectPresetParameter} />
|
||||
<PresetsParameter
|
||||
onSelect={handleSelectPresetParameter}
|
||||
supportedParameterNames={supportedPresetParameterNames}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ type DSLPayload = {
|
||||
description?: string
|
||||
}
|
||||
type ResponseCallback = {
|
||||
onSuccess?: () => void
|
||||
onSuccess?: (payload: DSLImportResponse) => void
|
||||
onPending?: (payload: DSLImportResponse) => void
|
||||
onFailed?: () => void
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export const useImportDSL = () => {
|
||||
toast.success(message)
|
||||
else
|
||||
toast.warning(message, { description })
|
||||
onSuccess?.()
|
||||
onSuccess?.(response)
|
||||
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
|
||||
await handleCheckPluginDependencies(app_id)
|
||||
getRedirection(isCurrentWorkspaceEditor, { id: app_id, mode: app_mode }, push)
|
||||
@@ -134,7 +134,7 @@ export const useImportDSL = () => {
|
||||
return
|
||||
|
||||
if (status === DSLImportStatus.COMPLETED) {
|
||||
onSuccess?.()
|
||||
onSuccess?.(response)
|
||||
toast.success(t('newApp.appCreated', { ns: 'app' }))
|
||||
await handleCheckPluginDependencies(app_id)
|
||||
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dify-web",
|
||||
"type": "module",
|
||||
"version": "1.14.1",
|
||||
"version": "1.14.2",
|
||||
"private": true,
|
||||
"imports": {
|
||||
"#i18n": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user