mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 10:56:13 +08:00
Merge remote-tracking branch 'origin/deploy/konwledge' into deploy/konwledge
This commit is contained in:
commit
f5000ecb88
@ -55,7 +55,8 @@ For non-string `Select` and `RadioGroup` values, prefer explicit domain generics
|
||||
Flag:
|
||||
|
||||
- Form-like UI using unrelated `Input` and `Button` pieces without a submit boundary.
|
||||
- Text-like fields not composed through `Field`, `FieldLabel`, and `FieldControl` when using Dify UI form semantics.
|
||||
- Text-like fields not composed through `Field`, `FieldLabel`, and `Input` or `InputGroupInput` when using Dify UI form semantics.
|
||||
- Prefixes, suffixes, or actions manually layered over `Input` instead of using the canonical `InputGroup` composition.
|
||||
- Select fields using `FieldLabel` instead of `SelectLabel`.
|
||||
- Slider fields using a generic label instead of `SliderLabel`.
|
||||
- Checkbox/radio groups missing `Fieldset` and `FieldsetLegend`.
|
||||
|
||||
2
.github/workflows/accessibility-e2e.yml
vendored
2
.github/workflows/accessibility-e2e.yml
vendored
@ -52,7 +52,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
|
||||
6
.github/workflows/api-tests.yml
vendored
6
.github/workflows/api-tests.yml
vendored
@ -35,7 +35,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@ -94,7 +94,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@ -145,7 +145,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
|
||||
2
.github/workflows/autofix.yml
vendored
2
.github/workflows/autofix.yml
vendored
@ -90,7 +90,7 @@ jobs:
|
||||
python-version: '3.11'
|
||||
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
|
||||
- name: Generate Docker Compose
|
||||
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
|
||||
|
||||
29
.github/workflows/build-push.yml
vendored
29
.github/workflows/build-push.yml
vendored
@ -229,3 +229,32 @@ jobs:
|
||||
IMAGE_VERSION: ${{ steps.meta.outputs.version }}
|
||||
run: |
|
||||
docker buildx imagetools inspect "$IMAGE_NAME:$IMAGE_VERSION"
|
||||
|
||||
sync-e2b-template:
|
||||
needs: create-manifest
|
||||
runs-on: ubuntu-24.04
|
||||
if: github.repository == 'langgenius/dify' && startsWith(github.ref, 'refs/tags/')
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- project: dev
|
||||
api_key_secret: E2B_API_KEY_DEV
|
||||
- project: prod
|
||||
api_key_secret: E2B_API_KEY_PROD
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USER }}
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Sync E2B Template (${{ matrix.project }})
|
||||
env:
|
||||
E2B_API_KEY: ${{ secrets[matrix.api_key_secret] }}
|
||||
run: ./dify-agent-runtime/docker/sync-e2b-template.sh
|
||||
|
||||
4
.github/workflows/db-migration-test.yml
vendored
4
.github/workflows/db-migration-test.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
@ -69,7 +69,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
|
||||
2
.github/workflows/pyrefly-diff.yml
vendored
2
.github/workflows/pyrefly-diff.yml
vendored
@ -22,7 +22,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
2
.github/workflows/pyrefly-type-coverage.yml
vendored
2
.github/workflows/pyrefly-type-coverage.yml
vendored
@ -22,7 +22,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
2
.github/workflows/style.yml
vendored
2
.github/workflows/style.yml
vendored
@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
- name: Setup UV and Python
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: '3.12'
|
||||
|
||||
2
.github/workflows/translate-i18n-claude.yml
vendored
2
.github/workflows/translate-i18n-claude.yml
vendored
@ -162,7 +162,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code for Translation Sync
|
||||
if: steps.context.outputs.CHANGED_FILES != ''
|
||||
uses: anthropics/claude-code-action@e63208cb983318a44e3f945e959ef894b707dcfa # v1.0.192
|
||||
uses: anthropics/claude-code-action@9d7150bc8a3dae8149739a88019d192b579ad90c # v1.0.193
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/vdb-tests-full.yml
vendored
2
.github/workflows/vdb-tests-full.yml
vendored
@ -36,7 +36,7 @@ jobs:
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
2
.github/workflows/vdb-tests.yml
vendored
2
.github/workflows/vdb-tests.yml
vendored
@ -33,7 +33,7 @@ jobs:
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
2
.github/workflows/web-e2e.yml
vendored
2
.github/workflows/web-e2e.yml
vendored
@ -35,7 +35,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
|
||||
@ -23,6 +23,7 @@ from dify_agent.protocol import (
|
||||
CancelRunResponse,
|
||||
CreateRunRequest,
|
||||
CreateRunResponse,
|
||||
RunCancelledEvent,
|
||||
RunEvent,
|
||||
RunStatusResponse,
|
||||
)
|
||||
@ -45,6 +46,15 @@ class AgentBackendRunClient(Protocol):
|
||||
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
|
||||
"""Request explicit cancellation for one Agent backend run."""
|
||||
|
||||
def cancel_run_and_wait(
|
||||
self,
|
||||
run_id: str,
|
||||
request: CancelRunRequest | None = None,
|
||||
*,
|
||||
after: str | None = None,
|
||||
) -> RunCancelledEvent:
|
||||
"""Request cancellation and wait for runner cleanup to finish."""
|
||||
|
||||
def stream_events(
|
||||
self,
|
||||
run_id: str,
|
||||
@ -67,6 +77,15 @@ class _DifyAgentSyncClient(Protocol):
|
||||
def cancel_run_sync(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
|
||||
"""Cancel one run synchronously."""
|
||||
|
||||
def cancel_run_and_wait_sync(
|
||||
self,
|
||||
run_id: str,
|
||||
request: CancelRunRequest | None = None,
|
||||
*,
|
||||
after: str | None = None,
|
||||
) -> RunCancelledEvent:
|
||||
"""Cancel one run and wait for its terminal event synchronously."""
|
||||
|
||||
def stream_events_sync(
|
||||
self,
|
||||
run_id: str,
|
||||
@ -110,6 +129,19 @@ class DifyAgentBackendRunClient:
|
||||
except Exception as exc:
|
||||
raise _normalize_dify_agent_error(exc) from exc
|
||||
|
||||
def cancel_run_and_wait(
|
||||
self,
|
||||
run_id: str,
|
||||
request: CancelRunRequest | None = None,
|
||||
*,
|
||||
after: str | None = None,
|
||||
) -> RunCancelledEvent:
|
||||
"""Cancel one run, then wait for the cleanup-complete terminal event."""
|
||||
try:
|
||||
return self.client.cancel_run_and_wait_sync(run_id, request=request, after=after)
|
||||
except Exception as exc:
|
||||
raise _normalize_dify_agent_error(exc) from exc
|
||||
|
||||
def stream_events(
|
||||
self,
|
||||
run_id: str,
|
||||
|
||||
@ -99,6 +99,7 @@ class AgentBackendRunFailedInternalEvent(AgentBackendInternalEventBase):
|
||||
error: str
|
||||
error_type: RunFailureType | None = None
|
||||
reason: str | None = None
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
|
||||
|
||||
class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase):
|
||||
@ -107,6 +108,7 @@ class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase):
|
||||
type: Literal[AgentBackendInternalEventType.RUN_CANCELLED] = AgentBackendInternalEventType.RUN_CANCELLED
|
||||
reason: str | None = None
|
||||
message: str | None = None
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
|
||||
|
||||
type AgentBackendInternalEvent = Annotated[
|
||||
@ -184,6 +186,7 @@ class AgentBackendRunEventAdapter:
|
||||
error=event.data.error,
|
||||
error_type=event.data.error_type,
|
||||
reason=event.data.reason,
|
||||
session_snapshot=event.data.session_snapshot,
|
||||
)
|
||||
]
|
||||
case RunCancelledEvent():
|
||||
@ -193,6 +196,7 @@ class AgentBackendRunEventAdapter:
|
||||
source_event_id=event.id,
|
||||
reason=event.data.reason,
|
||||
message=event.data.message,
|
||||
session_snapshot=event.data.session_snapshot,
|
||||
)
|
||||
]
|
||||
raise TypeError(f"unsupported agent backend run event: {type(event).__name__}")
|
||||
|
||||
@ -18,6 +18,8 @@ from dify_agent.protocol import (
|
||||
CreateRunRequest,
|
||||
CreateRunResponse,
|
||||
DeferredToolCallPayload,
|
||||
RunCancelledEvent,
|
||||
RunCancelledEventData,
|
||||
RunEvent,
|
||||
RunFailedEvent,
|
||||
RunFailedEventData,
|
||||
@ -69,6 +71,28 @@ class FakeAgentBackendRunClient:
|
||||
del request
|
||||
return CancelRunResponse(run_id=run_id, status="cancelled")
|
||||
|
||||
def cancel_run_and_wait(
|
||||
self,
|
||||
run_id: str,
|
||||
request: CancelRunRequest | None = None,
|
||||
*,
|
||||
after: str | None = None,
|
||||
) -> RunCancelledEvent:
|
||||
"""Return a deterministic cleanup-complete cancellation event."""
|
||||
del after
|
||||
request = request or CancelRunRequest()
|
||||
_ = self.cancel_run(run_id, request)
|
||||
return RunCancelledEvent(
|
||||
id="cancel-0",
|
||||
run_id=run_id,
|
||||
created_at=_FIXED_TIME,
|
||||
data=RunCancelledEventData(
|
||||
reason=request.reason,
|
||||
message=request.message,
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
)
|
||||
|
||||
def stream_events(
|
||||
self,
|
||||
run_id: str,
|
||||
@ -133,7 +157,11 @@ class FakeAgentBackendRunClient:
|
||||
id="2-0",
|
||||
run_id=run_id,
|
||||
created_at=_FIXED_TIME,
|
||||
data=RunFailedEventData(error="fake failure", reason="unit_test"),
|
||||
data=RunFailedEventData(
|
||||
error="fake failure",
|
||||
reason="unit_test",
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
),
|
||||
)
|
||||
case FakeAgentBackendScenario.PAUSED:
|
||||
|
||||
@ -150,6 +150,7 @@ class AgentBackendModelConfig(BaseModel):
|
||||
model_provider: str
|
||||
model: str
|
||||
model_settings: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
context_window_tokens: int | None = Field(default=None, gt=0)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
@ -413,6 +414,7 @@ class AgentBackendRunRequestBuilder:
|
||||
model_provider=run_input.model.model_provider,
|
||||
model=run_input.model.model,
|
||||
model_settings=_agent_model_settings(run_input.model.model_settings),
|
||||
context_window_tokens=run_input.model.context_window_tokens,
|
||||
),
|
||||
)
|
||||
)
|
||||
@ -605,6 +607,7 @@ class AgentBackendRunRequestBuilder:
|
||||
model_provider=run_input.model.model_provider,
|
||||
model=run_input.model.model,
|
||||
model_settings=_agent_model_settings(run_input.model.model_settings),
|
||||
context_window_tokens=run_input.model.context_window_tokens,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@ -1324,6 +1324,17 @@ class MultiModalTransferConfig(BaseSettings):
|
||||
)
|
||||
|
||||
|
||||
class NewAgentBetaConfig(BaseSettings):
|
||||
NEW_AGENT_BETA_ACTIVITY_START_AT: datetime | None = Field(
|
||||
description="New Agent Beta Publish window start in RFC3339 UTC (inclusive)",
|
||||
default=None,
|
||||
)
|
||||
NEW_AGENT_BETA_ACTIVITY_END_AT: datetime | None = Field(
|
||||
description="New Agent Beta Publish window end in RFC3339 UTC (exclusive)",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
class OpsTraceConfig(BaseSettings):
|
||||
OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES: PositiveInt = Field(
|
||||
description="Maximum retry attempts for transient ops trace provider dispatch failures.",
|
||||
@ -1641,6 +1652,7 @@ class FeatureConfig(
|
||||
ModelLoadBalanceConfig,
|
||||
ModerationConfig,
|
||||
MultiModalTransferConfig,
|
||||
NewAgentBetaConfig,
|
||||
OpsTraceConfig,
|
||||
PositionConfig,
|
||||
RagEtlConfig,
|
||||
|
||||
@ -184,13 +184,15 @@ class EmailRegisterResetApi(Resource):
|
||||
if account:
|
||||
raise EmailAlreadyInUseError()
|
||||
|
||||
ip_address = extract_remote_ip(request)
|
||||
account = self._create_new_account(
|
||||
email=normalized_email,
|
||||
password=req_data.password_confirm,
|
||||
timezone=req_data.timezone,
|
||||
language=req_data.language,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
token_pair = AccountService.login(account=account, session=db.session(), ip_address=extract_remote_ip(request))
|
||||
token_pair = AccountService.login(account=account, session=db.session(), ip_address=ip_address)
|
||||
AccountService.reset_login_error_rate_limit(normalized_email)
|
||||
|
||||
return {"result": "success", "data": token_pair.model_dump()}
|
||||
@ -201,6 +203,7 @@ class EmailRegisterResetApi(Resource):
|
||||
password: str,
|
||||
timezone: str | None = None,
|
||||
language: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
) -> Account:
|
||||
try:
|
||||
return AccountService.create_account_and_tenant(
|
||||
@ -209,6 +212,7 @@ class EmailRegisterResetApi(Resource):
|
||||
password=password,
|
||||
interface_language=get_valid_language(language),
|
||||
timezone=timezone,
|
||||
ip_address=ip_address,
|
||||
session=db.session(),
|
||||
)
|
||||
except SeatsLimitExceededError:
|
||||
|
||||
@ -346,6 +346,7 @@ class EmailCodeLoginApi(Resource):
|
||||
else:
|
||||
TenantService.create_owner_tenant(account, session=db.session())
|
||||
|
||||
ip_address = extract_remote_ip(request)
|
||||
if account is None:
|
||||
try:
|
||||
account = AccountService.create_account_and_tenant(
|
||||
@ -353,6 +354,7 @@ class EmailCodeLoginApi(Resource):
|
||||
name=user_email,
|
||||
interface_language=get_valid_language(language),
|
||||
timezone=req_data.timezone,
|
||||
ip_address=ip_address,
|
||||
session=db.session(),
|
||||
)
|
||||
except WorkSpaceNotAllowedCreateError:
|
||||
@ -364,7 +366,7 @@ class EmailCodeLoginApi(Resource):
|
||||
raise AccountInFreezeError()
|
||||
except WorkspacesLimitExceededError:
|
||||
raise WorkspacesLimitExceeded()
|
||||
token_pair = AccountService.login(account, session=db.session(), ip_address=extract_remote_ip(request))
|
||||
token_pair = AccountService.login(account, session=db.session(), ip_address=ip_address)
|
||||
AccountService.reset_login_error_rate_limit(user_email)
|
||||
|
||||
# Create response with cookies instead of returning tokens in body
|
||||
|
||||
@ -233,7 +233,13 @@ class OAuthCallback(Resource):
|
||||
return _redirect_with_console_session(account, target_url)
|
||||
|
||||
try:
|
||||
account, oauth_new_user = _generate_account(provider, user_info, timezone=timezone, language=language)
|
||||
account, oauth_new_user = _generate_account(
|
||||
provider,
|
||||
user_info,
|
||||
timezone=timezone,
|
||||
language=language,
|
||||
ip_address=extract_remote_ip(request),
|
||||
)
|
||||
except AccountNotFoundError:
|
||||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Account not found.")
|
||||
except (WorkSpaceNotFoundError, WorkSpaceNotAllowedCreateError):
|
||||
@ -285,6 +291,7 @@ def _generate_account(
|
||||
user_info: OAuthUserInfo,
|
||||
timezone: str | None = None,
|
||||
language: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
) -> tuple[Account, bool]:
|
||||
# Get account by openid or email.
|
||||
account = _get_account_by_openid_or_email(provider, user_info)
|
||||
@ -322,6 +329,7 @@ def _generate_account(
|
||||
provider=provider,
|
||||
language=interface_language,
|
||||
timezone=timezone,
|
||||
ip_address=ip_address,
|
||||
session=db.session(),
|
||||
)
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@ from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.enums import PermissionEnum
|
||||
from models.provider_ids import DatasourceProviderID
|
||||
from services.datasource_provider_service import DatasourceProviderService
|
||||
from services.plugin.oauth_service import OAuthProxyService
|
||||
@ -74,6 +75,13 @@ class DatasourceUpdateNamePayload(BaseModel):
|
||||
|
||||
class DatasourceOAuthAuthorizationQuery(BaseModel):
|
||||
credential_id: str | None = Field(default=None, description="Credential ID to reauthorize")
|
||||
visibility: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Visibility for the credential to be created. Accepts 'only_me' or 'all_team_members'; "
|
||||
"any other value falls back to 'only_me'. Ignored on reauthorization (credential_id set)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DatasourceOAuthCallbackQuery(BaseModel):
|
||||
@ -175,12 +183,27 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource):
|
||||
if not oauth_config:
|
||||
raise ValueError(f"No OAuth Client Config for {provider_id}")
|
||||
|
||||
# Visibility is chosen by the user in the frontend before the redirect,
|
||||
# then read back in the callback below when the credential is created.
|
||||
# Only ONLY_ME / ALL_TEAM are accepted; anything else falls back to
|
||||
# ONLY_ME (OAuth tokens are personal by nature).
|
||||
# For reauthorization (credential_id set), visibility is ignored — we
|
||||
# keep whatever the credential was created with.
|
||||
raw_visibility = request.args.get("visibility")
|
||||
try:
|
||||
requested_visibility = PermissionEnum(raw_visibility) if raw_visibility else PermissionEnum.ONLY_ME
|
||||
except ValueError:
|
||||
requested_visibility = PermissionEnum.ONLY_ME
|
||||
if requested_visibility not in (PermissionEnum.ONLY_ME, PermissionEnum.ALL_TEAM):
|
||||
requested_visibility = PermissionEnum.ONLY_ME
|
||||
|
||||
context_id = OAuthProxyService.create_proxy_context(
|
||||
user_id=current_user.id,
|
||||
tenant_id=tenant_id,
|
||||
plugin_id=plugin_id,
|
||||
provider=provider_name,
|
||||
credential_id=credential_id,
|
||||
extra_data={"visibility": requested_visibility.value},
|
||||
)
|
||||
oauth_handler = OAuthHandler()
|
||||
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider_id}/datasource/callback"
|
||||
@ -253,6 +276,17 @@ class DatasourceOAuthCallback(Resource):
|
||||
credential_id=credential_id,
|
||||
)
|
||||
else:
|
||||
# Visibility was chosen by the user before the redirect and stashed
|
||||
# in the proxy context. Fall back to ONLY_ME for older cookies (or
|
||||
# anything that somehow wrote an unexpected value) — OAuth tokens
|
||||
# are personal by default.
|
||||
stored_visibility = context.get("visibility")
|
||||
try:
|
||||
visibility = PermissionEnum(stored_visibility) if stored_visibility else PermissionEnum.ONLY_ME
|
||||
except ValueError:
|
||||
visibility = PermissionEnum.ONLY_ME
|
||||
if visibility not in (PermissionEnum.ONLY_ME, PermissionEnum.ALL_TEAM):
|
||||
visibility = PermissionEnum.ONLY_ME
|
||||
datasource_provider_service.add_datasource_oauth_provider(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=datasource_provider_id,
|
||||
@ -260,6 +294,8 @@ class DatasourceOAuthCallback(Resource):
|
||||
name=oauth_response.metadata.get("name") or None,
|
||||
expire_at=oauth_response.expires_at,
|
||||
credentials=dict(oauth_response.credentials),
|
||||
user_id=user_id,
|
||||
visibility=visibility,
|
||||
)
|
||||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/oauth-callback")
|
||||
|
||||
@ -381,11 +417,12 @@ class DatasourceAuthListApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str):
|
||||
def get(self, current_tenant_id: str, user: Account):
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasources = datasource_provider_service.get_all_datasource_credentials(
|
||||
tenant_id=current_tenant_id, session=db.session()
|
||||
tenant_id=current_tenant_id, session=db.session(), user=user
|
||||
)
|
||||
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
|
||||
|
||||
@ -400,11 +437,12 @@ class DatasourceHardCodeAuthListApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str):
|
||||
def get(self, current_tenant_id: str, user: Account):
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
datasources = datasource_provider_service.get_hard_code_datasource_credentials(
|
||||
tenant_id=current_tenant_id, session=db.session()
|
||||
tenant_id=current_tenant_id, session=db.session(), user=user
|
||||
)
|
||||
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
|
||||
|
||||
|
||||
@ -67,6 +67,7 @@ from fields.base import ResponseModel
|
||||
from libs.helper import alphanumeric, dump_response, uuid_value
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.enums import PermissionEnum
|
||||
from models.provider_ids import ToolProviderID
|
||||
|
||||
# from models.provider_ids import ToolProviderID
|
||||
@ -97,6 +98,13 @@ class ToolProviderListQuery(BaseModel):
|
||||
type: Literal["builtin", "model", "api", "workflow", "mcp"] | None = None
|
||||
|
||||
|
||||
class ToolOAuthAuthorizationQuery(BaseModel):
|
||||
visibility: Literal["only_me", "all_team_members"] | None = Field(
|
||||
default=None,
|
||||
description="Visibility for the OAuth credential. Defaults to 'only_me'.",
|
||||
)
|
||||
|
||||
|
||||
class BuiltinToolCredentialDeletePayload(BaseModel):
|
||||
credential_id: str
|
||||
|
||||
@ -436,6 +444,7 @@ class WorkflowToolDetailResponse(ResponseModel):
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
ToolProviderListQuery,
|
||||
ToolOAuthAuthorizationQuery,
|
||||
UrlQuery,
|
||||
ProviderQuery,
|
||||
BuiltinCredentialListQuery,
|
||||
@ -1107,6 +1116,7 @@ class ToolLabelsApi(Resource):
|
||||
|
||||
@console_ns.route("/oauth/plugin/<path:provider>/tool/authorization-url")
|
||||
class ToolPluginOAuthApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(ToolOAuthAuthorizationQuery))
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Tool OAuth authorization URL generated successfully",
|
||||
@ -1128,9 +1138,25 @@ class ToolPluginOAuthApi(Resource):
|
||||
if oauth_client_params is None:
|
||||
raise Forbidden("no oauth available client config found for this tool provider")
|
||||
|
||||
# Visibility is chosen by the user in the frontend before the redirect,
|
||||
# then read back in the callback below when the credential is created.
|
||||
# Only ONLY_ME / ALL_TEAM are accepted; anything else falls back to
|
||||
# ONLY_ME (OAuth tokens are personal by nature).
|
||||
raw_visibility = request.args.get("visibility")
|
||||
try:
|
||||
requested_visibility = PermissionEnum(raw_visibility) if raw_visibility else PermissionEnum.ONLY_ME
|
||||
except ValueError:
|
||||
requested_visibility = PermissionEnum.ONLY_ME
|
||||
if requested_visibility not in (PermissionEnum.ONLY_ME, PermissionEnum.ALL_TEAM):
|
||||
requested_visibility = PermissionEnum.ONLY_ME
|
||||
|
||||
oauth_handler = OAuthHandler()
|
||||
context_id = OAuthProxyService.create_proxy_context(
|
||||
user_id=user.id, tenant_id=tenant_id, plugin_id=plugin_id, provider=provider_name
|
||||
user_id=user.id,
|
||||
tenant_id=tenant_id,
|
||||
plugin_id=plugin_id,
|
||||
provider=provider_name,
|
||||
extra_data={"visibility": requested_visibility.value},
|
||||
)
|
||||
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider}/tool/callback"
|
||||
authorization_url_response = oauth_handler.get_authorization_url(
|
||||
@ -1194,7 +1220,17 @@ class ToolOAuthCallback(Resource):
|
||||
if not credentials:
|
||||
raise Exception("the plugin credentials failed")
|
||||
|
||||
# add credentials to database — OAuth tokens default to only_me since they're personal
|
||||
# Visibility was chosen by the user before the redirect and stashed in
|
||||
# the proxy context. Fall back to ONLY_ME for older cookies (or for
|
||||
# anything that somehow wrote an unexpected value) — OAuth tokens are
|
||||
# personal by default.
|
||||
stored_visibility = context.get("visibility")
|
||||
try:
|
||||
visibility = PermissionEnum(stored_visibility) if stored_visibility else PermissionEnum.ONLY_ME
|
||||
except ValueError:
|
||||
visibility = PermissionEnum.ONLY_ME
|
||||
if visibility not in (PermissionEnum.ONLY_ME, PermissionEnum.ALL_TEAM):
|
||||
visibility = PermissionEnum.ONLY_ME
|
||||
BuiltinToolManageService.add_builtin_tool_provider(
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
@ -1202,7 +1238,7 @@ class ToolOAuthCallback(Resource):
|
||||
credentials=dict(credentials),
|
||||
expires_at=expires_at,
|
||||
api_type=CredentialType.OAUTH2,
|
||||
visibility="only_me",
|
||||
visibility=visibility.value,
|
||||
)
|
||||
# response-contract:ignore redirect response
|
||||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/oauth-callback")
|
||||
|
||||
@ -32,6 +32,7 @@ class PluginUploadQuery(BaseModel):
|
||||
user_id: str | None = Field(default=None, description="User identifier")
|
||||
user_from: Literal["account", "end-user"] | None = Field(default=None, description="User identity type")
|
||||
conversation_id: str | None = Field(default=None, description="Conversation identifier")
|
||||
max_size: int | None = Field(default=None, ge=0, description="Signed maximum file size in bytes")
|
||||
|
||||
|
||||
register_schema_models(files_ns, PluginUploadQuery)
|
||||
@ -113,14 +114,22 @@ class PluginUploadFileApi(Resource):
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
sign=sign,
|
||||
max_size=args.max_size,
|
||||
):
|
||||
raise Forbidden("Invalid request.")
|
||||
|
||||
try:
|
||||
if args.max_size is None:
|
||||
file_binary = file.stream.read()
|
||||
else:
|
||||
file_binary = file.stream.read(args.max_size + 1)
|
||||
if len(file_binary) > args.max_size:
|
||||
raise FileTooLargeError("File size exceeds the signed upload limit.")
|
||||
|
||||
tool_file = ToolFileManager().create_file_by_raw(
|
||||
user_id=owner_id,
|
||||
tenant_id=tenant_id,
|
||||
file_binary=file.stream.read(),
|
||||
file_binary=file_binary,
|
||||
mimetype=mimetype,
|
||||
filename=filename,
|
||||
conversation_id=args.conversation_id,
|
||||
|
||||
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from typing import Literal
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
@ -39,6 +39,7 @@ class AgentFileUploadRequestPayload(RequestRequestUploadFile):
|
||||
tenant_id: str
|
||||
user_id: str
|
||||
user_from: Literal["account", "end-user"] | None = None
|
||||
max_size: int = Field(ge=0, description="Maximum upload size in bytes")
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@ -127,6 +128,7 @@ class AgentFileUploadRequestApi(Resource):
|
||||
user_id=owner_id,
|
||||
conversation_id=payload.conversation_id,
|
||||
user_from=payload.user_from,
|
||||
max_size=payload.max_size,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise AgentFileRequestHttpError(
|
||||
|
||||
@ -39,5 +39,5 @@ def trigger_endpoint(endpoint_id: str):
|
||||
except ValueError as e:
|
||||
return jsonify({"error": "Endpoint processing failed", "message": str(e)}), 400
|
||||
except Exception:
|
||||
logger.exception("Webhook processing failed for {endpoint_id}")
|
||||
logger.exception("Webhook processing failed for %s", endpoint_id)
|
||||
return jsonify({"error": "Internal server error"}), 500
|
||||
|
||||
@ -15,7 +15,12 @@ from controllers.console.auth.error import (
|
||||
PasswordMismatchError,
|
||||
)
|
||||
from controllers.console.error import EmailSendIpLimitError
|
||||
from controllers.console.wraps import email_password_login_enabled, only_edition_enterprise, setup_required
|
||||
from controllers.console.wraps import (
|
||||
email_password_login_enabled,
|
||||
model_validate,
|
||||
only_edition_enterprise,
|
||||
setup_required,
|
||||
)
|
||||
from controllers.web import web_ns
|
||||
from extensions.ext_database import db
|
||||
from libs.helper import extract_remote_ip
|
||||
@ -54,9 +59,8 @@ class ForgotPasswordSendEmailApi(Resource):
|
||||
}
|
||||
)
|
||||
@web_ns.response(200, "Password reset email sent successfully", web_ns.models[SimpleResultDataResponse.__name__])
|
||||
def post(self):
|
||||
payload = ForgotPasswordSendPayload.model_validate(web_ns.payload or {})
|
||||
|
||||
@model_validate(ForgotPasswordSendPayload)
|
||||
def post(self, payload: ForgotPasswordSendPayload):
|
||||
request_email = payload.email
|
||||
normalized_email = request_email.lower()
|
||||
|
||||
@ -90,9 +94,8 @@ class ForgotPasswordCheckApi(Resource):
|
||||
responses={200: "Token is valid", 400: "Bad request - invalid token format", 401: "Invalid or expired token"}
|
||||
)
|
||||
@web_ns.response(200, "Token is valid", web_ns.models[VerificationTokenResponse.__name__])
|
||||
def post(self):
|
||||
payload = ForgotPasswordCheckPayload.model_validate(web_ns.payload or {})
|
||||
|
||||
@model_validate(ForgotPasswordCheckPayload)
|
||||
def post(self, payload: ForgotPasswordCheckPayload):
|
||||
user_email = payload.email.lower()
|
||||
|
||||
is_forgot_password_error_rate_limit = AccountService.is_forgot_password_error_rate_limit(user_email)
|
||||
@ -144,9 +147,8 @@ class ForgotPasswordResetApi(Resource):
|
||||
}
|
||||
)
|
||||
@web_ns.response(200, "Password reset successfully", web_ns.models[SimpleResultResponse.__name__])
|
||||
def post(self):
|
||||
payload = ForgotPasswordResetPayload.model_validate(web_ns.payload or {})
|
||||
|
||||
@model_validate(ForgotPasswordResetPayload)
|
||||
def post(self, payload: ForgotPasswordResetPayload):
|
||||
# Validate passwords match
|
||||
if payload.new_password != payload.password_confirm:
|
||||
raise PasswordMismatchError()
|
||||
|
||||
@ -262,7 +262,7 @@ class HumanInputFormApi(Resource):
|
||||
raise NotFoundError("Form not found")
|
||||
|
||||
if (recipient_type := form.recipient_type) is None:
|
||||
logger.warning("Recipient type is None for form, form_id=%", form.id)
|
||||
logger.warning("Recipient type is None for form, form_id=%s", form.id)
|
||||
raise AssertionError("Recipient type is None")
|
||||
|
||||
try:
|
||||
|
||||
@ -25,6 +25,7 @@ from controllers.console.error import AccountBannedError
|
||||
from controllers.console.wraps import (
|
||||
decrypt_code_field,
|
||||
decrypt_password_field,
|
||||
model_validate,
|
||||
only_edition_enterprise,
|
||||
setup_required,
|
||||
)
|
||||
@ -100,9 +101,9 @@ class LoginApi(Resource):
|
||||
)
|
||||
@web_ns.response(200, "Authentication successful", web_ns.models[AccessTokenResultResponse.__name__])
|
||||
@decrypt_password_field
|
||||
def post(self):
|
||||
@model_validate(LoginPayload)
|
||||
def post(self, payload: LoginPayload):
|
||||
"""Authenticate user and login."""
|
||||
payload = LoginPayload.model_validate(web_ns.payload or {})
|
||||
normalized_email = payload.email.lower()
|
||||
|
||||
try:
|
||||
@ -139,8 +140,8 @@ class LoginStatusApi(Resource):
|
||||
}
|
||||
)
|
||||
@web_ns.response(200, "Login status", web_ns.models[LoginStatusResponse.__name__])
|
||||
def get(self):
|
||||
query = LoginStatusQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@model_validate(LoginStatusQuery)
|
||||
def get(self, query: LoginStatusQuery):
|
||||
app_code = query.app_code
|
||||
user_id = query.user_id
|
||||
token = extract_webapp_access_token(request)
|
||||
@ -206,9 +207,8 @@ class EmailCodeLoginSendEmailApi(Resource):
|
||||
}
|
||||
)
|
||||
@web_ns.response(200, "Email code sent successfully", web_ns.models[SimpleResultDataResponse.__name__])
|
||||
def post(self):
|
||||
payload = EmailCodeLoginSendPayload.model_validate(web_ns.payload or {})
|
||||
|
||||
@model_validate(EmailCodeLoginSendPayload)
|
||||
def post(self, payload: EmailCodeLoginSendPayload):
|
||||
if payload.language == "zh-Hans":
|
||||
language = "zh-Hans"
|
||||
else:
|
||||
@ -242,9 +242,8 @@ class EmailCodeLoginApi(Resource):
|
||||
web_ns.models[AccessTokenResultResponse.__name__],
|
||||
)
|
||||
@decrypt_code_field
|
||||
def post(self):
|
||||
payload = EmailCodeLoginVerifyPayload.model_validate(web_ns.payload or {})
|
||||
|
||||
@model_validate(EmailCodeLoginVerifyPayload)
|
||||
def post(self, payload: EmailCodeLoginVerifyPayload):
|
||||
user_email = payload.email.lower()
|
||||
|
||||
token_data = WebAppAuthService.get_email_code_login_data(payload.token)
|
||||
|
||||
@ -24,6 +24,7 @@ from clients.agent_backend import (
|
||||
AgentBackendDeferredToolCallInternalEvent,
|
||||
AgentBackendError,
|
||||
AgentBackendInternalEventType,
|
||||
AgentBackendRunCancelledInternalEvent,
|
||||
AgentBackendRunClient,
|
||||
AgentBackendRunEventAdapter,
|
||||
AgentBackendRunFailedError,
|
||||
@ -681,6 +682,8 @@ class AgentAppRunner:
|
||||
queue_manager=queue_manager,
|
||||
model_name=model_name,
|
||||
query=query,
|
||||
session_scope=scope,
|
||||
binding_id=runtime.binding_id,
|
||||
)
|
||||
|
||||
if isinstance(terminal, AgentBackendDeferredToolCallInternalEvent):
|
||||
@ -700,6 +703,15 @@ class AgentAppRunner:
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(terminal, AgentBackendRunFailedInternalEvent | AgentBackendRunCancelledInternalEvent):
|
||||
# None means no post-exit snapshot was produced; leave the previously stored session snapshot untouched.
|
||||
if terminal.session_snapshot is not None:
|
||||
self._save_session(
|
||||
scope=scope,
|
||||
binding_id=runtime.binding_id,
|
||||
snapshot=terminal.session_snapshot,
|
||||
)
|
||||
|
||||
if not isinstance(terminal, AgentBackendRunSucceededInternalEvent):
|
||||
if isinstance(terminal, AgentBackendRunFailedInternalEvent):
|
||||
reason = terminal.reason
|
||||
@ -895,6 +907,8 @@ class AgentAppRunner:
|
||||
queue_manager: AppQueueManager,
|
||||
model_name: str,
|
||||
query: str | None,
|
||||
session_scope: AgentAppSessionScope,
|
||||
binding_id: str,
|
||||
):
|
||||
"""Consume backend events while preserving raw recorder granularity."""
|
||||
terminal = None
|
||||
@ -904,6 +918,7 @@ class AgentAppRunner:
|
||||
queue_manager=queue_manager,
|
||||
)
|
||||
text_delta_debouncer = _TextDeltaDebouncer(debounce_seconds=self._text_delta_debounce_seconds)
|
||||
last_event_id: str | None = None
|
||||
|
||||
def persist_answer_text(content_delta: str) -> None:
|
||||
try:
|
||||
@ -934,14 +949,26 @@ class AgentAppRunner:
|
||||
should_stop=queue_manager.is_stopped,
|
||||
)
|
||||
for public_event in public_events:
|
||||
if public_event.id is not None:
|
||||
last_event_id = public_event.id
|
||||
if queue_manager.is_stopped():
|
||||
flush_pending_agent_message_text()
|
||||
self._cancel_run(run_id)
|
||||
self._cancel_run(
|
||||
run_id,
|
||||
after=last_event_id,
|
||||
session_scope=session_scope,
|
||||
binding_id=binding_id,
|
||||
)
|
||||
raise GenerateTaskStoppedError()
|
||||
for internal_event in self._event_adapter.adapt(public_event):
|
||||
if queue_manager.is_stopped():
|
||||
flush_pending_agent_message_text()
|
||||
self._cancel_run(run_id)
|
||||
self._cancel_run(
|
||||
run_id,
|
||||
after=last_event_id,
|
||||
session_scope=session_scope,
|
||||
binding_id=binding_id,
|
||||
)
|
||||
raise GenerateTaskStoppedError()
|
||||
if internal_event.type in (
|
||||
AgentBackendInternalEventType.RUN_STARTED,
|
||||
@ -978,21 +1005,52 @@ class AgentAppRunner:
|
||||
raise
|
||||
except Exception as error:
|
||||
flush_pending_agent_message_text()
|
||||
self._cancel_run(run_id)
|
||||
self._cancel_run(
|
||||
run_id,
|
||||
after=last_event_id,
|
||||
session_scope=session_scope,
|
||||
binding_id=binding_id,
|
||||
)
|
||||
if queue_manager.is_stopped():
|
||||
raise GenerateTaskStoppedError() from error
|
||||
raise
|
||||
flush_pending_agent_message_text()
|
||||
if queue_manager.is_stopped():
|
||||
self._cancel_run(run_id)
|
||||
self._cancel_run(
|
||||
run_id,
|
||||
after=last_event_id,
|
||||
session_scope=session_scope,
|
||||
binding_id=binding_id,
|
||||
)
|
||||
raise GenerateTaskStoppedError()
|
||||
return terminal, process_recorder
|
||||
|
||||
def _cancel_run(self, run_id: str) -> None:
|
||||
def _cancel_run(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
after: str | None,
|
||||
session_scope: AgentAppSessionScope,
|
||||
binding_id: str,
|
||||
) -> None:
|
||||
try:
|
||||
self._agent_backend_client.cancel_run(run_id)
|
||||
public_event = self._agent_backend_client.cancel_run_and_wait(run_id, after=after)
|
||||
for internal_event in self._event_adapter.adapt(public_event):
|
||||
if (
|
||||
isinstance(internal_event, AgentBackendRunCancelledInternalEvent)
|
||||
and internal_event.session_snapshot is not None
|
||||
):
|
||||
self._save_session(
|
||||
scope=session_scope,
|
||||
binding_id=binding_id,
|
||||
snapshot=internal_event.session_snapshot,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to cancel stopped Agent App backend run: run_id=%s", run_id, exc_info=True)
|
||||
logger.warning(
|
||||
"Failed to finish cancelling stopped Agent App backend run: run_id=%s",
|
||||
run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _publish_answer(
|
||||
self, *, queue_manager: AppQueueManager, model_name: str, answer: str, query: str | None
|
||||
|
||||
@ -29,6 +29,7 @@ from clients.agent_backend import (
|
||||
)
|
||||
from configs import dify_config
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
|
||||
from core.app.llm.model_access import resolve_model_context_window
|
||||
from core.plugin.provider_identity import normalize_plugin_daemon_provider_identity
|
||||
from core.workflow.nodes.agent_v2.dify_tools_builder import (
|
||||
WorkflowAgentDifyToolLayersBuilder,
|
||||
@ -129,6 +130,11 @@ class AgentAppRuntimeRequestBuilder:
|
||||
append_runtime_warnings(metadata, config_warnings)
|
||||
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
|
||||
knowledge_config = build_knowledge_layer_config(agent_soul)
|
||||
context_window_tokens = resolve_model_context_window(
|
||||
run_context=context.dify_context,
|
||||
provider_name=agent_soul.model.model_provider,
|
||||
model_name=agent_soul.model.model,
|
||||
)
|
||||
model_plugin_id, model_provider = normalize_plugin_daemon_provider_identity(
|
||||
ModelProviderID(agent_soul.model.model_provider),
|
||||
agent_soul.model.plugin_id,
|
||||
@ -141,6 +147,7 @@ class AgentAppRuntimeRequestBuilder:
|
||||
model_provider=model_provider,
|
||||
model=agent_soul.model.model,
|
||||
model_settings=agent_soul.model.model_settings.model_dump(mode="json", exclude_none=True),
|
||||
context_window_tokens=context_window_tokens,
|
||||
),
|
||||
execution_context=DifyExecutionContextLayerConfig(
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
|
||||
@ -8,6 +8,7 @@ from enum import Enum, auto
|
||||
|
||||
from configs import dify_config
|
||||
from extensions.ext_redis import redis_client
|
||||
from graphon.graph_engine.command_channels import RedisChannel
|
||||
from graphon.graph_engine.manager import GraphEngineManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -20,6 +21,11 @@ class AppExecutionState(Enum):
|
||||
TERMINAL = auto()
|
||||
|
||||
|
||||
def app_task_command_channel_key(task_id: str) -> str:
|
||||
"""Redis key of the GraphEngine command channel for one app task."""
|
||||
return f"workflow:{task_id}:commands"
|
||||
|
||||
|
||||
def set_app_task_stop_flag(task_id: str) -> None:
|
||||
if not task_id:
|
||||
return
|
||||
@ -27,6 +33,40 @@ def set_app_task_stop_flag(task_id: str) -> None:
|
||||
redis_client.setex(f"generate_task_stopped:{task_id}", 600, 1)
|
||||
|
||||
|
||||
def clear_app_task_cancellation_signals(task_id: str) -> None:
|
||||
"""Discard cancellation signals left over from earlier attempts of one task.
|
||||
|
||||
Both cancellation channels are keyed by task ID and outlive the attempt that
|
||||
armed them: the stop flag lives for 600 seconds and a queued ``AbortCommand``
|
||||
for an hour, and neither is consumed while no engine is running. A resumed
|
||||
workflow deliberately reuses the paused run's task ID, so without this reset
|
||||
it inherits those signals and aborts itself as soon as it starts. Call this
|
||||
only when starting a new attempt that is meant to run, never mid-execution.
|
||||
"""
|
||||
if not task_id:
|
||||
return
|
||||
|
||||
try:
|
||||
redis_client.delete(f"generate_task_stopped:{task_id}")
|
||||
except Exception:
|
||||
logger.exception("Failed to clear stop flag for app task %s", task_id)
|
||||
|
||||
channel_key = app_task_command_channel_key(task_id)
|
||||
try:
|
||||
# fetch_commands() drains the queue and its pending marker together; the
|
||||
# explicit delete covers a queue whose marker was already consumed.
|
||||
discarded = RedisChannel(redis_client, channel_key).fetch_commands()
|
||||
redis_client.delete(channel_key)
|
||||
if discarded:
|
||||
logger.info(
|
||||
"Discarded %s stale GraphEngine command(s) for app task %s",
|
||||
len(discarded),
|
||||
task_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to clear pending GraphEngine commands for app task %s", task_id)
|
||||
|
||||
|
||||
class AppExecutionCoordinator:
|
||||
"""Own cancellation policy for one app execution attempt.
|
||||
|
||||
|
||||
@ -654,8 +654,6 @@ class PipelineGenerator(BaseAppGenerator):
|
||||
except Exception as e:
|
||||
logger.exception("Unknown Error when generating")
|
||||
queue_manager.publish_error(e, PublishFrom.APPLICATION_MANAGER)
|
||||
finally:
|
||||
db.session.close()
|
||||
|
||||
def _handle_response(
|
||||
self,
|
||||
|
||||
@ -4,6 +4,7 @@ from collections.abc import Sequence
|
||||
from typing import cast
|
||||
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
from core.app.apps.execution_coordinator import app_task_command_channel_key
|
||||
from core.app.apps.workflow.app_config_manager import WorkflowAppConfig
|
||||
from core.app.apps.workflow.command_channels import (
|
||||
CelerySignalCommandChannel,
|
||||
@ -153,7 +154,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
|
||||
# RUN WORKFLOW
|
||||
# Create Redis command channel for this workflow execution
|
||||
task_id = self.application_generate_entity.task_id
|
||||
channel_key = f"workflow:{task_id}:commands"
|
||||
channel_key = app_task_command_channel_key(task_id)
|
||||
celery_signal_channel = CelerySignalCommandChannel(
|
||||
shutdown_state_getter=celery_warm_shutdown_started,
|
||||
abort_reason=WORKFLOW_WARM_SHUTDOWN_ABORT_REASON,
|
||||
|
||||
@ -9,7 +9,7 @@ from core.errors.error import ProviderTokenNotInitError
|
||||
from core.model_manager import ModelInstance, ModelManager
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager
|
||||
from core.provider_manager import ProviderManager
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.entities.model_entities import ModelPropertyKey, ModelType
|
||||
from graphon.nodes.llm.entities import ModelConfig
|
||||
from graphon.nodes.llm.exc import LLMModeRequiredError, ModelNotExistError
|
||||
from graphon.nodes.llm.protocols import CredentialsProvider
|
||||
@ -128,6 +128,27 @@ def build_dify_model_access(run_context: DifyRunContext) -> tuple[CredentialsPro
|
||||
)
|
||||
|
||||
|
||||
def resolve_model_context_window(
|
||||
*,
|
||||
run_context: DifyRunContext,
|
||||
provider_name: str,
|
||||
model_name: str,
|
||||
) -> int | None:
|
||||
"""Return the selected model's credential-bound context-window capability.
|
||||
|
||||
The ``ModelInstance`` and its schema are resolved with the current
|
||||
tenant/user ``DifyRunContext``. A positive, non-boolean plugin-declared
|
||||
``CONTEXT_SIZE`` is returned; a missing or invalid value returns ``None``.
|
||||
Model lookup and schema errors propagate. This function does not infer a
|
||||
window from the model name or fall back to a model registry or cache.
|
||||
"""
|
||||
model_instance = DifyModelFactory(run_context=run_context).init_model_instance(provider_name, model_name)
|
||||
context_window = model_instance.get_model_schema().model_properties.get(ModelPropertyKey.CONTEXT_SIZE)
|
||||
if isinstance(context_window, bool) or not isinstance(context_window, int) or context_window <= 0:
|
||||
return None
|
||||
return context_window
|
||||
|
||||
|
||||
def _normalize_completion_params(completion_params: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
||||
"""
|
||||
Split node-level completion params into provider parameters and stop sequences.
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.parse
|
||||
@ -95,6 +96,7 @@ def get_signed_file_uri_for_plugin(
|
||||
user_id: str,
|
||||
conversation_id: str | None = None,
|
||||
user_from: Literal["account", "end-user"] | None = None,
|
||||
max_size: int | None = None,
|
||||
) -> str:
|
||||
"""Build a signed plugin-upload URI without selecting a network origin."""
|
||||
|
||||
@ -109,6 +111,7 @@ def get_signed_file_uri_for_plugin(
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
user_from=user_from,
|
||||
max_size=max_size,
|
||||
)
|
||||
sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
|
||||
encoded_sign = base64.urlsafe_b64encode(sign).decode()
|
||||
@ -123,6 +126,8 @@ def get_signed_file_uri_for_plugin(
|
||||
query_params["conversation_id"] = conversation_id
|
||||
if user_from is not None:
|
||||
query_params["user_from"] = user_from
|
||||
if max_size is not None:
|
||||
query_params["max_size"] = str(max_size)
|
||||
query = urllib.parse.urlencode(query_params)
|
||||
return f"/files/upload/for-plugin?{query}"
|
||||
|
||||
@ -138,6 +143,7 @@ def verify_plugin_file_signature(
|
||||
timestamp: str,
|
||||
nonce: str,
|
||||
sign: str,
|
||||
max_size: int | None = None,
|
||||
) -> bool:
|
||||
"""Verify the signature used by the plugin-facing file upload endpoint."""
|
||||
|
||||
@ -150,6 +156,7 @@ def verify_plugin_file_signature(
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
user_from=user_from,
|
||||
max_size=max_size,
|
||||
)
|
||||
recalculated_sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
|
||||
recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode()
|
||||
@ -171,14 +178,34 @@ def _plugin_upload_signature_payload(
|
||||
timestamp: str,
|
||||
nonce: str,
|
||||
user_from: Literal["account", "end-user"] | None,
|
||||
max_size: int | None,
|
||||
) -> str:
|
||||
"""Build the compatible upload signature payload with optional identity ownership.
|
||||
"""Build the compatible upload signature payload with optional protected claims.
|
||||
|
||||
Omitting ``user_from`` preserves the legacy payload. When present, the
|
||||
identity kind is appended and HMAC-protected so account/end-user ownership
|
||||
cannot be altered.
|
||||
Omitting ``max_size`` preserves the legacy payload. Size-limited tickets use
|
||||
a versioned JSON payload so unconstrained string fields cannot absorb or
|
||||
impersonate optional trailing claims.
|
||||
"""
|
||||
|
||||
if max_size is not None:
|
||||
return json.dumps(
|
||||
{
|
||||
"conversation_id": conversation_id or "",
|
||||
"filename": filename,
|
||||
"max_size": max_size,
|
||||
"mimetype": mimetype,
|
||||
"nonce": nonce,
|
||||
"tenant_id": tenant_id,
|
||||
"timestamp": timestamp,
|
||||
"user_from": user_from,
|
||||
"user_id": user_id,
|
||||
"version": 2,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
payload = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{conversation_id or ''}|{timestamp}|{nonce}"
|
||||
if user_from is not None:
|
||||
payload = f"{payload}|{user_from}"
|
||||
|
||||
@ -47,6 +47,7 @@ from core.workflow.nodes.agent.plugin_strategy_adapter import (
|
||||
from core.workflow.nodes.agent.runtime_support import AgentRuntimeSupport
|
||||
from core.workflow.nodes.agent_v2 import DifyAgentNode
|
||||
from core.workflow.nodes.agent_v2.binding_resolver import WorkflowAgentBindingResolver
|
||||
from core.workflow.nodes.agent_v2.discriminator import is_dify_agent_node_data
|
||||
from core.workflow.nodes.agent_v2.output_adapter import WorkflowAgentOutputAdapter
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import WorkflowAgentRuntimeRequestBuilder
|
||||
from core.workflow.nodes.human_input.callback import DifyHITLCallback
|
||||
@ -134,12 +135,27 @@ def get_node_type_classes_mapping() -> Mapping[NodeType, Mapping[str, type[Node]
|
||||
return Node.get_node_type_classes_mapping()
|
||||
|
||||
|
||||
def resolve_workflow_node_class(*, node_type: NodeType, node_version: str) -> type[Node]:
|
||||
def resolve_workflow_node_class(
|
||||
*,
|
||||
node_type: NodeType,
|
||||
node_version: str,
|
||||
node_data: Mapping[str, Any] | BaseNodeData | None = None,
|
||||
) -> type[Node]:
|
||||
"""Resolve the production node class for the requested type/version."""
|
||||
node_mapping = get_node_type_classes_mapping().get(node_type)
|
||||
if not node_mapping:
|
||||
raise ValueError(f"No class mapping found for node type: {node_type}")
|
||||
|
||||
# Historical Agent nodes used version=2 for their tool-parameter format.
|
||||
# Only the explicit kind marker identifies the newer Dify Agent node.
|
||||
if (
|
||||
node_data is not None
|
||||
and node_type == BuiltinNodeTypes.AGENT
|
||||
and node_version == "2"
|
||||
and not is_dify_agent_node_data(node_data)
|
||||
):
|
||||
node_version = "1"
|
||||
|
||||
latest_node_class = node_mapping.get(LATEST_VERSION)
|
||||
matched_node_class = node_mapping.get(node_version)
|
||||
node_class = matched_node_class or latest_node_class
|
||||
@ -400,7 +416,11 @@ class DifyNodeFactory(NodeFactory):
|
||||
typed_node_config = NodeConfigDictAdapter.validate_python(adapted_node_config)
|
||||
node_id = typed_node_config["id"]
|
||||
node_data = typed_node_config["data"]
|
||||
node_class = self._resolve_node_class(node_type=node_data.type, node_version=str(node_data.version))
|
||||
node_class = self._resolve_node_class(
|
||||
node_type=node_data.type,
|
||||
node_version=str(node_data.version),
|
||||
node_data=node_data,
|
||||
)
|
||||
# Graph configs are initially validated against permissive shared node data.
|
||||
# Re-validate using the resolved node class so workflow-local node schemas
|
||||
# stay explicit and constructors receive the concrete typed payload.
|
||||
@ -493,10 +513,19 @@ class DifyNodeFactory(NodeFactory):
|
||||
return node_data
|
||||
|
||||
@staticmethod
|
||||
def _resolve_node_class(*, node_type: NodeType, node_version: str) -> type[Node]:
|
||||
def _resolve_node_class(
|
||||
*,
|
||||
node_type: NodeType,
|
||||
node_version: str,
|
||||
node_data: Mapping[str, Any] | BaseNodeData | None = None,
|
||||
) -> type[Node]:
|
||||
if node_type == BuiltinNodeTypes.LLM:
|
||||
return DifyLLMNode
|
||||
return resolve_workflow_node_class(node_type=node_type, node_version=node_version)
|
||||
return resolve_workflow_node_class(
|
||||
node_type=node_type,
|
||||
node_version=node_version,
|
||||
node_data=node_data,
|
||||
)
|
||||
|
||||
def _resolve_llm_model_reference(self, node_data: LLMNodeData) -> LLMNodeData:
|
||||
"""Resolve an optional shared model selector from the workflow variable pool."""
|
||||
|
||||
@ -340,6 +340,17 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
process_data=process_data,
|
||||
metadata=metadata,
|
||||
)
|
||||
# None means no post-exit snapshot was produced; leave the previously stored session snapshot untouched.
|
||||
if (
|
||||
isinstance(terminal_event, AgentBackendRunFailedInternalEvent | AgentBackendRunCancelledInternalEvent)
|
||||
and terminal_event.session_snapshot is not None
|
||||
):
|
||||
self._save_session_snapshot(
|
||||
session_scope=session_scope,
|
||||
binding_id=stored_session.binding_id,
|
||||
snapshot=terminal_event.session_snapshot,
|
||||
metadata=metadata,
|
||||
)
|
||||
if exhausted is not None:
|
||||
# Streaming error / unexpected end — surface immediately without
|
||||
# retrying because the failure is transport-level.
|
||||
@ -516,16 +527,20 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
- ``terminal_event``: the first non-stream/non-started internal event,
|
||||
or ``None`` if the stream ended without one.
|
||||
- ``transport_failure``: a populated ``StreamCompletedEvent`` when the
|
||||
stream itself errored (backend/HTTP/protocol fault). Mutually
|
||||
exclusive with ``terminal_event``.
|
||||
stream itself errored (backend/HTTP/protocol fault). A cancellation
|
||||
terminal may accompany it so the caller can persist the final session
|
||||
snapshot while preserving the original transport failure.
|
||||
"""
|
||||
stream_event_count = 0
|
||||
last_event_id: str | None = None
|
||||
try:
|
||||
for public_event in self._agent_backend_client.stream_events(
|
||||
run_id,
|
||||
should_stop=self._is_graph_aborted,
|
||||
):
|
||||
stream_event_count += 1
|
||||
if public_event.id is not None:
|
||||
last_event_id = public_event.id
|
||||
for internal_event in self._event_adapter.adapt(public_event):
|
||||
if internal_event.type == AgentBackendInternalEventType.RUN_STARTED:
|
||||
continue
|
||||
@ -552,8 +567,12 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
| AgentBackendDeferredToolCallInternalEvent,
|
||||
):
|
||||
return internal_event, None
|
||||
self._cancel_backend_run(run_id, reason="unexpected_event")
|
||||
return None, self._failure_event(
|
||||
cancellation = self._cancel_backend_run(
|
||||
run_id,
|
||||
reason="unexpected_event",
|
||||
after=last_event_id,
|
||||
)
|
||||
return cancellation, self._failure_event(
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
metadata=metadata,
|
||||
@ -561,8 +580,12 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
error_type="agent_backend_stream_error",
|
||||
)
|
||||
except AgentBackendError as error:
|
||||
self._cancel_backend_run(run_id, reason=self._stream_stop_reason())
|
||||
return None, self._failure_event(
|
||||
cancellation = self._cancel_backend_run(
|
||||
run_id,
|
||||
reason=self._stream_stop_reason(),
|
||||
after=last_event_id,
|
||||
)
|
||||
return cancellation, self._failure_event(
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
metadata=metadata,
|
||||
@ -570,8 +593,12 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
error_type=self._agent_backend_error_type(error),
|
||||
)
|
||||
except Exception as error:
|
||||
self._cancel_backend_run(run_id, reason=self._stream_stop_reason())
|
||||
return None, self._failure_event(
|
||||
cancellation = self._cancel_backend_run(
|
||||
run_id,
|
||||
reason=self._stream_stop_reason(),
|
||||
after=last_event_id,
|
||||
)
|
||||
return cancellation, self._failure_event(
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
metadata=metadata,
|
||||
@ -579,8 +606,12 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
error_type="agent_backend_stream_error",
|
||||
)
|
||||
|
||||
self._cancel_backend_run(run_id, reason="stream_ended_without_terminal_event")
|
||||
return None, None
|
||||
cancellation = self._cancel_backend_run(
|
||||
run_id,
|
||||
reason=self._stream_stop_reason() if self._is_graph_aborted() else "stream_ended_without_terminal_event",
|
||||
after=last_event_id,
|
||||
)
|
||||
return cancellation, None
|
||||
|
||||
def _is_graph_aborted(self) -> bool:
|
||||
"""Let Agent SSE consumption observe GraphEngine's cooperative abort state."""
|
||||
@ -592,14 +623,25 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
def _stream_stop_reason(self) -> str:
|
||||
return "workflow_graph_aborted" if self._is_graph_aborted() else "event_stream_failed"
|
||||
|
||||
def _cancel_backend_run(self, run_id: str, *, reason: str) -> None:
|
||||
def _cancel_backend_run(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
reason: str,
|
||||
after: str | None,
|
||||
) -> AgentBackendRunCancelledInternalEvent | None:
|
||||
try:
|
||||
self._agent_backend_client.cancel_run(
|
||||
public_event = self._agent_backend_client.cancel_run_and_wait(
|
||||
run_id,
|
||||
CancelRunRequest(reason=reason, message="Workflow Agent event consumption stopped"),
|
||||
after=after,
|
||||
)
|
||||
for internal_event in self._event_adapter.adapt(public_event):
|
||||
if isinstance(internal_event, AgentBackendRunCancelledInternalEvent):
|
||||
return internal_event
|
||||
except Exception:
|
||||
logger.warning("Failed to cancel Workflow Agent backend run: run_id=%s", run_id, exc_info=True)
|
||||
logger.warning("Failed to finish cancelling Workflow Agent backend run: run_id=%s", run_id, exc_info=True)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _record_type_check_metadata(metadata: dict[str, Any], outcome: OutputTypeCheckOutcome) -> None:
|
||||
|
||||
28
api/core/workflow/nodes/agent_v2/discriminator.py
Normal file
28
api/core/workflow/nodes/agent_v2/discriminator.py
Normal file
@ -0,0 +1,28 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from graphon.entities.base_node_data import BaseNodeData
|
||||
from graphon.enums import BuiltinNodeTypes
|
||||
|
||||
AGENT_NODE_KIND = "dify_agent"
|
||||
AGENT_NODE_VERSION = "2"
|
||||
|
||||
|
||||
def is_dify_agent_node_data(node_data: Mapping[str, Any] | BaseNodeData) -> bool:
|
||||
"""Return whether node data explicitly identifies the new Dify Agent node."""
|
||||
|
||||
if isinstance(node_data, Mapping):
|
||||
node_type = node_data.get("type")
|
||||
node_version = node_data.get("version")
|
||||
agent_node_kind = node_data.get("agent_node_kind")
|
||||
else:
|
||||
serialized_node_data = node_data.model_dump(mode="python")
|
||||
node_type = serialized_node_data.get("type")
|
||||
node_version = serialized_node_data.get("version")
|
||||
agent_node_kind = serialized_node_data.get("agent_node_kind")
|
||||
|
||||
return (
|
||||
node_type == BuiltinNodeTypes.AGENT
|
||||
and str(node_version) == AGENT_NODE_VERSION
|
||||
and agent_node_kind == AGENT_NODE_KIND
|
||||
)
|
||||
@ -8,7 +8,7 @@ from graphon.enums import BuiltinNodeTypes, NodeType
|
||||
|
||||
class DifyAgentNodeData(BaseNodeData):
|
||||
type: NodeType = BuiltinNodeTypes.AGENT
|
||||
agent_node_kind: Literal["dify_agent"] = "dify_agent"
|
||||
agent_node_kind: Literal["dify_agent"]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_version(self) -> "DifyAgentNodeData":
|
||||
|
||||
@ -47,6 +47,7 @@ from clients.agent_backend import (
|
||||
)
|
||||
from configs import dify_config
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
|
||||
from core.app.llm.model_access import resolve_model_context_window
|
||||
from core.plugin.provider_identity import normalize_plugin_daemon_provider_identity
|
||||
from core.workflow.system_variables import SystemVariableKey, get_system_text, get_system_value
|
||||
from graphon.file import File, FileTransferMethod
|
||||
@ -211,6 +212,11 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
|
||||
soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
|
||||
knowledge_config = build_knowledge_layer_config(agent_soul)
|
||||
context_window_tokens = resolve_model_context_window(
|
||||
run_context=context.dify_context,
|
||||
provider_name=agent_soul.model.model_provider,
|
||||
model_name=agent_soul.model.model,
|
||||
)
|
||||
model_plugin_id, model_provider = normalize_plugin_daemon_provider_identity(
|
||||
ModelProviderID(agent_soul.model.model_provider),
|
||||
agent_soul.model.plugin_id,
|
||||
@ -223,6 +229,7 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
model_provider=model_provider,
|
||||
model=agent_soul.model.model,
|
||||
model_settings=agent_soul.model.model_settings.model_dump(mode="json", exclude_none=True),
|
||||
context_window_tokens=context_window_tokens,
|
||||
),
|
||||
# The execution-context layer is now the only public protocol
|
||||
# carrier for Dify tenant/user/run identifiers. ``user_id`` and
|
||||
|
||||
@ -20,6 +20,7 @@ from models.model import UploadFile
|
||||
from models.workflow import Workflow
|
||||
from services.agent.knowledge_datasets import list_missing_tenant_knowledge_dataset_ids
|
||||
|
||||
from .discriminator import is_dify_agent_node_data
|
||||
from .entities import DifyAgentNodeData
|
||||
|
||||
|
||||
@ -243,7 +244,7 @@ class WorkflowAgentNodeValidator:
|
||||
node_data = node.get("data")
|
||||
if not isinstance(node_id, str) or not isinstance(node_data, Mapping):
|
||||
continue
|
||||
if node_data.get("type") == BuiltinNodeTypes.AGENT and str(node_data.get("version")) == "2":
|
||||
if is_dify_agent_node_data(node_data):
|
||||
yield node_id, node_data
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -215,7 +215,11 @@ class WorkflowEntry:
|
||||
if node_type in {BuiltinNodeTypes.LOOP, BuiltinNodeTypes.ITERATION}:
|
||||
raise ValueError("Loop and Iteration nodes must use their engine-backed debug endpoints")
|
||||
node_version = str(node_config_data.version)
|
||||
node_cls = resolve_workflow_node_class(node_type=node_type, node_version=node_version)
|
||||
node_cls = resolve_workflow_node_class(
|
||||
node_type=node_type,
|
||||
node_version=node_version,
|
||||
node_data=node_config_data,
|
||||
)
|
||||
|
||||
# init graph context and runtime state
|
||||
run_context = build_dify_run_context(
|
||||
|
||||
@ -173,6 +173,7 @@ def init_app(app: DifyApp) -> Celery:
|
||||
"tasks.regenerate_summary_index_task", # summary index regeneration
|
||||
"tasks.initialize_created_app_rbac_access_task", # app access initialization
|
||||
"tasks.install_default_plugins_task", # tenant default plugin installation
|
||||
"tasks.new_agent_beta_task", # New Agent Beta eligibility checks
|
||||
"tasks.refresh_billing_vector_space_task", # billing vector-space cache refresh
|
||||
"tasks.app_generate.resume_agent_app_task", # ENG-635: Agent v2 chat ask_human resume
|
||||
"tasks.workflow_run_archive_download_tasks", # workflow-run archive download preparation
|
||||
|
||||
@ -9701,6 +9701,7 @@ Initiate OAuth login process
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| credential_id | query | Credential ID to reauthorize | No | string |
|
||||
| visibility | query | Visibility for the credential to be created. Accepts 'only_me' or 'all_team_members'; any other value falls back to 'only_me'. Ignored on reauthorization (credential_id set). | No | string |
|
||||
| provider_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
@ -9714,6 +9715,7 @@ Initiate OAuth login process
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| visibility | query | Visibility for the OAuth credential. Defaults to 'only_me'. | No | string, <br>**Available values:** "all_team_members", "only_me" |
|
||||
| provider | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
@ -19184,6 +19186,7 @@ Model class for provider custom model configuration.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| credential_id | string | Credential ID to reauthorize | No |
|
||||
| visibility | string | Visibility for the credential to be created. Accepts 'only_me' or 'all_team_members'; any other value falls back to 'only_me'. Ignored on reauthorization (credential_id set). | No |
|
||||
|
||||
#### DatasourceOAuthCallbackQuery
|
||||
|
||||
@ -26779,6 +26782,12 @@ Tool label
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| ToolLabelListResponse | array | | |
|
||||
|
||||
#### ToolOAuthAuthorizationQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| visibility | string | Visibility for the OAuth credential. Defaults to 'only_me'. | No |
|
||||
|
||||
#### ToolOAuthCustomClientPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@ -448,6 +448,7 @@ class AccountService:
|
||||
interface_theme: str = "light",
|
||||
is_setup: bool | None = False,
|
||||
timezone: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
*,
|
||||
session: Session,
|
||||
) -> Account:
|
||||
@ -500,6 +501,7 @@ class AccountService:
|
||||
interface_language=interface_language,
|
||||
interface_theme=interface_theme,
|
||||
timezone=resolved_timezone,
|
||||
last_login_ip=ip_address,
|
||||
)
|
||||
|
||||
session.add(account)
|
||||
@ -513,6 +515,7 @@ class AccountService:
|
||||
interface_language: str,
|
||||
password: str | None = None,
|
||||
timezone: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
*,
|
||||
session: Session,
|
||||
) -> Account:
|
||||
@ -523,6 +526,7 @@ class AccountService:
|
||||
interface_language=interface_language,
|
||||
password=password,
|
||||
timezone=timezone,
|
||||
ip_address=ip_address,
|
||||
session=session,
|
||||
)
|
||||
|
||||
@ -2009,10 +2013,10 @@ class RegisterService:
|
||||
interface_language=get_valid_language(language),
|
||||
password=password,
|
||||
is_setup=True,
|
||||
ip_address=ip_address,
|
||||
session=session,
|
||||
)
|
||||
|
||||
account.last_login_ip = ip_address
|
||||
account.initialized_at = naive_utc_now()
|
||||
|
||||
TenantService.create_owner_tenant_if_not_exist(account=account, is_setup=True, session=session)
|
||||
@ -2048,6 +2052,7 @@ class RegisterService:
|
||||
is_setup: bool | None = False,
|
||||
create_workspace_required: bool | None = True,
|
||||
timezone: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
*,
|
||||
session: Session,
|
||||
) -> Account:
|
||||
@ -2062,6 +2067,7 @@ class RegisterService:
|
||||
password=password,
|
||||
is_setup=is_setup,
|
||||
timezone=timezone,
|
||||
ip_address=ip_address,
|
||||
session=session,
|
||||
)
|
||||
account.status = status or AccountStatus.ACTIVE
|
||||
|
||||
@ -21,6 +21,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from core.workflow.nodes.agent_v2.discriminator import is_dify_agent_node_data
|
||||
from models.agent_config_entities import (
|
||||
AgentSoulConfig,
|
||||
DeclaredOutputConfig,
|
||||
@ -97,7 +98,7 @@ def previous_node_output_candidates(
|
||||
continue
|
||||
|
||||
declared: list[DeclaredOutputConfig] | None = None
|
||||
if kind == "agent" and str(data.get("version", "")) == "2":
|
||||
if is_dify_agent_node_data(data):
|
||||
declared = declared_outputs_loader(nid)
|
||||
if declared is not None:
|
||||
for output in declared:
|
||||
|
||||
@ -67,6 +67,7 @@ from services.entities.agent_entities import (
|
||||
WorkflowNodeJobConfig,
|
||||
)
|
||||
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
|
||||
from tasks.new_agent_beta_task import register_new_agent_beta_publish_after_commit
|
||||
|
||||
# WorkflowAgentNodeBinding.workflow_version tag for the draft workflow row.
|
||||
# Mirrors Workflow.version when it is "draft" (see models/workflow.py).
|
||||
@ -683,6 +684,12 @@ class AgentComposerService:
|
||||
app.enable_api = True
|
||||
app.updated_by = account_id
|
||||
session.flush()
|
||||
register_new_agent_beta_publish_after_commit(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
snapshot_id=version.id,
|
||||
)
|
||||
return {
|
||||
"result": "success",
|
||||
"active_config_snapshot_id": version.id,
|
||||
|
||||
@ -18,8 +18,8 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.workflow.nodes.agent_v2.discriminator import is_dify_agent_node_data
|
||||
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator
|
||||
from graphon.enums import BuiltinNodeTypes
|
||||
from models import Account
|
||||
from models.agent import (
|
||||
APP_BACKED_AGENT_SOURCES,
|
||||
@ -636,7 +636,7 @@ class AgentDslService:
|
||||
|
||||
def is_agent_v2_graph(graph: Mapping[str, Any]) -> bool:
|
||||
return any(
|
||||
node.get("data", {}).get("type") == BuiltinNodeTypes.AGENT and node.get("data", {}).get("version") == "2"
|
||||
isinstance(node.get("data"), Mapping) and is_dify_agent_node_data(node["data"])
|
||||
for node in graph.get("nodes", [])
|
||||
if isinstance(node, Mapping)
|
||||
)
|
||||
|
||||
@ -208,6 +208,10 @@ class BillingService:
|
||||
# Cache TTL: 10 minutes
|
||||
_PLAN_CACHE_TTL = 600
|
||||
|
||||
@classmethod
|
||||
def ensure_new_agent_beta_revision(cls, revision_id: str) -> None:
|
||||
cls._send_request("POST", f"/new-agent-beta/revisions/{revision_id}/ensure")
|
||||
|
||||
@classmethod
|
||||
def get_info(cls, tenant_id: str, exclude_vector_space: bool = False) -> BillingInfo:
|
||||
params = {"tenant_id": tenant_id}
|
||||
|
||||
@ -22,6 +22,7 @@ from core.tools.utils.encryption import ProviderConfigCache, ProviderConfigEncry
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from graphon.model_runtime.entities.provider_entities import FormType
|
||||
from models.enums import PermissionEnum
|
||||
from models.oauth import DatasourceOauthParamConfig, DatasourceOauthTenantParamConfig, DatasourceProvider
|
||||
from models.provider_ids import DatasourceProviderID
|
||||
|
||||
@ -649,10 +650,23 @@ class DatasourceProviderService:
|
||||
avatar_url: str | None,
|
||||
expire_at: int,
|
||||
credentials: dict[str, Any],
|
||||
user_id: str | None = None,
|
||||
visibility: PermissionEnum | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
add datasource oauth provider
|
||||
add datasource oauth provider.
|
||||
|
||||
``user_id`` is the creator whose visibility choice this credential is
|
||||
scoped to. When ``visibility`` is only_me the row is visible only to
|
||||
this creator; ``all_team_members`` shares it workspace-wide.
|
||||
Callers that omit ``user_id``/``visibility`` fall back to the previous
|
||||
team-wide default (matches how the DB column defaults).
|
||||
"""
|
||||
# partial_members isn't supported for plugin credentials (matches the
|
||||
# tool + api-key paths); collapse it to ALL_TEAM so we never persist
|
||||
# an unreachable value here.
|
||||
if visibility == PermissionEnum.PARTIAL_TEAM:
|
||||
visibility = PermissionEnum.ALL_TEAM
|
||||
credential_type = CredentialType.OAUTH2
|
||||
with sessionmaker(bind=db.engine).begin() as session:
|
||||
lock = f"datasource_provider_create_lock:{tenant_id}_{provider_id}_{credential_type.value}"
|
||||
@ -699,16 +713,21 @@ class DatasourceProviderService:
|
||||
if key in provider_credential_secret_variables:
|
||||
credentials[key] = encrypter.encrypt_token(tenant_id, value)
|
||||
|
||||
datasource_provider = DatasourceProvider(
|
||||
tenant_id=tenant_id,
|
||||
name=db_provider_name,
|
||||
provider=provider_id.provider_name,
|
||||
plugin_id=provider_id.plugin_id,
|
||||
auth_type=credential_type.value,
|
||||
encrypted_credentials=credentials,
|
||||
avatar_url=avatar_url or "default",
|
||||
expires_at=expire_at,
|
||||
)
|
||||
datasource_provider_kwargs: dict[str, Any] = {
|
||||
"tenant_id": tenant_id,
|
||||
"name": db_provider_name,
|
||||
"provider": provider_id.provider_name,
|
||||
"plugin_id": provider_id.plugin_id,
|
||||
"auth_type": credential_type.value,
|
||||
"encrypted_credentials": credentials,
|
||||
"avatar_url": avatar_url or "default",
|
||||
"expires_at": expire_at,
|
||||
}
|
||||
if user_id is not None:
|
||||
datasource_provider_kwargs["user_id"] = user_id
|
||||
if visibility is not None:
|
||||
datasource_provider_kwargs["visibility"] = visibility
|
||||
datasource_provider = DatasourceProvider(**datasource_provider_kwargs)
|
||||
session.add(datasource_provider)
|
||||
|
||||
def add_datasource_api_key_provider(
|
||||
@ -886,11 +905,17 @@ class DatasourceProviderService:
|
||||
|
||||
return copy_credentials_list
|
||||
|
||||
def get_all_datasource_credentials(self, tenant_id: str, *, session: Session) -> list[dict]:
|
||||
def get_all_datasource_credentials(
|
||||
self, tenant_id: str, *, session: Session, user: "Account | None" = None
|
||||
) -> list[dict]:
|
||||
"""
|
||||
get datasource credentials.
|
||||
|
||||
:return:
|
||||
``user`` is threaded through to ``list_datasource_credentials`` so the
|
||||
embedded ``credentials_list`` per datasource is filtered by
|
||||
per-credential visibility. Callers that omit it (background /
|
||||
maintenance jobs) get the pre-visibility behavior of returning every
|
||||
credential in the workspace.
|
||||
"""
|
||||
# get all plugin providers
|
||||
manager = PluginDatasourceManager()
|
||||
@ -902,6 +927,7 @@ class DatasourceProviderService:
|
||||
tenant_id=tenant_id,
|
||||
provider=datasource.provider,
|
||||
plugin_id=datasource.plugin_id,
|
||||
user=user,
|
||||
session=session,
|
||||
)
|
||||
redirect_uri = (
|
||||
@ -945,11 +971,14 @@ class DatasourceProviderService:
|
||||
)
|
||||
return datasource_credentials
|
||||
|
||||
def get_hard_code_datasource_credentials(self, tenant_id: str, *, session: Session) -> list[dict]:
|
||||
def get_hard_code_datasource_credentials(
|
||||
self, tenant_id: str, *, session: Session, user: "Account | None" = None
|
||||
) -> list[dict]:
|
||||
"""
|
||||
get hard code datasource credentials.
|
||||
|
||||
:return:
|
||||
``user`` is threaded through to ``list_datasource_credentials`` so
|
||||
credentials in the returned envelope are visibility-filtered.
|
||||
"""
|
||||
# get all plugin providers
|
||||
manager = PluginDatasourceManager()
|
||||
@ -967,6 +996,7 @@ class DatasourceProviderService:
|
||||
tenant_id=tenant_id,
|
||||
provider=datasource.provider,
|
||||
plugin_id=datasource.plugin_id,
|
||||
user=user,
|
||||
session=session,
|
||||
)
|
||||
redirect_uri = "{}/console/api/oauth/plugin/{}/datasource/callback".format(
|
||||
|
||||
@ -59,15 +59,12 @@ from core.workflow.nodes.agent_v2.binding_resolver import (
|
||||
WorkflowAgentBindingError,
|
||||
WorkflowAgentBindingResolver,
|
||||
)
|
||||
from core.workflow.nodes.agent_v2.discriminator import is_dify_agent_node_data
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import (
|
||||
WorkflowAgentRuntimeRequestBuilder,
|
||||
)
|
||||
from factories.file_factory.builders import build_from_mapping
|
||||
from graphon.enums import (
|
||||
BuiltinNodeTypes,
|
||||
WorkflowExecutionStatus,
|
||||
WorkflowNodeExecutionStatus,
|
||||
)
|
||||
from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
|
||||
from graphon.file import helpers as file_helpers
|
||||
from models import App
|
||||
from models.agent_config_entities import DeclaredOutputConfig, DeclaredOutputType
|
||||
@ -182,18 +179,12 @@ class _ResolvedDeclaration:
|
||||
|
||||
|
||||
def _is_agent_v2_node(node: Mapping[str, Any]) -> bool:
|
||||
"""A graph node is Agent v2 iff its ``data.type`` is the AGENT builtin
|
||||
AND its ``data.version`` is ``"2"``.
|
||||
"""Return whether a graph node explicitly identifies the new Dify Agent node."""
|
||||
|
||||
``BuiltinNodeTypes.AGENT`` is a ``ClassVar[NodeType]`` (plain string), not
|
||||
a StrEnum, so we compare against it directly without ``.value``.
|
||||
"""
|
||||
data = node.get("data") or {}
|
||||
if not isinstance(data, Mapping):
|
||||
return False
|
||||
if data.get("type") != BuiltinNodeTypes.AGENT:
|
||||
return False
|
||||
return str(data.get("version", "")) == "2"
|
||||
return is_dify_agent_node_data(data)
|
||||
|
||||
|
||||
def _graph_nodes(workflow_run: WorkflowRun) -> list[Mapping[str, Any]]:
|
||||
|
||||
@ -12,6 +12,7 @@ from sqlalchemy import Engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
|
||||
from core.app.apps.execution_coordinator import clear_app_task_cancellation_signals
|
||||
from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
|
||||
from core.app.apps.workflow.app_generator import WorkflowAppGenerator
|
||||
from core.app.entities.app_invoke_entities import (
|
||||
@ -558,6 +559,12 @@ def _resume_app_execution(payload: dict[str, Any]) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
# The resumed attempt reuses the paused run's task ID, so cancellation
|
||||
# signals armed against that ID before or during the pause would abort it
|
||||
# immediately and report it as stopped by the user. This attempt is starting
|
||||
# deliberately, so drop them before any engine can observe them.
|
||||
clear_app_task_cancellation_signals(generate_entity.task_id)
|
||||
|
||||
workflow_run_repo.resume_workflow_pause(workflow_run_id, pause_entity)
|
||||
|
||||
pause_config = PauseStateLayerConfig(
|
||||
|
||||
114
api/tasks/new_agent_beta_task.py
Normal file
114
api/tasks/new_agent_beta_task.py
Normal file
@ -0,0 +1,114 @@
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy import event, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from enums import DeploymentEdition
|
||||
from models.agent import AgentConfigRevision, AgentConfigRevisionOperation
|
||||
from services.billing_service import BillingService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_RETRIES = 8
|
||||
_RETRY_DELAY_SECONDS = 30
|
||||
_MAX_RETRY_DELAY_SECONDS = 900
|
||||
NEW_AGENT_BETA_QUEUE = "new_agent_beta"
|
||||
|
||||
|
||||
def _is_publish_in_activity_window(published_at: datetime) -> bool:
|
||||
start = dify_config.NEW_AGENT_BETA_ACTIVITY_START_AT
|
||||
end = dify_config.NEW_AGENT_BETA_ACTIVITY_END_AT
|
||||
if start is None or end is None or start.tzinfo is None or end.tzinfo is None or start >= end:
|
||||
logger.error("New Agent Beta Publish window must be a valid RFC3339 interval")
|
||||
return False
|
||||
if published_at.tzinfo is None:
|
||||
published_at = published_at.replace(tzinfo=UTC)
|
||||
else:
|
||||
published_at = published_at.astimezone(UTC)
|
||||
return start.astimezone(UTC) <= published_at < end.astimezone(UTC)
|
||||
|
||||
|
||||
def register_new_agent_beta_publish_after_commit(
|
||||
*, session: Session, tenant_id: str, agent_id: str, snapshot_id: str
|
||||
) -> None:
|
||||
"""Best-effort registration that never changes the Publish result."""
|
||||
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
|
||||
return
|
||||
|
||||
try:
|
||||
revision = session.scalar(
|
||||
select(AgentConfigRevision)
|
||||
.where(
|
||||
AgentConfigRevision.tenant_id == tenant_id,
|
||||
AgentConfigRevision.agent_id == agent_id,
|
||||
AgentConfigRevision.current_snapshot_id == snapshot_id,
|
||||
AgentConfigRevision.operation == AgentConfigRevisionOperation.PUBLISH_DRAFT,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if revision is None:
|
||||
logger.error(
|
||||
"New Agent Beta publish revision was not found, tenant_id=%s, agent_id=%s, snapshot_id=%s",
|
||||
tenant_id,
|
||||
agent_id,
|
||||
snapshot_id,
|
||||
)
|
||||
return
|
||||
if not _is_publish_in_activity_window(revision.created_at):
|
||||
return
|
||||
cancelled = False
|
||||
|
||||
def cancel_on_rollback(_session: Session) -> None:
|
||||
nonlocal cancelled
|
||||
cancelled = True
|
||||
|
||||
def dispatch_after_commit(_session: Session) -> None:
|
||||
if not cancelled:
|
||||
schedule_new_agent_beta_ensure(revision.id)
|
||||
|
||||
event.listen(session, "after_rollback", cancel_on_rollback, once=True)
|
||||
event.listen(session, "after_commit", dispatch_after_commit, once=True)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to register New Agent Beta publish event, tenant_id=%s, agent_id=%s, snapshot_id=%s",
|
||||
tenant_id,
|
||||
agent_id,
|
||||
snapshot_id,
|
||||
)
|
||||
|
||||
|
||||
@shared_task(
|
||||
queue=NEW_AGENT_BETA_QUEUE,
|
||||
bind=True,
|
||||
max_retries=_MAX_RETRIES,
|
||||
default_retry_delay=_RETRY_DELAY_SECONDS,
|
||||
acks_late=True,
|
||||
reject_on_worker_lost=True,
|
||||
)
|
||||
def ensure_new_agent_beta_participation_task(self, revision_id: str) -> None:
|
||||
try:
|
||||
BillingService.ensure_new_agent_beta_revision(revision_id)
|
||||
except Exception as exc:
|
||||
if self.request.retries >= _MAX_RETRIES:
|
||||
logger.exception("New Agent Beta eligibility retry budget exhausted, revision_id=%s", revision_id)
|
||||
raise
|
||||
|
||||
logger.warning(
|
||||
"New Agent Beta eligibility request failed, scheduling retry %d/%d, revision_id=%s",
|
||||
self.request.retries + 1,
|
||||
_MAX_RETRIES,
|
||||
revision_id,
|
||||
exc_info=True,
|
||||
)
|
||||
countdown = min(_RETRY_DELAY_SECONDS * (2**self.request.retries), _MAX_RETRY_DELAY_SECONDS)
|
||||
raise self.retry(exc=exc, countdown=countdown)
|
||||
|
||||
|
||||
def schedule_new_agent_beta_ensure(revision_id: str) -> None:
|
||||
try:
|
||||
ensure_new_agent_beta_participation_task.delay(revision_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to dispatch New Agent Beta eligibility task, revision_id=%s", revision_id)
|
||||
@ -133,7 +133,12 @@ def seeded_run(
|
||||
"nodes": [
|
||||
{
|
||||
"id": "agent-node-1",
|
||||
"data": {"type": "agent", "version": "2", "title": "My Agent"},
|
||||
"data": {
|
||||
"type": "agent",
|
||||
"version": "2",
|
||||
"agent_node_kind": "dify_agent",
|
||||
"title": "My Agent",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "tool-node-1",
|
||||
@ -328,7 +333,14 @@ def test_snapshot_404s_for_published_run_per_decision_d1(flask_req_ctx, fake_app
|
||||
def test_snapshot_surfaces_type_check_failure_from_metadata(flask_req_ctx, fake_app_model):
|
||||
"""Per-output ``TYPE_CHECK_FAILED`` derived from the metadata blob the
|
||||
Stage 4 §5 stack records on the execution row."""
|
||||
graph = {"nodes": [{"id": "agent-1", "data": {"type": "agent", "version": "2"}}]}
|
||||
graph = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "agent-1",
|
||||
"data": {"type": "agent", "version": "2", "agent_node_kind": "dify_agent"},
|
||||
}
|
||||
]
|
||||
}
|
||||
workflow_run = _make_workflow_run(app_id=fake_app_model.id, tenant_id=fake_app_model.tenant_id, graph=graph)
|
||||
execution = _make_execution(
|
||||
app_id=fake_app_model.id,
|
||||
@ -375,7 +387,14 @@ def test_snapshot_surfaces_type_check_failure_from_metadata(flask_req_ctx, fake_
|
||||
def test_snapshot_surfaces_output_check_failure_from_metadata(flask_req_ctx, fake_app_model):
|
||||
"""When ``output_type_check.passed`` but ``output_check.passed=False``, the
|
||||
output is flagged ``OUTPUT_CHECK_FAILED``."""
|
||||
graph = {"nodes": [{"id": "agent-1", "data": {"type": "agent", "version": "2"}}]}
|
||||
graph = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "agent-1",
|
||||
"data": {"type": "agent", "version": "2", "agent_node_kind": "dify_agent"},
|
||||
}
|
||||
]
|
||||
}
|
||||
workflow_run = _make_workflow_run(app_id=fake_app_model.id, tenant_id=fake_app_model.tenant_id, graph=graph)
|
||||
execution = _make_execution(
|
||||
app_id=fake_app_model.id,
|
||||
@ -470,7 +489,14 @@ def test_keeps_latest_execution_per_node_by_index(flask_req_ctx, fake_app_model)
|
||||
"""Multiple executions for the same node_id → service keeps the highest
|
||||
``index`` (matches the agent_v2 retry pattern that re-emits node
|
||||
executions)."""
|
||||
graph = {"nodes": [{"id": "agent-1", "data": {"type": "agent", "version": "2"}}]}
|
||||
graph = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "agent-1",
|
||||
"data": {"type": "agent", "version": "2", "agent_node_kind": "dify_agent"},
|
||||
}
|
||||
]
|
||||
}
|
||||
workflow_run = _make_workflow_run(app_id=fake_app_model.id, tenant_id=fake_app_model.tenant_id, graph=graph)
|
||||
older = _make_execution(
|
||||
app_id=fake_app_model.id,
|
||||
|
||||
@ -1010,6 +1010,7 @@ class TestAppDslService:
|
||||
"data": {
|
||||
"type": BuiltinNodeTypes.AGENT,
|
||||
"version": "2",
|
||||
"agent_node_kind": "dify_agent",
|
||||
"agent_binding": {
|
||||
"binding_type": WorkflowAgentBindingType.ROSTER_AGENT.value,
|
||||
AGENT_PACKAGE_REF_KEY: "agent_1",
|
||||
@ -1021,6 +1022,7 @@ class TestAppDslService:
|
||||
"data": {
|
||||
"type": BuiltinNodeTypes.AGENT,
|
||||
"version": "2",
|
||||
"agent_node_kind": "dify_agent",
|
||||
"agent_binding": {
|
||||
"binding_type": WorkflowAgentBindingType.INLINE_AGENT.value,
|
||||
AGENT_PACKAGE_REF_KEY: "agent_1",
|
||||
|
||||
@ -9,6 +9,8 @@ from dify_agent.protocol import (
|
||||
CancelRunResponse,
|
||||
CreateRunRequest,
|
||||
CreateRunResponse,
|
||||
RunCancelledEvent,
|
||||
RunCancelledEventData,
|
||||
RunEvent,
|
||||
RunStartedEvent,
|
||||
RunStatusResponse,
|
||||
@ -51,6 +53,7 @@ def _request() -> CreateRunRequest:
|
||||
|
||||
class _SuccessfulClient:
|
||||
stream_options: tuple[int | None, object, Callable[[], bool] | None] | None = None
|
||||
cancel_after: str | None = None
|
||||
|
||||
def create_run_sync(self, request: CreateRunRequest) -> CreateRunResponse:
|
||||
assert isinstance(request, CreateRunRequest)
|
||||
@ -60,6 +63,21 @@ class _SuccessfulClient:
|
||||
del request
|
||||
return CancelRunResponse(run_id=run_id, status="cancelled")
|
||||
|
||||
def cancel_run_and_wait_sync(
|
||||
self,
|
||||
run_id: str,
|
||||
request: CancelRunRequest | None = None,
|
||||
*,
|
||||
after: str | None = None,
|
||||
) -> RunCancelledEvent:
|
||||
self.cancel_after = after
|
||||
request = request or CancelRunRequest()
|
||||
return RunCancelledEvent(
|
||||
id="2-0",
|
||||
run_id=run_id,
|
||||
data=RunCancelledEventData(reason=request.reason, message=request.message),
|
||||
)
|
||||
|
||||
def stream_events_sync(
|
||||
self,
|
||||
run_id: str,
|
||||
@ -94,13 +112,20 @@ def test_dify_agent_backend_run_client_delegates_sync_methods() -> None:
|
||||
|
||||
created = client.create_run(_request())
|
||||
cancelled = client.cancel_run(created.run_id)
|
||||
cancelled_event = client.cancel_run_and_wait(
|
||||
created.run_id,
|
||||
CancelRunRequest(reason="stopped"),
|
||||
after="1-0",
|
||||
)
|
||||
events = list(client.stream_events(created.run_id, should_stop=should_stop))
|
||||
status = client.wait_run(created.run_id)
|
||||
|
||||
assert created.run_id == "run-1"
|
||||
assert cancelled.status == "cancelled"
|
||||
assert cancelled_event.data.reason == "stopped"
|
||||
assert events[0].type == "run_started"
|
||||
assert status.status == "succeeded"
|
||||
assert wrapped.cancel_after == "1-0"
|
||||
assert wrapped.stream_options == (2, _STREAM_TIMEOUT_UNSET, should_stop)
|
||||
|
||||
|
||||
|
||||
19
api/tests/unit_tests/configs/_isolated_settings.py
Normal file
19
api/tests/unit_tests/configs/_isolated_settings.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""Test-only settings source that ignores process and dotenv configuration."""
|
||||
|
||||
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource
|
||||
|
||||
|
||||
class InitSettingsOnly:
|
||||
"""Mixin for settings tests whose inputs should be entirely explicit."""
|
||||
|
||||
@classmethod
|
||||
def settings_customise_sources(
|
||||
cls,
|
||||
settings_cls: type[BaseSettings],
|
||||
init_settings: PydanticBaseSettingsSource,
|
||||
env_settings: PydanticBaseSettingsSource,
|
||||
dotenv_settings: PydanticBaseSettingsSource,
|
||||
file_secret_settings: PydanticBaseSettingsSource,
|
||||
) -> tuple[PydanticBaseSettingsSource, ...]:
|
||||
del settings_cls, env_settings, dotenv_settings, file_secret_settings
|
||||
return (init_settings,)
|
||||
@ -1,80 +1,63 @@
|
||||
import os
|
||||
from typing import override
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from packaging.version import Version
|
||||
from pydantic import SecretStr
|
||||
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource
|
||||
from yarl import URL
|
||||
|
||||
from configs.app_config import DifyConfig
|
||||
from enums import DeploymentEdition
|
||||
|
||||
|
||||
def _clear_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name in tuple(os.environ):
|
||||
monkeypatch.delenv(name)
|
||||
class _IsolatedDifyConfig(DifyConfig):
|
||||
"""Load explicit test values and packaging metadata without consulting process state."""
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def settings_customise_sources(
|
||||
cls,
|
||||
settings_cls: type[BaseSettings],
|
||||
init_settings: PydanticBaseSettingsSource,
|
||||
env_settings: PydanticBaseSettingsSource,
|
||||
dotenv_settings: PydanticBaseSettingsSource,
|
||||
file_secret_settings: PydanticBaseSettingsSource,
|
||||
) -> tuple[PydanticBaseSettingsSource, ...]:
|
||||
production_sources = super().settings_customise_sources(
|
||||
settings_cls,
|
||||
init_settings,
|
||||
env_settings,
|
||||
dotenv_settings,
|
||||
file_secret_settings,
|
||||
)
|
||||
return init_settings, production_sources[-1]
|
||||
|
||||
|
||||
def _set_basic_config_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_clear_environment(monkeypatch)
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
|
||||
monkeypatch.setenv("DB_TYPE", "postgresql")
|
||||
monkeypatch.setenv("DB_USERNAME", "postgres")
|
||||
monkeypatch.setenv("DB_PASSWORD", "postgres")
|
||||
monkeypatch.setenv("DB_HOST", "localhost")
|
||||
monkeypatch.setenv("DB_PORT", "5432")
|
||||
monkeypatch.setenv("DB_DATABASE", "dify")
|
||||
def _make_config(**values: object) -> DifyConfig:
|
||||
return _IsolatedDifyConfig(**values)
|
||||
|
||||
|
||||
def test_dify_config_keeps_secret_key_empty_when_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
_set_basic_config_env(monkeypatch)
|
||||
monkeypatch.delenv("SECRET_KEY", raising=False)
|
||||
monkeypatch.setenv("OPENDAL_FS_ROOT", str(tmp_path))
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
def test_dify_config_keeps_secret_key_empty_when_missing(tmp_path) -> None:
|
||||
config = _make_config(OPENDAL_FS_ROOT=str(tmp_path))
|
||||
|
||||
assert config.SECRET_KEY == ""
|
||||
assert not hasattr(config, "OPENDAL_FS_ROOT")
|
||||
assert not (tmp_path / ".dify_secret_key").exists()
|
||||
|
||||
|
||||
def test_dify_config_preserves_explicit_secret_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
_set_basic_config_env(monkeypatch)
|
||||
monkeypatch.setenv("SECRET_KEY", "explicit")
|
||||
monkeypatch.setenv("OPENDAL_FS_ROOT", str(tmp_path))
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
def test_dify_config_preserves_explicit_secret_key(tmp_path) -> None:
|
||||
config = _make_config(SECRET_KEY="explicit", OPENDAL_FS_ROOT=str(tmp_path))
|
||||
|
||||
assert config.SECRET_KEY == "explicit"
|
||||
assert not (tmp_path / ".dify_secret_key").exists()
|
||||
|
||||
|
||||
def test_dify_config(monkeypatch: pytest.MonkeyPatch):
|
||||
# clear system environment variables
|
||||
_clear_environment(monkeypatch)
|
||||
|
||||
# Set environment variables using monkeypatch
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
|
||||
monkeypatch.setenv("HTTP_REQUEST_MAX_WRITE_TIMEOUT", "30") # Custom value for testing
|
||||
monkeypatch.setenv("DB_TYPE", "postgresql")
|
||||
monkeypatch.setenv("DB_USERNAME", "postgres")
|
||||
monkeypatch.setenv("DB_PASSWORD", "postgres")
|
||||
monkeypatch.setenv("DB_HOST", "localhost")
|
||||
monkeypatch.setenv("DB_PORT", "5432")
|
||||
monkeypatch.setenv("DB_DATABASE", "dify")
|
||||
monkeypatch.setenv("HTTP_REQUEST_MAX_READ_TIMEOUT", "300") # Custom value for testing
|
||||
|
||||
# load dotenv file with pydantic-settings
|
||||
# Disable `.env` loading to ensure test stability across environments
|
||||
config = DifyConfig(_env_file=None)
|
||||
def test_dify_config():
|
||||
config = _make_config(
|
||||
HTTP_REQUEST_MAX_WRITE_TIMEOUT="30",
|
||||
HTTP_REQUEST_MAX_READ_TIMEOUT="300",
|
||||
)
|
||||
|
||||
# constant values
|
||||
assert config.COMMIT_SHA == ""
|
||||
@ -106,93 +89,66 @@ def test_dify_config(monkeypatch: pytest.MonkeyPatch):
|
||||
pytest.param("pässwörd-🔐", "pässwörd-🔐", id="unicode"),
|
||||
],
|
||||
)
|
||||
def test_init_password_defaults_to_empty_and_preserves_environment_value(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
def test_init_password_defaults_to_empty_and_preserves_explicit_value(
|
||||
environment_value: str | None,
|
||||
expected: str,
|
||||
) -> None:
|
||||
_set_basic_config_env(monkeypatch)
|
||||
if environment_value is None:
|
||||
monkeypatch.delenv("INIT_PASSWORD", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("INIT_PASSWORD", environment_value)
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
values = {} if environment_value is None else {"INIT_PASSWORD": environment_value}
|
||||
config = _make_config(**values)
|
||||
|
||||
assert expected == config.INIT_PASSWORD
|
||||
|
||||
|
||||
@pytest.mark.parametrize("edition", list(DeploymentEdition))
|
||||
def test_deployment_edition_is_loaded_from_environment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
edition: DeploymentEdition,
|
||||
) -> None:
|
||||
_set_basic_config_env(monkeypatch)
|
||||
monkeypatch.setenv("DEPLOYMENT_EDITION", edition.value)
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
def test_deployment_edition_accepts_every_supported_value(edition: DeploymentEdition) -> None:
|
||||
config = _make_config(DEPLOYMENT_EDITION=edition.value)
|
||||
|
||||
assert config.DEPLOYMENT_EDITION is edition
|
||||
|
||||
|
||||
def test_new_user_default_plugin_ids_are_parsed_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_basic_config_env(monkeypatch)
|
||||
monkeypatch.setenv(
|
||||
"NEW_USER_DEFAULT_PLUGIN_IDS",
|
||||
"langgenius/openai, langgenius/gemini",
|
||||
def test_new_user_default_plugin_ids_are_parsed() -> None:
|
||||
config = _make_config(
|
||||
NEW_USER_DEFAULT_PLUGIN_IDS="langgenius/openai, langgenius/gemini",
|
||||
)
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
|
||||
assert config.NEW_USER_DEFAULT_PLUGIN_ID_LIST == [
|
||||
"langgenius/openai",
|
||||
"langgenius/gemini",
|
||||
]
|
||||
|
||||
|
||||
def test_turnstile_config_is_parsed_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_basic_config_env(monkeypatch)
|
||||
monkeypatch.setenv("TURNSTILE_SECRET_KEY", " test-secret ")
|
||||
monkeypatch.setenv("TURNSTILE_ALLOWED_HOSTNAMES", "dify.dev, Login.Example.COM. ")
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
def test_turnstile_config_is_parsed() -> None:
|
||||
config = _make_config(
|
||||
TURNSTILE_SECRET_KEY=" test-secret ",
|
||||
TURNSTILE_ALLOWED_HOSTNAMES="dify.dev, Login.Example.COM. ",
|
||||
)
|
||||
|
||||
assert isinstance(config.TURNSTILE_SECRET_KEY, SecretStr)
|
||||
assert config.TURNSTILE_SECRET_KEY.get_secret_value() == "test-secret"
|
||||
assert frozenset({"dify.dev", "login.example.com"}) == config.TURNSTILE_ALLOWED_HOSTNAME_SET
|
||||
|
||||
|
||||
def test_plugin_remote_install_port_rejects_host_port_spec(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_plugin_remote_install_port_rejects_host_port_spec() -> None:
|
||||
"""A 'host:port' compose publish spec must produce an actionable error, not an opaque int_parsing traceback."""
|
||||
_set_basic_config_env(monkeypatch)
|
||||
monkeypatch.setenv("PLUGIN_REMOTE_INSTALL_PORT", "127.0.0.1:5003")
|
||||
|
||||
with pytest.raises(ValueError, match="must be a bare port number"):
|
||||
DifyConfig(_env_file=None)
|
||||
_make_config(PLUGIN_REMOTE_INSTALL_PORT="127.0.0.1:5003")
|
||||
|
||||
|
||||
def test_plugin_remote_install_port_accepts_bare_port(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_basic_config_env(monkeypatch)
|
||||
monkeypatch.setenv("PLUGIN_REMOTE_INSTALL_PORT", "5003")
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
def test_plugin_remote_install_port_accepts_bare_port() -> None:
|
||||
config = _make_config(PLUGIN_REMOTE_INSTALL_PORT="5003")
|
||||
|
||||
assert config.PLUGIN_REMOTE_INSTALL_PORT == 5003
|
||||
|
||||
|
||||
def test_new_user_default_models_are_parsed_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_basic_config_env(monkeypatch)
|
||||
monkeypatch.setenv(
|
||||
"NEW_USER_DEFAULT_MODELS",
|
||||
(
|
||||
def test_new_user_default_models_are_parsed() -> None:
|
||||
config = _make_config(
|
||||
NEW_USER_DEFAULT_MODELS=(
|
||||
"llm:langgenius/openai/openai:gpt-4o-mini, "
|
||||
"text-embedding:langgenius/openai/openai:text-embedding-3-small, "
|
||||
"rerank:langgenius/ollama/ollama:reranker:latest"
|
||||
),
|
||||
)
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
|
||||
assert config.NEW_USER_DEFAULT_MODEL_LIST == [
|
||||
("llm", "langgenius/openai/openai", "gpt-4o-mini"),
|
||||
("text-embedding", "langgenius/openai/openai", "text-embedding-3-small"),
|
||||
@ -200,34 +156,20 @@ def test_new_user_default_models_are_parsed_from_env(monkeypatch: pytest.MonkeyP
|
||||
]
|
||||
|
||||
|
||||
def test_new_user_default_models_reject_duplicate_model_types(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_basic_config_env(monkeypatch)
|
||||
monkeypatch.setenv(
|
||||
"NEW_USER_DEFAULT_MODELS",
|
||||
"llm:langgenius/openai/openai:gpt-4o-mini,llm:langgenius/anthropic/anthropic:claude-sonnet-4",
|
||||
def test_new_user_default_models_reject_duplicate_model_types() -> None:
|
||||
config = _make_config(
|
||||
NEW_USER_DEFAULT_MODELS=(
|
||||
"llm:langgenius/openai/openai:gpt-4o-mini,llm:langgenius/anthropic/anthropic:claude-sonnet-4"
|
||||
),
|
||||
)
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
|
||||
with pytest.raises(ValueError, match="duplicate model type: llm"):
|
||||
_ = config.NEW_USER_DEFAULT_MODEL_LIST
|
||||
|
||||
|
||||
def test_http_timeout_defaults(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_http_timeout_defaults():
|
||||
"""Test that HTTP timeout defaults are correctly set"""
|
||||
# clear system environment variables
|
||||
_clear_environment(monkeypatch)
|
||||
|
||||
# Set minimal required env vars
|
||||
monkeypatch.setenv("DB_TYPE", "postgresql")
|
||||
monkeypatch.setenv("DB_USERNAME", "postgres")
|
||||
monkeypatch.setenv("DB_PASSWORD", "postgres")
|
||||
monkeypatch.setenv("DB_HOST", "localhost")
|
||||
monkeypatch.setenv("DB_PORT", "5432")
|
||||
monkeypatch.setenv("DB_DATABASE", "dify")
|
||||
|
||||
# Disable `.env` loading to ensure test stability across environments
|
||||
config = DifyConfig(_env_file=None)
|
||||
config = _make_config()
|
||||
|
||||
# Verify default timeout values
|
||||
assert config.HTTP_REQUEST_MAX_CONNECT_TIMEOUT == 10
|
||||
@ -235,56 +177,43 @@ def test_http_timeout_defaults(monkeypatch: pytest.MonkeyPatch):
|
||||
assert config.HTTP_REQUEST_MAX_WRITE_TIMEOUT == 600
|
||||
|
||||
|
||||
def test_internal_files_url_falls_back_to_server_console_api_url(monkeypatch: pytest.MonkeyPatch):
|
||||
_clear_environment(monkeypatch)
|
||||
monkeypatch.setenv("SERVER_CONSOLE_API_URL", "http://api:5001")
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
def test_internal_files_url_falls_back_to_server_console_api_url():
|
||||
config = _make_config(SERVER_CONSOLE_API_URL="http://api:5001")
|
||||
|
||||
assert config.INTERNAL_FILES_URL == "http://api:5001"
|
||||
|
||||
|
||||
def test_internal_files_url_prefers_explicit_value(monkeypatch: pytest.MonkeyPatch):
|
||||
_clear_environment(monkeypatch)
|
||||
monkeypatch.setenv("INTERNAL_FILES_URL", "http://files-internal:5001")
|
||||
monkeypatch.setenv("SERVER_CONSOLE_API_URL", "http://api:5001")
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
def test_internal_files_url_prefers_explicit_value():
|
||||
config = _make_config(
|
||||
INTERNAL_FILES_URL="http://files-internal:5001",
|
||||
SERVER_CONSOLE_API_URL="http://api:5001",
|
||||
)
|
||||
|
||||
assert config.INTERNAL_FILES_URL == "http://files-internal:5001"
|
||||
|
||||
|
||||
def test_empty_files_url_overrides_console_api_url_for_relative_browser_uris(monkeypatch: pytest.MonkeyPatch):
|
||||
_clear_environment(monkeypatch)
|
||||
monkeypatch.setenv("FILES_URL", "")
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "http://api:5001")
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
def test_empty_files_url_overrides_console_api_url_for_relative_browser_uris():
|
||||
config = _make_config(FILES_URL="", CONSOLE_API_URL="http://api:5001")
|
||||
|
||||
assert config.FILES_URL == ""
|
||||
|
||||
|
||||
# NOTE: If there is a `.env` file in your Workspace, this test might not succeed as expected.
|
||||
# This is due to `pymilvus` loading all the variables from the `.env` file into `os.environ`.
|
||||
def test_flask_configs(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_flask_configs():
|
||||
flask_app = Flask("app")
|
||||
# clear system environment variables
|
||||
_clear_environment(monkeypatch)
|
||||
|
||||
# Set environment variables using monkeypatch
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
|
||||
monkeypatch.setenv("DB_TYPE", "postgresql")
|
||||
monkeypatch.setenv("DB_USERNAME", "postgres")
|
||||
monkeypatch.setenv("DB_PASSWORD", "postgres")
|
||||
monkeypatch.setenv("DB_HOST", "localhost")
|
||||
monkeypatch.setenv("DB_PORT", "5432")
|
||||
monkeypatch.setenv("DB_DATABASE", "dify")
|
||||
monkeypatch.setenv("WEB_API_CORS_ALLOW_ORIGINS", "http://127.0.0.1:3000,*")
|
||||
monkeypatch.setenv("CODE_EXECUTION_ENDPOINT", "http://127.0.0.1:8194/")
|
||||
|
||||
# Disable `.env` loading to ensure test stability across environments
|
||||
flask_app.config.from_mapping(DifyConfig(_env_file=None).model_dump())
|
||||
flask_app.config.from_mapping(
|
||||
_make_config(
|
||||
CONSOLE_API_URL="https://example.com",
|
||||
CONSOLE_WEB_URL="https://example.com",
|
||||
DB_TYPE="postgresql",
|
||||
DB_USERNAME="postgres",
|
||||
DB_PASSWORD="postgres",
|
||||
DB_HOST="localhost",
|
||||
DB_PORT="5432",
|
||||
DB_DATABASE="dify",
|
||||
WEB_API_CORS_ALLOW_ORIGINS="http://127.0.0.1:3000,*",
|
||||
CODE_EXECUTION_ENDPOINT="http://127.0.0.1:8194/",
|
||||
).model_dump()
|
||||
)
|
||||
config = flask_app.config
|
||||
|
||||
# configs read from pydantic-settings
|
||||
@ -321,147 +250,67 @@ def test_flask_configs(monkeypatch: pytest.MonkeyPatch):
|
||||
assert str(URL(str(config["CODE_EXECUTION_ENDPOINT"])) / "v1") == "http://127.0.0.1:8194/v1"
|
||||
|
||||
|
||||
def test_inner_api_config_exist(monkeypatch: pytest.MonkeyPatch):
|
||||
# Set environment variables using monkeypatch
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
|
||||
monkeypatch.setenv("DB_TYPE", "postgresql")
|
||||
monkeypatch.setenv("DB_USERNAME", "postgres")
|
||||
monkeypatch.setenv("DB_PASSWORD", "postgres")
|
||||
monkeypatch.setenv("DB_HOST", "localhost")
|
||||
monkeypatch.setenv("DB_PORT", "5432")
|
||||
monkeypatch.setenv("DB_DATABASE", "dify")
|
||||
monkeypatch.setenv("INNER_API_KEY", "test-inner-api-key")
|
||||
|
||||
config = DifyConfig()
|
||||
def test_inner_api_config_exist():
|
||||
config = _make_config(INNER_API_KEY="test-inner-api-key")
|
||||
assert config.INNER_API is False
|
||||
assert isinstance(config.INNER_API_KEY, str)
|
||||
assert len(config.INNER_API_KEY) > 0
|
||||
|
||||
|
||||
def test_db_extras_options_merging(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_db_extras_options_merging():
|
||||
"""Test that DB_EXTRAS options are merged with the default timezone startup option."""
|
||||
# Set environment variables
|
||||
monkeypatch.setenv("DB_TYPE", "postgresql")
|
||||
monkeypatch.setenv("DB_USERNAME", "postgres")
|
||||
monkeypatch.setenv("DB_PASSWORD", "postgres")
|
||||
monkeypatch.setenv("DB_HOST", "localhost")
|
||||
monkeypatch.setenv("DB_PORT", "5432")
|
||||
monkeypatch.setenv("DB_DATABASE", "dify")
|
||||
monkeypatch.setenv("DB_EXTRAS", "options=-c search_path=myschema")
|
||||
|
||||
# Create config
|
||||
config = DifyConfig()
|
||||
config = _make_config(DB_EXTRAS="options=-c search_path=myschema")
|
||||
|
||||
options = config.SQLALCHEMY_ENGINE_OPTIONS["connect_args"]["options"]
|
||||
assert "search_path=myschema" in options
|
||||
assert "timezone=UTC" in options
|
||||
|
||||
|
||||
def test_db_session_timezone_override_can_disable_app_level_timezone_injection(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("DB_TYPE", "postgresql")
|
||||
monkeypatch.setenv("DB_USERNAME", "postgres")
|
||||
monkeypatch.setenv("DB_PASSWORD", "postgres")
|
||||
monkeypatch.setenv("DB_HOST", "localhost")
|
||||
monkeypatch.setenv("DB_PORT", "5432")
|
||||
monkeypatch.setenv("DB_DATABASE", "dify")
|
||||
monkeypatch.setenv("DB_EXTRAS", "options=-c search_path=myschema")
|
||||
monkeypatch.setenv("DB_SESSION_TIMEZONE_OVERRIDE", "")
|
||||
|
||||
config = DifyConfig()
|
||||
def test_db_session_timezone_override_can_disable_app_level_timezone_injection():
|
||||
config = _make_config(
|
||||
DB_EXTRAS="options=-c search_path=myschema",
|
||||
DB_SESSION_TIMEZONE_OVERRIDE="",
|
||||
)
|
||||
|
||||
assert config.SQLALCHEMY_ENGINE_OPTIONS["connect_args"] == {
|
||||
"options": "-c search_path=myschema",
|
||||
}
|
||||
|
||||
|
||||
def test_pubsub_redis_url_default(monkeypatch: pytest.MonkeyPatch):
|
||||
_clear_environment(monkeypatch)
|
||||
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
|
||||
monkeypatch.setenv("DB_USERNAME", "postgres")
|
||||
monkeypatch.setenv("DB_PASSWORD", "postgres")
|
||||
monkeypatch.setenv("DB_HOST", "localhost")
|
||||
monkeypatch.setenv("DB_PORT", "5432")
|
||||
monkeypatch.setenv("DB_DATABASE", "dify")
|
||||
monkeypatch.setenv("REDIS_HOST", "redis.example.com")
|
||||
monkeypatch.setenv("REDIS_PORT", "6380")
|
||||
monkeypatch.setenv("REDIS_USERNAME", "user")
|
||||
monkeypatch.setenv("REDIS_PASSWORD", "pass@word")
|
||||
monkeypatch.setenv("REDIS_DB", "2")
|
||||
monkeypatch.setenv("REDIS_USE_SSL", "true")
|
||||
|
||||
config = DifyConfig()
|
||||
def test_pubsub_redis_url_default():
|
||||
config = _make_config(
|
||||
REDIS_HOST="redis.example.com",
|
||||
REDIS_PORT="6380",
|
||||
REDIS_USERNAME="user",
|
||||
REDIS_PASSWORD="pass@word",
|
||||
REDIS_DB="2",
|
||||
REDIS_USE_SSL="true",
|
||||
)
|
||||
|
||||
assert config.normalized_pubsub_redis_url == "rediss://user:pass%40word@redis.example.com:6380/2"
|
||||
assert config.PUBSUB_REDIS_CHANNEL_TYPE == "pubsub"
|
||||
|
||||
|
||||
def test_pubsub_redis_url_override(monkeypatch: pytest.MonkeyPatch):
|
||||
_clear_environment(monkeypatch)
|
||||
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
|
||||
monkeypatch.setenv("DB_USERNAME", "postgres")
|
||||
monkeypatch.setenv("DB_PASSWORD", "postgres")
|
||||
monkeypatch.setenv("DB_HOST", "localhost")
|
||||
monkeypatch.setenv("DB_PORT", "5432")
|
||||
monkeypatch.setenv("DB_DATABASE", "dify")
|
||||
monkeypatch.setenv("PUBSUB_REDIS_URL", "redis://pubsub-host:6381/5")
|
||||
|
||||
config = DifyConfig()
|
||||
def test_pubsub_redis_url_override():
|
||||
config = _make_config(PUBSUB_REDIS_URL="redis://pubsub-host:6381/5")
|
||||
|
||||
assert config.normalized_pubsub_redis_url == "redis://pubsub-host:6381/5"
|
||||
|
||||
|
||||
def test_pubsub_redis_url_required_when_default_unavailable(monkeypatch: pytest.MonkeyPatch):
|
||||
_clear_environment(monkeypatch)
|
||||
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
|
||||
monkeypatch.setenv("DB_USERNAME", "postgres")
|
||||
monkeypatch.setenv("DB_PASSWORD", "postgres")
|
||||
monkeypatch.setenv("DB_HOST", "localhost")
|
||||
monkeypatch.setenv("DB_PORT", "5432")
|
||||
monkeypatch.setenv("DB_DATABASE", "dify")
|
||||
monkeypatch.setenv("REDIS_HOST", "")
|
||||
|
||||
def test_pubsub_redis_url_required_when_default_unavailable():
|
||||
config = _make_config(REDIS_HOST="")
|
||||
with pytest.raises(ValueError, match="PUBSUB_REDIS_URL must be set"):
|
||||
_ = DifyConfig().normalized_pubsub_redis_url
|
||||
_ = config.normalized_pubsub_redis_url
|
||||
|
||||
|
||||
def test_dify_config_exposes_redis_key_prefix_default(monkeypatch: pytest.MonkeyPatch):
|
||||
_clear_environment(monkeypatch)
|
||||
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
|
||||
monkeypatch.setenv("DB_TYPE", "postgresql")
|
||||
monkeypatch.setenv("DB_USERNAME", "postgres")
|
||||
monkeypatch.setenv("DB_PASSWORD", "postgres")
|
||||
monkeypatch.setenv("DB_HOST", "localhost")
|
||||
monkeypatch.setenv("DB_PORT", "5432")
|
||||
monkeypatch.setenv("DB_DATABASE", "dify")
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
def test_dify_config_exposes_redis_key_prefix_default():
|
||||
config = _make_config()
|
||||
|
||||
assert config.REDIS_KEY_PREFIX == ""
|
||||
|
||||
|
||||
def test_dify_config_reads_redis_key_prefix_from_env(monkeypatch: pytest.MonkeyPatch):
|
||||
_clear_environment(monkeypatch)
|
||||
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
|
||||
monkeypatch.setenv("DB_TYPE", "postgresql")
|
||||
monkeypatch.setenv("DB_USERNAME", "postgres")
|
||||
monkeypatch.setenv("DB_PASSWORD", "postgres")
|
||||
monkeypatch.setenv("DB_HOST", "localhost")
|
||||
monkeypatch.setenv("DB_PORT", "5432")
|
||||
monkeypatch.setenv("DB_DATABASE", "dify")
|
||||
monkeypatch.setenv("REDIS_KEY_PREFIX", "enterprise-a")
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
def test_dify_config_accepts_redis_key_prefix():
|
||||
config = _make_config(REDIS_KEY_PREFIX="enterprise-a")
|
||||
|
||||
assert config.REDIS_KEY_PREFIX == "enterprise-a"
|
||||
|
||||
@ -489,7 +338,6 @@ def test_dify_config_reads_redis_key_prefix_from_env(monkeypatch: pytest.MonkeyP
|
||||
],
|
||||
)
|
||||
def test_celery_broker_url_with_special_chars_password(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
broker_url,
|
||||
expected_host,
|
||||
expected_port,
|
||||
@ -500,24 +348,7 @@ def test_celery_broker_url_with_special_chars_password(
|
||||
"""Test that CELERY_BROKER_URL with various formats are handled correctly."""
|
||||
from kombu.utils.url import parse_url
|
||||
|
||||
# clear system environment variables
|
||||
_clear_environment(monkeypatch)
|
||||
|
||||
# Set up basic required environment variables (following existing pattern)
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
|
||||
monkeypatch.setenv("DB_TYPE", "postgresql")
|
||||
monkeypatch.setenv("DB_USERNAME", "postgres")
|
||||
monkeypatch.setenv("DB_PASSWORD", "postgres")
|
||||
monkeypatch.setenv("DB_HOST", "localhost")
|
||||
monkeypatch.setenv("DB_PORT", "5432")
|
||||
monkeypatch.setenv("DB_DATABASE", "dify")
|
||||
|
||||
# Set the CELERY_BROKER_URL to test
|
||||
monkeypatch.setenv("CELERY_BROKER_URL", broker_url)
|
||||
|
||||
# Create config and verify the URL is stored correctly
|
||||
config = DifyConfig()
|
||||
config = _make_config(CELERY_BROKER_URL=broker_url)
|
||||
assert broker_url == config.CELERY_BROKER_URL
|
||||
|
||||
# Test actual parsing behavior using kombu's parse_url (same as production)
|
||||
|
||||
@ -1,23 +1,23 @@
|
||||
import pytest
|
||||
|
||||
from configs.feature import FileUploadConfig
|
||||
from tests.unit_tests.configs._isolated_settings import InitSettingsOnly
|
||||
|
||||
|
||||
def test_paid_plan_file_size_limit_uses_its_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("UPLOAD_FILE_SIZE_LIMIT", "23")
|
||||
monkeypatch.delenv("KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN", raising=False)
|
||||
class _IsolatedFileUploadConfig(InitSettingsOnly, FileUploadConfig):
|
||||
pass
|
||||
|
||||
config = FileUploadConfig()
|
||||
|
||||
def test_paid_plan_file_size_limit_uses_its_default() -> None:
|
||||
config = _IsolatedFileUploadConfig(UPLOAD_FILE_SIZE_LIMIT="23")
|
||||
|
||||
assert config.UPLOAD_FILE_SIZE_LIMIT == 23
|
||||
assert config.KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN == 15
|
||||
|
||||
|
||||
def test_paid_plan_file_size_limit_can_be_configured_separately(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("UPLOAD_FILE_SIZE_LIMIT", "23")
|
||||
monkeypatch.setenv("KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN", "50")
|
||||
|
||||
config = FileUploadConfig()
|
||||
def test_paid_plan_file_size_limit_can_be_configured_separately() -> None:
|
||||
config = _IsolatedFileUploadConfig(
|
||||
UPLOAD_FILE_SIZE_LIMIT="23",
|
||||
KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN="50",
|
||||
)
|
||||
|
||||
assert config.UPLOAD_FILE_SIZE_LIMIT == 23
|
||||
assert config.KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN == 50
|
||||
|
||||
@ -1,19 +1,20 @@
|
||||
import pytest
|
||||
|
||||
from configs.middleware.vdb.tidb_on_qdrant_config import TidbOnQdrantConfig
|
||||
from tests.unit_tests.configs._isolated_settings import InitSettingsOnly
|
||||
|
||||
|
||||
def test_estimated_storage_limits_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB", raising=False)
|
||||
class _IsolatedTidbOnQdrantConfig(InitSettingsOnly, TidbOnQdrantConfig):
|
||||
pass
|
||||
|
||||
config = TidbOnQdrantConfig()
|
||||
|
||||
def test_estimated_storage_limits_default() -> None:
|
||||
config = _IsolatedTidbOnQdrantConfig()
|
||||
|
||||
assert config.TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB == "sandbox:60,professional:6400,team:25600"
|
||||
|
||||
|
||||
def test_estimated_storage_limits_custom(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB", "sandbox:61,professional:6500,team:26000")
|
||||
|
||||
config = TidbOnQdrantConfig()
|
||||
def test_estimated_storage_limits_custom() -> None:
|
||||
config = _IsolatedTidbOnQdrantConfig(
|
||||
TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB="sandbox:61,professional:6500,team:26000"
|
||||
)
|
||||
|
||||
assert config.TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB == "sandbox:61,professional:6500,team:26000"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import os
|
||||
import shutil
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Callable, Iterator
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@ -112,6 +112,26 @@ def reset_secret_key() -> Iterator[None]:
|
||||
dify_config.SECRET_KEY = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_overrides(monkeypatch: pytest.MonkeyPatch) -> Callable[..., None]:
|
||||
"""Temporarily override fields on the shared typed application config.
|
||||
|
||||
Application modules import the same config instance, so mutating known
|
||||
field names keeps tests scoped without replacing that instance with an
|
||||
unconstrained mock. ``monkeypatch`` restores every value after the test.
|
||||
"""
|
||||
from configs import dify_config
|
||||
|
||||
def apply(**values: object) -> None:
|
||||
unknown_fields = values.keys() - type(dify_config).model_fields.keys()
|
||||
if unknown_fields:
|
||||
raise ValueError(f"Unknown DifyConfig fields: {sorted(unknown_fields)}")
|
||||
for name, value in values.items():
|
||||
monkeypatch.setattr(dify_config, name, value)
|
||||
|
||||
return apply
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _sqlite_engine(_sqlite_database_template: Path, tmp_path: Path) -> Iterator[Engine]:
|
||||
"""Create an engine over a pristine per-test copy of the SQLite schema."""
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
@ -11,13 +11,41 @@ from werkzeug.exceptions import BadRequest, NotFound
|
||||
|
||||
from controllers.console.app import conversation as conversation_module
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from models.enums import ConversationFromSource
|
||||
from models.model import AppMode, Conversation
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from models import Account, App, EndUser, Message, MessageAnnotation, MessageFeedback
|
||||
from models.enums import (
|
||||
ConversationFromSource,
|
||||
CreatorUserRole,
|
||||
EndUserType,
|
||||
FeedbackFromSource,
|
||||
FeedbackRating,
|
||||
WorkflowRunTriggeredFrom,
|
||||
)
|
||||
from models.model import AppMode, Conversation, IconType
|
||||
from models.workflow import WorkflowRun, WorkflowType
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
|
||||
|
||||
def _make_account():
|
||||
return SimpleNamespace(timezone="UTC", id="u1")
|
||||
def _make_account() -> Account:
|
||||
account = Account(name="Account", email="account@example.com", timezone="UTC")
|
||||
account.id = "u1"
|
||||
return account
|
||||
|
||||
|
||||
def _app(*, mode: AppMode = AppMode.CHAT) -> App:
|
||||
return App(
|
||||
id="app-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Conversation app",
|
||||
description="",
|
||||
mode=mode,
|
||||
icon_type=IconType.EMOJI,
|
||||
icon="robot",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
max_active_requests=None,
|
||||
)
|
||||
|
||||
|
||||
def _conversation(*, conversation_id: str = "c1", app_id: str = "app-1") -> Conversation:
|
||||
@ -63,7 +91,7 @@ def test_completion_conversation_list_returns_paginated_result(
|
||||
conversation_module.CompletionConversationQuery(),
|
||||
unbound_session,
|
||||
account,
|
||||
app_model=SimpleNamespace(id="app-1"),
|
||||
app_model=_app(mode=AppMode.COMPLETION),
|
||||
)
|
||||
assert response == {"page": 1, "limit": 20, "total": 0, "has_more": False, "data": []}
|
||||
|
||||
@ -88,7 +116,7 @@ def test_completion_conversation_list_invalid_time_range(
|
||||
conversation_module.CompletionConversationQuery(),
|
||||
unbound_session,
|
||||
account,
|
||||
app_model=SimpleNamespace(id="app-1"),
|
||||
app_model=_app(mode=AppMode.COMPLETION),
|
||||
)
|
||||
|
||||
|
||||
@ -112,7 +140,7 @@ def test_chat_conversation_list_advanced_chat_calls_paginate(
|
||||
conversation_module.ChatConversationQuery(),
|
||||
unbound_session,
|
||||
account,
|
||||
app_model=SimpleNamespace(id="app-1", mode=AppMode.ADVANCED_CHAT),
|
||||
app_model=_app(mode=AppMode.ADVANCED_CHAT),
|
||||
)
|
||||
assert response == {"page": 1, "limit": 20, "total": 0, "has_more": False, "data": []}
|
||||
|
||||
@ -122,7 +150,7 @@ def test_get_conversation_updates_read_at(sqlite_session: Session) -> None:
|
||||
sqlite_session.add(conversation)
|
||||
sqlite_session.flush()
|
||||
session = sqlite_session
|
||||
result = conversation_module._get_conversation(session, _make_account(), SimpleNamespace(id="app-1"), "c1")
|
||||
result = conversation_module._get_conversation(session, _make_account(), _app(), "c1")
|
||||
assert result is conversation
|
||||
assert conversation.read_at is not None
|
||||
assert conversation.read_account_id == "u1"
|
||||
@ -131,58 +159,120 @@ def test_get_conversation_updates_read_at(sqlite_session: Session) -> None:
|
||||
def test_get_conversation_missing_raises_not_found(sqlite_session: Session) -> None:
|
||||
session = sqlite_session
|
||||
with pytest.raises(NotFound):
|
||||
conversation_module._get_conversation(session, _make_account(), SimpleNamespace(id="app-1"), "missing")
|
||||
conversation_module._get_conversation(session, _make_account(), _app(), "missing")
|
||||
|
||||
|
||||
def test_conversation_response_source_uses_caller_session(unbound_session: Session) -> None:
|
||||
session = unbound_session
|
||||
account = object()
|
||||
annotation = MagicMock()
|
||||
annotation.account_with_session.return_value = account
|
||||
message = MagicMock()
|
||||
conversation = MagicMock()
|
||||
conversation.inputs_with_session.return_value = {"topic": "support"}
|
||||
conversation.model_config_with_session.return_value = {"model_id": "model-1"}
|
||||
conversation.summary_or_query_with_session.return_value = "summary"
|
||||
conversation.annotated_with_session.return_value = True
|
||||
conversation.annotation_with_session.return_value = annotation
|
||||
conversation.message_count_with_session.return_value = 3
|
||||
conversation.user_feedback_stats_with_session.return_value = {"like": 2, "dislike": 1}
|
||||
conversation.admin_feedback_stats_with_session.return_value = {"like": 1, "dislike": 0}
|
||||
conversation.status_count_with_session.return_value = {"success": 1, "failed": 0}
|
||||
conversation.first_message_with_session.return_value = message
|
||||
conversation.from_end_user_session_id_with_session.return_value = "end-user-session"
|
||||
conversation.from_account_name_with_session.return_value = "Account"
|
||||
def test_conversation_response_source_uses_caller_session(sqlite_session: Session) -> None:
|
||||
account = _make_account()
|
||||
end_user = EndUser(
|
||||
id="end-user-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
type=EndUserType.SERVICE_API,
|
||||
external_user_id="external-user-1",
|
||||
name="End user",
|
||||
session_id="end-user-session",
|
||||
)
|
||||
conversation = _conversation()
|
||||
conversation.mode = AppMode.ADVANCED_CHAT
|
||||
conversation.override_model_configs = "{}"
|
||||
conversation.summary = "summary"
|
||||
conversation.inputs = {"topic": "support"}
|
||||
conversation.from_end_user_id = end_user.id
|
||||
conversation.from_account_id = account.id
|
||||
message = Message(
|
||||
id="message-1",
|
||||
app_id=conversation.app_id,
|
||||
model_provider=None,
|
||||
model_id=None,
|
||||
override_model_configs=None,
|
||||
conversation_id=conversation.id,
|
||||
inputs={},
|
||||
query="first question",
|
||||
message={},
|
||||
message_tokens=1,
|
||||
message_unit_price=Decimal(0),
|
||||
message_price_unit=Decimal("0.001"),
|
||||
answer="answer",
|
||||
answer_tokens=1,
|
||||
answer_unit_price=Decimal(0),
|
||||
answer_price_unit=Decimal("0.001"),
|
||||
parent_message_id=None,
|
||||
provider_response_latency=0,
|
||||
total_price=Decimal(0),
|
||||
currency="USD",
|
||||
error=None,
|
||||
message_metadata=None,
|
||||
invoke_from=InvokeFrom.EXPLORE,
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_end_user_id=end_user.id,
|
||||
from_account_id=account.id,
|
||||
workflow_run_id="run-1",
|
||||
app_mode=AppMode.ADVANCED_CHAT,
|
||||
)
|
||||
annotation = MessageAnnotation(
|
||||
app_id=conversation.app_id,
|
||||
question="question",
|
||||
content="annotation",
|
||||
account_id=account.id,
|
||||
conversation_id=conversation.id,
|
||||
message_id=message.id,
|
||||
)
|
||||
feedbacks = [
|
||||
MessageFeedback(
|
||||
app_id=conversation.app_id,
|
||||
conversation_id=conversation.id,
|
||||
message_id=message.id,
|
||||
rating=rating,
|
||||
from_source=source,
|
||||
from_account_id=account.id,
|
||||
)
|
||||
for source, rating in (
|
||||
(FeedbackFromSource.USER, FeedbackRating.LIKE),
|
||||
(FeedbackFromSource.USER, FeedbackRating.DISLIKE),
|
||||
(FeedbackFromSource.ADMIN, FeedbackRating.LIKE),
|
||||
)
|
||||
]
|
||||
workflow_run = WorkflowRun(
|
||||
id="run-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id=conversation.app_id,
|
||||
workflow_id="workflow-1",
|
||||
type=WorkflowType.WORKFLOW,
|
||||
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
|
||||
version="1",
|
||||
graph="{}",
|
||||
inputs="{}",
|
||||
status=WorkflowExecutionStatus.SUCCEEDED,
|
||||
outputs="{}",
|
||||
error=None,
|
||||
elapsed_time=1,
|
||||
total_tokens=1,
|
||||
total_steps=1,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=account.id,
|
||||
finished_at=None,
|
||||
exceptions_count=0,
|
||||
)
|
||||
sqlite_session.add_all([account, end_user, conversation, message, annotation, workflow_run, *feedbacks])
|
||||
sqlite_session.commit()
|
||||
|
||||
source = conversation_module.ConversationResponseSource(conversation, session=session)
|
||||
source = conversation_module.ConversationResponseSource(conversation, session=sqlite_session)
|
||||
|
||||
assert source.inputs == {"topic": "support"}
|
||||
assert source.model_config == {"model_id": "model-1"}
|
||||
assert source.model_config == {"model_id": None, "provider": None}
|
||||
assert source.summary_or_query == "summary"
|
||||
assert source.annotated is True
|
||||
annotation_source = source.annotation
|
||||
assert annotation_source is not None
|
||||
assert annotation_source.account is account
|
||||
assert source.message_count == 3
|
||||
assert source.user_feedback_stats == {"like": 2, "dislike": 1}
|
||||
assert source.message_count == 1
|
||||
assert source.user_feedback_stats == {"like": 1, "dislike": 1}
|
||||
assert source.admin_feedback_stats == {"like": 1, "dislike": 0}
|
||||
assert source.status_count == {"success": 1, "failed": 0}
|
||||
assert source.status_count == {"success": 1, "failed": 0, "partial_success": 0, "paused": 0}
|
||||
assert source.first_message is not None
|
||||
assert source.from_end_user_session_id == "end-user-session"
|
||||
assert source.from_account_name == "Account"
|
||||
conversation.model_config_with_session.assert_called_once_with(session=session)
|
||||
conversation.inputs_with_session.assert_called_once_with(session=session)
|
||||
conversation.summary_or_query_with_session.assert_called_once_with(session=session)
|
||||
conversation.annotated_with_session.assert_called_once_with(session=session)
|
||||
conversation.annotation_with_session.assert_called_once_with(session=session)
|
||||
conversation.message_count_with_session.assert_called_once_with(session=session)
|
||||
conversation.user_feedback_stats_with_session.assert_called_once_with(session=session)
|
||||
conversation.admin_feedback_stats_with_session.assert_called_once_with(session=session)
|
||||
conversation.status_count_with_session.assert_called_once_with(session=session)
|
||||
conversation.first_message_with_session.assert_called_once_with(session=session)
|
||||
conversation.from_end_user_session_id_with_session.assert_called_once_with(session=session)
|
||||
conversation.from_account_name_with_session.assert_called_once_with(session=session)
|
||||
annotation.account_with_session.assert_called_once_with(session=session)
|
||||
|
||||
|
||||
def test_completion_conversation_delete_maps_not_found(
|
||||
@ -197,4 +287,4 @@ def test_completion_conversation_delete_maps_not_found(
|
||||
)
|
||||
session = unbound_session
|
||||
with pytest.raises(NotFound):
|
||||
method(api, session, _make_account(), app_model=SimpleNamespace(id="app-1"), conversation_id="c1")
|
||||
method(api, session, _make_account(), app_model=_app(), conversation_id="c1")
|
||||
|
||||
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
@ -13,12 +13,28 @@ from sqlalchemy.orm import Session
|
||||
from controllers.console.app import conversation_variables as conversation_variables_module
|
||||
from factories import variable_factory
|
||||
from graphon.variables.types import SegmentType
|
||||
from models import ConversationVariable
|
||||
from models import App, AppMode, ConversationVariable
|
||||
from models.model import IconType
|
||||
|
||||
|
||||
def _app() -> App:
|
||||
return App(
|
||||
id="app-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Conversation variables app",
|
||||
description="",
|
||||
mode=AppMode.ADVANCED_CHAT,
|
||||
icon_type=IconType.EMOJI,
|
||||
icon="robot",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
max_active_requests=None,
|
||||
)
|
||||
|
||||
|
||||
def test_get_conversation_variables_returns_paginated_response(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_engine: Engine,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
@ -44,17 +60,19 @@ def test_get_conversation_variables_returns_paginated_response(
|
||||
sqlite_session.expire(row)
|
||||
expected_created_at = int(row.created_at.timestamp())
|
||||
expected_updated_at = int(row.updated_at.timestamp())
|
||||
monkeypatch.setattr(conversation_variables_module, "db", SimpleNamespace(engine=sqlite_engine))
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/apps/app-1/conversation-variables",
|
||||
method="GET",
|
||||
query_string={"conversation_id": "conv-1"},
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/console/api/apps/app-1/conversation-variables",
|
||||
method="GET",
|
||||
query_string={"conversation_id": "conv-1"},
|
||||
),
|
||||
patch.object(type(conversation_variables_module.db), "engine", new_callable=PropertyMock) as engine,
|
||||
):
|
||||
engine.return_value = sqlite_engine
|
||||
response = method(
|
||||
api,
|
||||
conversation_variables_module.ConversationVariablesQuery(conversation_id="conv-1"),
|
||||
app_model=SimpleNamespace(id="app-1"),
|
||||
app_model=_app(),
|
||||
)
|
||||
|
||||
assert response["page"] == 1
|
||||
@ -69,7 +87,6 @@ def test_get_conversation_variables_returns_paginated_response(
|
||||
@pytest.mark.parametrize("sqlite_session", [(ConversationVariable,)], indirect=True)
|
||||
def test_get_conversation_variables_normalizes_value_type_and_value(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_engine: Engine,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
@ -87,17 +104,19 @@ def test_get_conversation_variables_normalizes_value_type_and_value(
|
||||
)
|
||||
sqlite_session.add(ConversationVariable.from_variable(app_id="app-1", conversation_id="conv-1", variable=variable))
|
||||
sqlite_session.commit()
|
||||
monkeypatch.setattr(conversation_variables_module, "db", SimpleNamespace(engine=sqlite_engine))
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/apps/app-1/conversation-variables",
|
||||
method="GET",
|
||||
query_string={"conversation_id": "conv-1"},
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/console/api/apps/app-1/conversation-variables",
|
||||
method="GET",
|
||||
query_string={"conversation_id": "conv-1"},
|
||||
),
|
||||
patch.object(type(conversation_variables_module.db), "engine", new_callable=PropertyMock) as engine,
|
||||
):
|
||||
engine.return_value = sqlite_engine
|
||||
response = method(
|
||||
api,
|
||||
conversation_variables_module.ConversationVariablesQuery(conversation_id="conv-1"),
|
||||
app_model=SimpleNamespace(id="app-1"),
|
||||
app_model=_app(),
|
||||
)
|
||||
|
||||
assert response["data"][0]["value_type"] == "number"
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import datetime
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@ -18,16 +17,32 @@ from controllers.console.app.mcp_server import (
|
||||
MCPServerUpdatePayload,
|
||||
)
|
||||
from controllers.console.wraps import RBACPermission, RBACResourceScope
|
||||
from models import Account
|
||||
from models.account import AccountStatus
|
||||
from models.enums import AppMCPServerStatus
|
||||
from models.model import AppMCPServer
|
||||
from models.model import App, AppMCPServer, AppMode, IconType
|
||||
|
||||
|
||||
class _ValidatedResponse:
|
||||
def __init__(self, payload: dict[str, str]) -> None:
|
||||
self._payload = payload
|
||||
|
||||
def model_dump(self, mode: str = "json") -> dict[str, str]:
|
||||
return self._payload
|
||||
def _app(
|
||||
*,
|
||||
app_id: str = "app-1",
|
||||
tenant_id: str = "tenant-1",
|
||||
name: str = "Demo App",
|
||||
description: str = "App description",
|
||||
) -> App:
|
||||
return App(
|
||||
id=app_id,
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
description=description,
|
||||
mode=AppMode.CHAT,
|
||||
icon_type=IconType.EMOJI,
|
||||
icon="robot",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
max_active_requests=None,
|
||||
)
|
||||
|
||||
|
||||
def _server(
|
||||
@ -136,7 +151,7 @@ class TestAppMCPServerController:
|
||||
method = unwrap(api.get)
|
||||
|
||||
with patch("controllers.console.app.mcp_server.db.session", sqlite_session):
|
||||
response = method(api, app_model=SimpleNamespace(id="app-1"))
|
||||
response = method(api, app_model=_app())
|
||||
|
||||
assert response == {}
|
||||
|
||||
@ -157,7 +172,7 @@ class TestAppMCPServerController:
|
||||
api,
|
||||
req_data,
|
||||
"tenant-1",
|
||||
app_model=SimpleNamespace(id="app-1", name="Demo App", description="App description"),
|
||||
app_model=_app(),
|
||||
)
|
||||
|
||||
server = sqlite_session.scalar(select(AppMCPServer))
|
||||
@ -185,9 +200,7 @@ class TestAppMCPServerController:
|
||||
response = method(
|
||||
api,
|
||||
req_data,
|
||||
app_model=SimpleNamespace(
|
||||
id="app-1", tenant_id="tenant-1", name="Demo App", description="App description"
|
||||
),
|
||||
app_model=_app(),
|
||||
)
|
||||
|
||||
sqlite_session.expire_all()
|
||||
@ -233,9 +246,7 @@ class TestAppMCPServerController:
|
||||
method(
|
||||
api,
|
||||
req_data,
|
||||
app_model=SimpleNamespace(
|
||||
id="app-1", tenant_id="tenant-1", name="Demo App", description="App description"
|
||||
),
|
||||
app_model=_app(),
|
||||
)
|
||||
|
||||
sqlite_session.expire_all()
|
||||
@ -245,32 +256,36 @@ class TestAppMCPServerController:
|
||||
|
||||
|
||||
class TestAppMCPServerRefreshController:
|
||||
def test_post_refreshes_server_bound_to_app_and_tenant(self):
|
||||
def test_post_refreshes_server_bound_to_app_and_tenant(self, sqlite_session: Session) -> None:
|
||||
api = AppMCPServerRefreshController()
|
||||
method = unwrap(api.post)
|
||||
server = SimpleNamespace(server_code="old-code")
|
||||
server = _server(server_code="old-code")
|
||||
server.id = "server-1"
|
||||
tenant_decoy = _server(tenant_id="tenant-2", server_code="tenant-decoy-code")
|
||||
tenant_decoy.id = "server-2"
|
||||
app_decoy = _server(app_id="app-2", server_code="app-decoy-code")
|
||||
app_decoy.id = "server-3"
|
||||
sqlite_session.add_all([server, tenant_decoy, app_decoy])
|
||||
sqlite_session.commit()
|
||||
|
||||
with (
|
||||
patch("controllers.console.app.mcp_server.db.session.scalar", return_value=server) as scalar,
|
||||
patch("controllers.console.app.mcp_server.db.session.commit") as commit,
|
||||
patch("controllers.console.app.mcp_server.db.session", sqlite_session),
|
||||
patch("controllers.console.app.mcp_server.AppMCPServer.generate_server_code", return_value="new-code"),
|
||||
patch(
|
||||
"controllers.console.app.mcp_server.AppMCPServerResponse.model_validate",
|
||||
return_value=_ValidatedResponse({"id": "server-1", "server_code": "new-code"}),
|
||||
),
|
||||
):
|
||||
response = method(api, "tenant-1", app_model=SimpleNamespace(id="app-1"))
|
||||
response = method(api, "tenant-1", app_model=_app())
|
||||
|
||||
stmt = scalar.call_args.args[0]
|
||||
compiled = stmt.compile()
|
||||
statement = str(compiled)
|
||||
assert "app_mcp_servers.tenant_id" in statement
|
||||
assert "app_mcp_servers.app_id" in statement
|
||||
assert "tenant-1" in compiled.params.values()
|
||||
assert "app-1" in compiled.params.values()
|
||||
assert server.server_code == "new-code"
|
||||
commit.assert_called_once()
|
||||
assert response == {"id": "server-1", "server_code": "new-code"}
|
||||
sqlite_session.expire_all()
|
||||
refreshed_server = sqlite_session.get(AppMCPServer, "server-1")
|
||||
persisted_tenant_decoy = sqlite_session.get(AppMCPServer, "server-2")
|
||||
persisted_app_decoy = sqlite_session.get(AppMCPServer, "server-3")
|
||||
assert refreshed_server is not None
|
||||
assert persisted_tenant_decoy is not None
|
||||
assert persisted_app_decoy is not None
|
||||
assert refreshed_server.server_code == "new-code"
|
||||
assert persisted_tenant_decoy.server_code == "tenant-decoy-code"
|
||||
assert persisted_app_decoy.server_code == "app-decoy-code"
|
||||
assert response["id"] == "server-1"
|
||||
assert response["server_code"] == "new-code"
|
||||
|
||||
def test_route_is_app_scoped_post(self):
|
||||
route_map = {
|
||||
@ -291,7 +306,8 @@ class TestAppMCPServerRefreshController:
|
||||
class PermissionCheckedError(Exception):
|
||||
pass
|
||||
|
||||
current_user = SimpleNamespace(id="account-1")
|
||||
current_user = Account(name="Current user", email="user@example.com", status=AccountStatus.ACTIVE)
|
||||
current_user.id = "account-1"
|
||||
with (
|
||||
patch("controllers.common.wraps.dify_config.RBAC_ENABLED", True),
|
||||
patch(
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
@ -16,6 +15,7 @@ from controllers.console.app import ops_trace as ops_trace_module
|
||||
from controllers.console.app import wraps as app_wraps
|
||||
from enums import DeploymentEdition
|
||||
from libs import login as login_lib
|
||||
from models import Tenant
|
||||
from models.account import Account, AccountStatus, TenantAccountRole
|
||||
from models.model import App, AppMode, IconType
|
||||
|
||||
@ -25,19 +25,33 @@ def _make_account(role: TenantAccountRole) -> Account:
|
||||
account.id = "account-123" # type: ignore[assignment]
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.role = role
|
||||
account._current_tenant = SimpleNamespace(id="tenant-123") # type: ignore[assignment]
|
||||
tenant = Tenant(name="Test tenant")
|
||||
tenant.id = "tenant-123"
|
||||
account._current_tenant = tenant
|
||||
account._get_current_object = lambda: account # type: ignore[attr-defined]
|
||||
return account
|
||||
|
||||
|
||||
def _make_app() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="app-123", tenant_id="tenant-123", status="normal", mode="chat")
|
||||
def _make_app() -> App:
|
||||
return App(
|
||||
id="app-123",
|
||||
tenant_id="tenant-123",
|
||||
name="Trace app",
|
||||
description="",
|
||||
mode=AppMode.CHAT,
|
||||
icon_type=IconType.EMOJI,
|
||||
icon="robot",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
max_active_requests=None,
|
||||
)
|
||||
|
||||
|
||||
def _patch_console_guards(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
account: Account,
|
||||
app_model: SimpleNamespace,
|
||||
app_model: App,
|
||||
*,
|
||||
rbac_enabled: bool = False,
|
||||
) -> None:
|
||||
|
||||
@ -650,6 +650,7 @@ def test_draft_workflow_get_projects_agent_node_job_to_graph(monkeypatch: pytest
|
||||
"data": {
|
||||
"type": "agent",
|
||||
"version": "2",
|
||||
"agent_node_kind": "dify_agent",
|
||||
},
|
||||
}
|
||||
],
|
||||
@ -663,6 +664,7 @@ def test_draft_workflow_get_projects_agent_node_job_to_graph(monkeypatch: pytest
|
||||
"data": {
|
||||
"type": "agent",
|
||||
"version": "2",
|
||||
"agent_node_kind": "dify_agent",
|
||||
"agent_task": "Summarize it.",
|
||||
"agent_declared_outputs": [{"name": "summary", "type": "string"}],
|
||||
},
|
||||
|
||||
@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
@ -16,7 +15,9 @@ from controllers.console.app import workflow_comment as workflow_comment_module
|
||||
from controllers.console.app import wraps as app_wraps
|
||||
from enums import DeploymentEdition
|
||||
from libs import login as login_lib
|
||||
from models import App, Tenant, WorkflowComment, WorkflowCommentMention, WorkflowCommentReply
|
||||
from models.account import Account, AccountStatus, TenantAccountRole
|
||||
from models.model import AppMode, IconType
|
||||
|
||||
JAN_1_2024_NOON = datetime(2024, 1, 1, 12, 0, 0)
|
||||
JAN_1_2024_NOON_TS = int(JAN_1_2024_NOON.timestamp())
|
||||
@ -33,16 +34,30 @@ def _make_account(role: TenantAccountRole) -> Account:
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.role = role
|
||||
account.id = "account-123" # type: ignore[assignment]
|
||||
account._current_tenant = SimpleNamespace(id="tenant-123") # type: ignore[attr-defined]
|
||||
tenant = Tenant(name="Test tenant")
|
||||
tenant.id = "tenant-123"
|
||||
account._current_tenant = tenant
|
||||
account._get_current_object = lambda: account # type: ignore[attr-defined]
|
||||
return account
|
||||
|
||||
|
||||
def _make_app() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="app-123", tenant_id="tenant-123", status="normal", mode="workflow")
|
||||
def _make_app() -> App:
|
||||
return App(
|
||||
id="app-123",
|
||||
tenant_id="tenant-123",
|
||||
name="Workflow comments app",
|
||||
description="",
|
||||
mode=AppMode.WORKFLOW,
|
||||
icon_type=IconType.EMOJI,
|
||||
icon="robot",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
max_active_requests=None,
|
||||
)
|
||||
|
||||
|
||||
def _patch_console_guards(monkeypatch: pytest.MonkeyPatch, account: Account, app_model: SimpleNamespace) -> None:
|
||||
def _patch_console_guards(monkeypatch: pytest.MonkeyPatch, account: Account, app_model: App) -> None:
|
||||
monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True)
|
||||
monkeypatch.setattr(login_lib, "current_user", account)
|
||||
monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
@ -246,29 +261,29 @@ def test_list_comments_serializes_response_model(app: Flask, monkeypatch: pytest
|
||||
app_model = _make_app()
|
||||
_patch_console_guards(monkeypatch, account, app_model)
|
||||
|
||||
comment_author = SimpleNamespace(
|
||||
id="account-123",
|
||||
comment_author = Account(
|
||||
name="tester",
|
||||
email="tester@example.com",
|
||||
avatar="https://example.com/avatar.png",
|
||||
status=AccountStatus.ACTIVE,
|
||||
)
|
||||
comment = SimpleNamespace(
|
||||
id="comment-1",
|
||||
comment_author.id = "account-123"
|
||||
comment = WorkflowComment(
|
||||
tenant_id="tenant-123",
|
||||
app_id="app-123",
|
||||
position_x=1.5,
|
||||
position_y=2.5,
|
||||
content="hello",
|
||||
created_by="account-123",
|
||||
created_by_account=comment_author,
|
||||
created_at=1_700_000_000,
|
||||
updated_at=1_700_000_001,
|
||||
resolved=False,
|
||||
resolved_at=None,
|
||||
resolved_by=None,
|
||||
resolved_by_account=None,
|
||||
reply_count=0,
|
||||
mention_count=0,
|
||||
participants=[comment_author],
|
||||
)
|
||||
comment.id = "comment-1"
|
||||
comment.created_at = datetime.fromtimestamp(1_700_000_000)
|
||||
comment.updated_at = datetime.fromtimestamp(1_700_000_001)
|
||||
comment.cache_created_by_account(comment_author)
|
||||
comment.cache_resolved_by_account(None)
|
||||
get_comments_mock = MagicMock(return_value=[comment])
|
||||
monkeypatch.setattr(workflow_comment_module.WorkflowCommentService, "get_comments", get_comments_mock)
|
||||
|
||||
@ -317,48 +332,49 @@ def test_get_comment_serializes_detail_response_model(app: Flask, monkeypatch: p
|
||||
app_model = _make_app()
|
||||
_patch_console_guards(monkeypatch, account, app_model)
|
||||
|
||||
comment_author = SimpleNamespace(
|
||||
id="account-123",
|
||||
comment_author = Account(
|
||||
name="tester",
|
||||
email="tester@example.com",
|
||||
avatar="https://example.com/avatar.png",
|
||||
status=AccountStatus.ACTIVE,
|
||||
)
|
||||
mentioned_user = SimpleNamespace(
|
||||
id="account-456",
|
||||
comment_author.id = "account-123"
|
||||
mentioned_user = Account(
|
||||
name="mentioned",
|
||||
email="mentioned@example.com",
|
||||
avatar=None,
|
||||
status=AccountStatus.ACTIVE,
|
||||
)
|
||||
comment = SimpleNamespace(
|
||||
id="comment-1",
|
||||
mentioned_user.id = "account-456"
|
||||
comment = WorkflowComment(
|
||||
tenant_id="tenant-123",
|
||||
app_id="app-123",
|
||||
position_x=1.5,
|
||||
position_y=2.5,
|
||||
content="hello",
|
||||
created_by="account-123",
|
||||
created_by_account=comment_author,
|
||||
created_at=JAN_1_2024_NOON,
|
||||
updated_at=JAN_1_2024_1201,
|
||||
resolved=True,
|
||||
resolved_at=JAN_1_2024_1202,
|
||||
resolved_by="account-123",
|
||||
resolved_by_account=comment_author,
|
||||
replies=[
|
||||
SimpleNamespace(
|
||||
id="reply-1",
|
||||
content="reply",
|
||||
created_by="account-456",
|
||||
created_by_account=mentioned_user,
|
||||
created_at=JAN_1_2024_1203,
|
||||
)
|
||||
],
|
||||
mentions=[
|
||||
SimpleNamespace(
|
||||
mentioned_user_id="account-456",
|
||||
mentioned_user_account=mentioned_user,
|
||||
reply_id="reply-1",
|
||||
)
|
||||
],
|
||||
)
|
||||
comment.id = "comment-1"
|
||||
comment.created_at = JAN_1_2024_NOON
|
||||
comment.updated_at = JAN_1_2024_1201
|
||||
comment.cache_created_by_account(comment_author)
|
||||
comment.cache_resolved_by_account(comment_author)
|
||||
reply = WorkflowCommentReply(comment_id=comment.id, content="reply", created_by="account-456")
|
||||
reply.id = "reply-1"
|
||||
reply.created_at = JAN_1_2024_1203
|
||||
reply.updated_at = JAN_1_2024_1203
|
||||
reply.cache_created_by_account(mentioned_user)
|
||||
mention = WorkflowCommentMention(
|
||||
comment_id=comment.id,
|
||||
mentioned_user_id="account-456",
|
||||
reply_id=reply.id,
|
||||
)
|
||||
mention.cache_mentioned_user_account(mentioned_user)
|
||||
comment.replies.append(reply)
|
||||
comment.mentions.append(mention)
|
||||
get_comment_mock = MagicMock(return_value=comment)
|
||||
monkeypatch.setattr(workflow_comment_module.WorkflowCommentService, "get_comment", get_comment_mock)
|
||||
|
||||
|
||||
@ -3,13 +3,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.console.app import workflow as workflow_module
|
||||
from controllers.console.app.workflow import ConvertToWorkflowApi
|
||||
from models import Account, App, AppMode
|
||||
from models.model import IconType
|
||||
|
||||
|
||||
def _app(app_id: str) -> App:
|
||||
return App(
|
||||
id=app_id,
|
||||
tenant_id="tenant-1",
|
||||
name=f"App {app_id}",
|
||||
description="",
|
||||
mode=AppMode.CHAT,
|
||||
icon_type=IconType.EMOJI,
|
||||
icon="robot",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
max_active_requests=None,
|
||||
)
|
||||
|
||||
|
||||
class TestConvertToWorkflowApi:
|
||||
@ -21,11 +38,12 @@ class TestConvertToWorkflowApi:
|
||||
self, api: ConvertToWorkflowApi, app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
method = unwrap(api.post)
|
||||
new_app = _app("new-app-1")
|
||||
|
||||
monkeypatch.setattr(
|
||||
workflow_module,
|
||||
"WorkflowService",
|
||||
lambda: SimpleNamespace(convert_to_workflow=lambda **_kwargs: SimpleNamespace(id="new-app-1")),
|
||||
lambda: type("WorkflowServiceStub", (), {"convert_to_workflow": lambda self, **_kwargs: new_app})(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
workflow_module,
|
||||
@ -38,11 +56,13 @@ class TestConvertToWorkflowApi:
|
||||
method="POST",
|
||||
json={},
|
||||
):
|
||||
current_user = Account(name="Current user", email="user@example.com")
|
||||
current_user.id = "u1"
|
||||
response = method(
|
||||
api,
|
||||
current_tenant_id="tenant-1",
|
||||
current_user=SimpleNamespace(id="u1"),
|
||||
app_model=SimpleNamespace(id="app-1"),
|
||||
current_user=current_user,
|
||||
app_model=_app("app-1"),
|
||||
)
|
||||
|
||||
assert response["new_app_id"] == "new-app-1"
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import ANY, MagicMock
|
||||
|
||||
import pytest
|
||||
@ -13,8 +12,9 @@ from controllers.console.app import workflow as workflow_module
|
||||
from controllers.console.app import wraps as app_wraps
|
||||
from enums import DeploymentEdition
|
||||
from libs import login as login_lib
|
||||
from models import App, Tenant
|
||||
from models.account import Account, AccountStatus, TenantAccountRole
|
||||
from models.model import AppMode
|
||||
from models.model import AppMode, IconType
|
||||
|
||||
|
||||
def _make_account() -> Account:
|
||||
@ -22,16 +22,30 @@ def _make_account() -> Account:
|
||||
account.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]
|
||||
tenant = Tenant(name="Test tenant")
|
||||
tenant.id = "tenant-123"
|
||||
account._current_tenant = tenant
|
||||
account._get_current_object = lambda: account # type: ignore[attr-defined]
|
||||
return account
|
||||
|
||||
|
||||
def _make_app(mode: AppMode) -> SimpleNamespace:
|
||||
return SimpleNamespace(id="app-123", tenant_id="tenant-123", mode=mode.value)
|
||||
def _make_app(mode: AppMode) -> App:
|
||||
return App(
|
||||
id="app-123",
|
||||
tenant_id="tenant-123",
|
||||
name="Human input app",
|
||||
description="",
|
||||
mode=mode,
|
||||
icon_type=IconType.EMOJI,
|
||||
icon="robot",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
max_active_requests=None,
|
||||
)
|
||||
|
||||
|
||||
def _patch_console_guards(monkeypatch: pytest.MonkeyPatch, account: Account, app_model: SimpleNamespace) -> None:
|
||||
def _patch_console_guards(monkeypatch: pytest.MonkeyPatch, account: Account, app_model: App) -> None:
|
||||
# Skip setup and auth guardrails
|
||||
monkeypatch.setattr("configs.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD)
|
||||
monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True)
|
||||
|
||||
@ -24,7 +24,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, MagicMock
|
||||
from uuid import UUID
|
||||
|
||||
@ -32,6 +31,8 @@ import pytest
|
||||
|
||||
from controllers.console.app import workflow_node_output_inspector as ctrl
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from models import App, AppMode
|
||||
from models.model import IconType
|
||||
from services.workflow.inspector_events import InspectorMessage
|
||||
from services.workflow.node_output_inspector_service import (
|
||||
NodeOutputInspectorError,
|
||||
@ -47,13 +48,25 @@ from services.workflow.node_output_inspector_service import (
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_model() -> Any:
|
||||
"""A minimal ``App`` stub the controller passes through to the service.
|
||||
def app_model() -> App:
|
||||
"""A real transient ``App`` the controller passes through to the service.
|
||||
|
||||
The SSE generator never reads its attributes — just forwards it — so a
|
||||
sentinel object is enough.
|
||||
transient mapped instance is sufficient.
|
||||
"""
|
||||
return MagicMock(name="App", tenant_id="tenant-1", id="app-1")
|
||||
return App(
|
||||
id="app-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Inspector app",
|
||||
description="",
|
||||
mode=AppMode.WORKFLOW,
|
||||
icon_type=IconType.EMOJI,
|
||||
icon="robot",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
max_active_requests=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@ -1,16 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask_restx import marshal
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.console.app import workflow_run as workflow_run_module
|
||||
from models import Account
|
||||
from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
|
||||
from models import Account, App, AppMode
|
||||
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
|
||||
from models.model import IconType
|
||||
from models.workflow import (
|
||||
WorkflowNodeExecutionModel,
|
||||
WorkflowNodeExecutionTriggeredFrom,
|
||||
WorkflowRun,
|
||||
WorkflowType,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_200_response(handler, payload: Any) -> Any:
|
||||
@ -24,75 +34,112 @@ def _serialize_200_response(handler, payload: Any) -> Any:
|
||||
return payload
|
||||
|
||||
|
||||
def _account() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="account-1", name="Alice", email="alice@example.com")
|
||||
|
||||
|
||||
def _current_account() -> Account:
|
||||
def _account(session: Session) -> Account:
|
||||
account = Account(name="Alice", email="alice@example.com")
|
||||
account.id = "account-1"
|
||||
session.add(account)
|
||||
session.commit()
|
||||
return account
|
||||
|
||||
|
||||
def _workflow_run_summary(**overrides) -> SimpleNamespace:
|
||||
def _app() -> App:
|
||||
return App(
|
||||
id="app-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Workflow run app",
|
||||
description="",
|
||||
mode=AppMode.WORKFLOW,
|
||||
icon_type=IconType.EMOJI,
|
||||
icon="robot",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
max_active_requests=None,
|
||||
)
|
||||
|
||||
|
||||
def _workflow_run_summary(session: Session, **overrides: object) -> WorkflowRun:
|
||||
created_at = datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC)
|
||||
payload = {
|
||||
"id": "run-1",
|
||||
"version": "v1",
|
||||
"status": "succeeded",
|
||||
"elapsed_time": 1.5,
|
||||
"total_tokens": 10,
|
||||
"total_steps": 2,
|
||||
"created_by_account": _account(),
|
||||
"created_at": created_at,
|
||||
"finished_at": created_at,
|
||||
"exceptions_count": 0,
|
||||
"retry_index": 0,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return SimpleNamespace(**payload)
|
||||
workflow_run = WorkflowRun(
|
||||
id="run-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
type=WorkflowType.WORKFLOW,
|
||||
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
|
||||
version="v1",
|
||||
graph='{"nodes": []}',
|
||||
inputs='{"query": "hello"}',
|
||||
status=WorkflowExecutionStatus.SUCCEEDED,
|
||||
outputs='{"answer": "world"}',
|
||||
error=None,
|
||||
elapsed_time=1.5,
|
||||
total_tokens=10,
|
||||
total_steps=2,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="account-1",
|
||||
created_at=created_at,
|
||||
finished_at=created_at,
|
||||
exceptions_count=0,
|
||||
)
|
||||
for name, value in overrides.items():
|
||||
setattr(workflow_run, name, value)
|
||||
workflow_run.retry_index = 0
|
||||
session.add(workflow_run)
|
||||
session.commit()
|
||||
return workflow_run
|
||||
|
||||
|
||||
def _workflow_run_node_execution(**overrides) -> SimpleNamespace:
|
||||
def _workflow_run_node_execution(session: Session) -> WorkflowNodeExecutionModel:
|
||||
created_at = datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC)
|
||||
payload = {
|
||||
"id": "node-exec-1",
|
||||
"index": 1,
|
||||
"predecessor_node_id": None,
|
||||
"node_id": "node-1",
|
||||
"node_type": "start",
|
||||
"title": "Start",
|
||||
"inputs_dict": {"query": "hello"},
|
||||
"process_data_dict": {"step": "prepared"},
|
||||
"outputs_dict": {"answer": "world"},
|
||||
"status": "succeeded",
|
||||
"error": None,
|
||||
"elapsed_time": 1.0,
|
||||
"execution_metadata_dict": {"total_tokens": 3},
|
||||
"extras": {},
|
||||
"created_at": created_at,
|
||||
"created_by_role": "account",
|
||||
"created_by_account": _account(),
|
||||
"created_by_end_user": None,
|
||||
"finished_at": created_at,
|
||||
"inputs_truncated": False,
|
||||
"outputs_truncated": False,
|
||||
"process_data_truncated": False,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return SimpleNamespace(**payload)
|
||||
execution = WorkflowNodeExecutionModel(
|
||||
id="node-exec-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
|
||||
workflow_run_id="run-1",
|
||||
index=1,
|
||||
predecessor_node_id=None,
|
||||
node_execution_id="node-execution-1",
|
||||
node_id="node-1",
|
||||
node_type="start",
|
||||
title="Start",
|
||||
agent_workspace_binding_id=None,
|
||||
inputs=json.dumps({"query": "hello"}),
|
||||
process_data=json.dumps({"step": "prepared"}),
|
||||
outputs=json.dumps({"answer": "world"}),
|
||||
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
||||
error=None,
|
||||
elapsed_time=1.0,
|
||||
execution_metadata=json.dumps({"total_tokens": 3}),
|
||||
created_at=created_at,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="account-1",
|
||||
finished_at=created_at,
|
||||
)
|
||||
execution.offload_data = []
|
||||
session.add(execution)
|
||||
session.commit()
|
||||
return execution
|
||||
|
||||
|
||||
def test_workflow_run_list_returns_frontend_history_contract(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_workflow_run_list_returns_frontend_history_contract(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
_account(sqlite_session)
|
||||
workflow_run = _workflow_run_summary(sqlite_session)
|
||||
|
||||
class WorkflowRunService:
|
||||
def get_paginate_workflow_runs(self, **_kwargs):
|
||||
return {
|
||||
"limit": 10,
|
||||
"has_more": False,
|
||||
"data": [_workflow_run_summary()],
|
||||
"data": [workflow_run],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(workflow_run_module, "WorkflowRunService", WorkflowRunService)
|
||||
monkeypatch.setattr(workflow_run_module.db, "session", sqlite_session)
|
||||
|
||||
api = workflow_run_module.WorkflowRunListApi()
|
||||
handler = unwrap(api.get)
|
||||
@ -101,7 +148,7 @@ def test_workflow_run_list_returns_frontend_history_contract(app: Flask, monkeyp
|
||||
payload = handler(
|
||||
api,
|
||||
workflow_run_module.WorkflowRunListQuery(limit=10),
|
||||
app_model=SimpleNamespace(id="app-1", tenant_id="tenant-1"),
|
||||
app_model=_app(),
|
||||
)
|
||||
|
||||
response = _serialize_200_response(api.get, payload)
|
||||
@ -123,21 +170,26 @@ def test_workflow_run_list_returns_frontend_history_contract(app: Flask, monkeyp
|
||||
}
|
||||
|
||||
|
||||
def test_advanced_chat_workflow_run_list_keeps_message_fields(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_advanced_chat_workflow_run_list_keeps_message_fields(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
_account(sqlite_session)
|
||||
workflow_run = _workflow_run_summary(
|
||||
sqlite_session,
|
||||
conversation_id="conversation-1",
|
||||
message_id="message-1",
|
||||
)
|
||||
|
||||
class WorkflowRunService:
|
||||
def get_paginate_advanced_chat_workflow_runs(self, **_kwargs):
|
||||
return {
|
||||
"limit": 1,
|
||||
"has_more": True,
|
||||
"data": [
|
||||
_workflow_run_summary(
|
||||
conversation_id="conversation-1",
|
||||
message_id="message-1",
|
||||
)
|
||||
],
|
||||
"data": [workflow_run],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(workflow_run_module, "WorkflowRunService", WorkflowRunService)
|
||||
monkeypatch.setattr(workflow_run_module.db, "session", sqlite_session)
|
||||
|
||||
api = workflow_run_module.AdvancedChatAppWorkflowRunListApi()
|
||||
handler = unwrap(api.get)
|
||||
@ -146,7 +198,7 @@ def test_advanced_chat_workflow_run_list_keeps_message_fields(app: Flask, monkey
|
||||
payload = handler(
|
||||
api,
|
||||
workflow_run_module.WorkflowRunListQuery(limit=1),
|
||||
app_model=SimpleNamespace(id="app-1", tenant_id="tenant-1"),
|
||||
app_model=_app(),
|
||||
)
|
||||
|
||||
response = _serialize_200_response(api.get, payload)
|
||||
@ -155,38 +207,24 @@ def test_advanced_chat_workflow_run_list_keeps_message_fields(app: Flask, monkey
|
||||
assert response["data"][0]["message_id"] == "message-1"
|
||||
|
||||
|
||||
def test_workflow_run_detail_returns_frontend_detail_contract(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
created_at = datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC)
|
||||
workflow_run = SimpleNamespace(
|
||||
id="run-1",
|
||||
version="v1",
|
||||
graph_dict={"nodes": []},
|
||||
inputs_dict={"query": "hello"},
|
||||
status="succeeded",
|
||||
outputs_dict={"answer": "world"},
|
||||
error=None,
|
||||
elapsed_time=1.5,
|
||||
total_tokens=10,
|
||||
total_steps=2,
|
||||
created_by_role="account",
|
||||
created_by_account=_account(),
|
||||
created_by_end_user=None,
|
||||
created_at=created_at,
|
||||
finished_at=created_at,
|
||||
exceptions_count=0,
|
||||
)
|
||||
def test_workflow_run_detail_returns_frontend_detail_contract(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
_account(sqlite_session)
|
||||
workflow_run = _workflow_run_summary(sqlite_session)
|
||||
|
||||
class WorkflowRunService:
|
||||
def get_workflow_run(self, **_kwargs):
|
||||
return workflow_run
|
||||
|
||||
monkeypatch.setattr(workflow_run_module, "WorkflowRunService", WorkflowRunService)
|
||||
monkeypatch.setattr(workflow_run_module.db, "session", sqlite_session)
|
||||
|
||||
api = workflow_run_module.WorkflowRunDetailApi()
|
||||
handler = unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/apps/app-1/workflow-runs/run-1", method="GET"):
|
||||
payload = handler(api, app_model=SimpleNamespace(id="app-1", tenant_id="tenant-1"), run_id="run-1")
|
||||
payload = handler(api, app_model=_app(), run_id="run-1")
|
||||
|
||||
response = _serialize_200_response(api.get, payload)
|
||||
|
||||
@ -211,21 +249,23 @@ def test_workflow_run_detail_returns_frontend_detail_contract(app: Flask, monkey
|
||||
|
||||
|
||||
def test_workflow_run_node_executions_return_frontend_trace_contract(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
account = _account(sqlite_session)
|
||||
execution = _workflow_run_node_execution(sqlite_session)
|
||||
|
||||
class WorkflowRunService:
|
||||
def get_workflow_run_node_executions(self, **_kwargs):
|
||||
return [_workflow_run_node_execution()]
|
||||
return [execution]
|
||||
|
||||
monkeypatch.setattr(workflow_run_module, "WorkflowRunService", WorkflowRunService)
|
||||
monkeypatch.setattr(workflow_run_module.db, "session", sqlite_session)
|
||||
|
||||
api = workflow_run_module.WorkflowRunNodeExecutionListApi()
|
||||
handler = unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/apps/app-1/workflow-runs/run-1/node-executions", method="GET"):
|
||||
payload = handler(
|
||||
api, _current_account(), app_model=SimpleNamespace(id="app-1", tenant_id="tenant-1"), run_id="run-1"
|
||||
)
|
||||
payload = handler(api, account, app_model=_app(), run_id="run-1")
|
||||
|
||||
response = _serialize_200_response(api.get, payload)
|
||||
|
||||
|
||||
@ -154,6 +154,7 @@ class TestEmailRegisterResetApi:
|
||||
password="ValidPass123!",
|
||||
timezone=None,
|
||||
language=None,
|
||||
ip_address="127.0.0.1",
|
||||
)
|
||||
mock_reset_login_rate.assert_called_once_with("invitee@example.com")
|
||||
mock_revoke_token.assert_called_once_with("token-123")
|
||||
@ -211,6 +212,7 @@ class TestEmailRegisterResetApi:
|
||||
password="ValidPass123!",
|
||||
timezone="Asia/Shanghai",
|
||||
language=None,
|
||||
ip_address="127.0.0.1",
|
||||
)
|
||||
mock_reset_login_rate.assert_called_once_with("invitee@example.com")
|
||||
mock_revoke_token.assert_called_once_with("token-123")
|
||||
@ -268,6 +270,7 @@ class TestEmailRegisterResetApi:
|
||||
password="ValidPass123!",
|
||||
timezone=None,
|
||||
language="zh-Hans",
|
||||
ip_address="127.0.0.1",
|
||||
)
|
||||
mock_reset_login_rate.assert_called_once_with("invitee@example.com")
|
||||
mock_revoke_token.assert_called_once_with("token-123")
|
||||
|
||||
@ -25,6 +25,7 @@ def test_create_new_account_uses_requested_language(mock_create_account):
|
||||
password="ValidPass123!",
|
||||
interface_language="zh-Hans",
|
||||
timezone="Asia/Shanghai",
|
||||
ip_address=None,
|
||||
session=ANY,
|
||||
)
|
||||
|
||||
|
||||
@ -462,16 +462,19 @@ class TestEmailCodeLoginApi:
|
||||
mock_login.return_value = mock_token_pair
|
||||
|
||||
# Act
|
||||
with app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={
|
||||
"email": "newuser@example.com",
|
||||
"code": encode_code("123456"),
|
||||
"token": "valid_token",
|
||||
"language": "en-US",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
with (
|
||||
patch("controllers.console.auth.login.extract_remote_ip", return_value="203.0.113.10"),
|
||||
app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={
|
||||
"email": "newuser@example.com",
|
||||
"code": encode_code("123456"),
|
||||
"token": "valid_token",
|
||||
"language": "en-US",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
),
|
||||
):
|
||||
api = EmailCodeLoginApi()
|
||||
response = api.post()
|
||||
@ -483,6 +486,7 @@ class TestEmailCodeLoginApi:
|
||||
name="newuser@example.com",
|
||||
interface_language="en-US",
|
||||
timezone="Asia/Shanghai",
|
||||
ip_address="203.0.113.10",
|
||||
session=ANY,
|
||||
)
|
||||
|
||||
|
||||
@ -211,11 +211,21 @@ class TestOAuthCallback:
|
||||
mock_generate_account.return_value = (oauth_setup["account"], True)
|
||||
mock_account_service.login.return_value = oauth_setup["token_pair"]
|
||||
|
||||
with app.test_request_context("/auth/oauth/github/callback?code=test_code"):
|
||||
with (
|
||||
patch("controllers.console.auth.oauth.extract_remote_ip", return_value="203.0.113.10"),
|
||||
app.test_request_context("/auth/oauth/github/callback?code=test_code"),
|
||||
):
|
||||
resource.get("github")
|
||||
|
||||
oauth_setup["provider"].get_access_token.assert_called_once_with("test_code")
|
||||
oauth_setup["provider"].get_user_info.assert_called_once_with("access_token")
|
||||
mock_generate_account.assert_called_once_with(
|
||||
"github",
|
||||
oauth_setup["provider"].get_user_info.return_value,
|
||||
timezone=None,
|
||||
language=None,
|
||||
ip_address="203.0.113.10",
|
||||
)
|
||||
mock_redirect.assert_called_once_with("http://localhost:3000?oauth_new_user=true")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@ -529,6 +539,7 @@ class TestAccountGeneration:
|
||||
provider="github",
|
||||
language="en-US",
|
||||
timezone=None,
|
||||
ip_address=None,
|
||||
session=ANY,
|
||||
)
|
||||
else:
|
||||
@ -563,6 +574,7 @@ class TestAccountGeneration:
|
||||
provider="github",
|
||||
language="en-US",
|
||||
timezone=None,
|
||||
ip_address=None,
|
||||
session=ANY,
|
||||
)
|
||||
|
||||
@ -595,6 +607,7 @@ class TestAccountGeneration:
|
||||
provider="github",
|
||||
language="zh-Hans",
|
||||
timezone="Asia/Shanghai",
|
||||
ip_address=None,
|
||||
session=ANY,
|
||||
)
|
||||
|
||||
@ -627,6 +640,7 @@ class TestAccountGeneration:
|
||||
provider="github",
|
||||
language="zh-Hans",
|
||||
timezone=None,
|
||||
ip_address=None,
|
||||
session=ANY,
|
||||
)
|
||||
|
||||
|
||||
@ -56,7 +56,9 @@ def test_generate_account_registers_with_browser_timezone(
|
||||
user_info = OAuthUserInfo(id="github-123", name="Test User", email="User@Example.com")
|
||||
|
||||
with app.test_request_context(headers={"Accept-Language": "zh-Hans,zh;q=0.9"}):
|
||||
result, oauth_new_user = _generate_account("github", user_info, timezone="Asia/Shanghai")
|
||||
result, oauth_new_user = _generate_account(
|
||||
"github", user_info, timezone="Asia/Shanghai", ip_address="203.0.113.10"
|
||||
)
|
||||
|
||||
assert result is account
|
||||
assert oauth_new_user is True
|
||||
@ -68,6 +70,7 @@ def test_generate_account_registers_with_browser_timezone(
|
||||
provider="github",
|
||||
language="zh-Hans",
|
||||
timezone="Asia/Shanghai",
|
||||
ip_address="203.0.113.10",
|
||||
session=ANY,
|
||||
)
|
||||
mock_link_account.assert_called_once_with("github", "github-123", account, session=ANY)
|
||||
@ -100,6 +103,7 @@ def test_generate_account_prefers_state_language_over_accept_language(
|
||||
provider="github",
|
||||
language="zh-Hans",
|
||||
timezone=None,
|
||||
ip_address=None,
|
||||
session=ANY,
|
||||
)
|
||||
mock_link_account.assert_called_once_with("github", "github-123", account, session=ANY)
|
||||
|
||||
@ -151,6 +151,7 @@ class TestDatasourcePluginOAuthAuthorizationUrl:
|
||||
plugin_id="langgenius/notion_datasource",
|
||||
provider="notion",
|
||||
credential_id="cred-1",
|
||||
extra_data={"visibility": "only_me"},
|
||||
)
|
||||
get_authorization_url.assert_called_once()
|
||||
assert get_authorization_url.call_args.kwargs["tenant_id"] == "tenant-1"
|
||||
@ -250,6 +251,10 @@ class TestDatasourceOAuthCallback:
|
||||
assert response.status_code == 302
|
||||
assert "/oauth-callback" in response.location
|
||||
add_oauth_provider.assert_called_once()
|
||||
# Legacy context without a visibility key falls back to ONLY_ME, and
|
||||
# the callback now also propagates the creator's user_id.
|
||||
from models.enums import PermissionEnum
|
||||
|
||||
assert add_oauth_provider.call_args.kwargs == {
|
||||
"tenant_id": "tenant-1",
|
||||
"provider_id": add_oauth_provider.call_args.kwargs["provider_id"],
|
||||
@ -257,6 +262,8 @@ class TestDatasourceOAuthCallback:
|
||||
"name": "Workspace Bot",
|
||||
"expire_at": expires_at,
|
||||
"credentials": {"token": "abc"},
|
||||
"user_id": "user-1",
|
||||
"visibility": PermissionEnum.ONLY_ME,
|
||||
}
|
||||
assert str(add_oauth_provider.call_args.kwargs["provider_id"]) == _PROVIDER_ID
|
||||
|
||||
@ -645,6 +652,7 @@ class TestDatasourceAuthListApi:
|
||||
def test_list_success(self, app: Flask):
|
||||
api = DatasourceAuthListApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
user = MagicMock(id="user-1")
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
@ -652,16 +660,20 @@ class TestDatasourceAuthListApi:
|
||||
DatasourceProviderService,
|
||||
"get_all_datasource_credentials",
|
||||
return_value=[_datasource_auth()],
|
||||
),
|
||||
) as get_all,
|
||||
):
|
||||
response, status = method(api, "tenant-1")
|
||||
response, status = method(api, "tenant-1", user)
|
||||
|
||||
assert status == 200
|
||||
assert response == {"result": [_datasource_auth()]}
|
||||
# user is threaded through so list_datasource_credentials applies the
|
||||
# visibility filter for the current viewer.
|
||||
assert get_all.call_args.kwargs["user"] is user
|
||||
|
||||
def test_auth_list_empty(self, app: Flask):
|
||||
api = DatasourceAuthListApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
user = MagicMock(id="user-1")
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
@ -671,7 +683,7 @@ class TestDatasourceAuthListApi:
|
||||
return_value=[],
|
||||
),
|
||||
):
|
||||
response, status = method(api, "tenant-1")
|
||||
response, status = method(api, "tenant-1", user)
|
||||
|
||||
assert status == 200
|
||||
assert response["result"] == []
|
||||
@ -679,6 +691,7 @@ class TestDatasourceAuthListApi:
|
||||
def test_hardcode_list_empty(self, app: Flask):
|
||||
api = DatasourceHardCodeAuthListApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
user = MagicMock(id="user-1")
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
@ -688,7 +701,7 @@ class TestDatasourceAuthListApi:
|
||||
return_value=[],
|
||||
),
|
||||
):
|
||||
response, status = method(api, "tenant-1")
|
||||
response, status = method(api, "tenant-1", user)
|
||||
|
||||
assert status == 200
|
||||
assert response["result"] == []
|
||||
@ -698,6 +711,7 @@ class TestDatasourceHardCodeAuthListApi:
|
||||
def test_list_success(self, app: Flask):
|
||||
api = DatasourceHardCodeAuthListApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
user = MagicMock(id="user-1")
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
@ -705,11 +719,12 @@ class TestDatasourceHardCodeAuthListApi:
|
||||
DatasourceProviderService,
|
||||
"get_hard_code_datasource_credentials",
|
||||
return_value=[_datasource_auth()],
|
||||
),
|
||||
) as get_hardcode,
|
||||
):
|
||||
response, status = method(api, "tenant-1")
|
||||
response, status = method(api, "tenant-1", user)
|
||||
|
||||
assert status == 200
|
||||
assert get_hardcode.call_args.kwargs["user"] is user
|
||||
|
||||
|
||||
class TestDatasourceAuthOauthCustomClient:
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, scoped_session
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
import controllers.console.explore.wraps as wraps_module
|
||||
@ -20,14 +21,20 @@ from controllers.console.explore.wraps import (
|
||||
trial_feature_enable,
|
||||
user_allowed_to_access_app,
|
||||
)
|
||||
from models import AccountTrialAppRecord, App, AppMode, InstalledApp, TrialApp
|
||||
from models import Account, AccountTrialAppRecord, App, AppMode, InstalledApp, TrialApp
|
||||
|
||||
|
||||
def _bind_database(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
|
||||
session_proxy = MagicMock(wraps=sqlite_session)
|
||||
session_proxy.return_value = sqlite_session
|
||||
monkeypatch.setattr(wraps_module.db, "session", session_proxy)
|
||||
monkeypatch.setattr(model_module.db, "session", session_proxy)
|
||||
session_registry = scoped_session(lambda: sqlite_session)
|
||||
monkeypatch.setattr(wraps_module.db, "session", session_registry)
|
||||
monkeypatch.setattr(model_module.db, "session", session_registry)
|
||||
|
||||
|
||||
def _account(*, account_id: str | None = None) -> Account:
|
||||
account = Account(name="Explore user", email="user@example.com")
|
||||
if account_id is not None:
|
||||
account.id = account_id
|
||||
return account
|
||||
|
||||
|
||||
def _app() -> App:
|
||||
@ -67,7 +74,7 @@ def test_installed_app_required_not_found(
|
||||
|
||||
with patch(
|
||||
"controllers.console.explore.wraps.current_account_with_tenant",
|
||||
return_value=(MagicMock(), tenant_id),
|
||||
return_value=(_account(), tenant_id),
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
view(str(uuid4()))
|
||||
@ -91,7 +98,7 @@ def test_installed_app_required_app_deleted(
|
||||
|
||||
with patch(
|
||||
"controllers.console.explore.wraps.current_account_with_tenant",
|
||||
return_value=(MagicMock(), tenant_id),
|
||||
return_value=(_account(), tenant_id),
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
view(installed_app_id)
|
||||
@ -116,7 +123,7 @@ def test_installed_app_required_success(
|
||||
|
||||
with patch(
|
||||
"controllers.console.explore.wraps.current_account_with_tenant",
|
||||
return_value=(MagicMock(), app.tenant_id),
|
||||
return_value=(_account(), app.tenant_id),
|
||||
):
|
||||
result = view(installed_app.id)
|
||||
|
||||
@ -126,19 +133,18 @@ def test_installed_app_required_success(
|
||||
|
||||
|
||||
def test_user_allowed_to_access_app_denied():
|
||||
installed_app = MagicMock(app_id="app-1")
|
||||
installed_app = _installed_app(app_id="app-1", tenant_id="tenant-1")
|
||||
|
||||
@user_allowed_to_access_app
|
||||
def view(installed_app):
|
||||
return "ok"
|
||||
|
||||
feature = MagicMock()
|
||||
feature.webapp_auth.enabled = True
|
||||
feature = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"controllers.console.explore.wraps.current_account_with_tenant",
|
||||
return_value=(MagicMock(id="user-1"), None),
|
||||
return_value=(_account(account_id="user-1"), None),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.explore.wraps.FeatureService.get_system_features",
|
||||
@ -154,19 +160,18 @@ def test_user_allowed_to_access_app_denied():
|
||||
|
||||
|
||||
def test_user_allowed_to_access_app_success():
|
||||
installed_app = MagicMock(app_id="app-1")
|
||||
installed_app = _installed_app(app_id="app-1", tenant_id="tenant-1")
|
||||
|
||||
@user_allowed_to_access_app
|
||||
def view(installed_app):
|
||||
return "ok"
|
||||
|
||||
feature = MagicMock()
|
||||
feature.webapp_auth.enabled = True
|
||||
feature = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"controllers.console.explore.wraps.current_account_with_tenant",
|
||||
return_value=(MagicMock(id="user-1"), None),
|
||||
return_value=(_account(account_id="user-1"), None),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.explore.wraps.FeatureService.get_system_features",
|
||||
@ -193,7 +198,7 @@ def test_trial_app_required_not_allowed(
|
||||
|
||||
with patch(
|
||||
"controllers.console.explore.wraps.current_account_with_tenant",
|
||||
return_value=(MagicMock(id=str(uuid4())), None),
|
||||
return_value=(_account(account_id=str(uuid4())), None),
|
||||
):
|
||||
with pytest.raises(TrialAppNotAllowed):
|
||||
view(str(uuid4()))
|
||||
@ -218,7 +223,7 @@ def test_trial_app_required_limit_exceeded(
|
||||
|
||||
with patch(
|
||||
"controllers.console.explore.wraps.current_account_with_tenant",
|
||||
return_value=(MagicMock(id=account_id), None),
|
||||
return_value=(_account(account_id=account_id), None),
|
||||
):
|
||||
with pytest.raises(TrialAppLimitExceeded):
|
||||
view(app.id)
|
||||
@ -243,7 +248,7 @@ def test_trial_app_required_success(
|
||||
|
||||
with patch(
|
||||
"controllers.console.explore.wraps.current_account_with_tenant",
|
||||
return_value=(MagicMock(id=account_id), None),
|
||||
return_value=(_account(account_id=account_id), None),
|
||||
):
|
||||
result = view(app.id)
|
||||
|
||||
|
||||
@ -764,3 +764,187 @@ def test_resolve_identity_mode_off_is_passthrough_when_not_enterprise(
|
||||
monkeypatch.setattr(controller_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
|
||||
|
||||
assert controller_module._resolve_identity_mode(None, current=identity_mode.OFF) == identity_mode.OFF
|
||||
|
||||
|
||||
# --- OAuth start + callback: visibility passthrough (PR #39840 review) ---
|
||||
#
|
||||
# The OAuth flow stashes the user-chosen visibility in the proxy context on the
|
||||
# start endpoint, then reads it back on the callback to persist the credential.
|
||||
# These tests pin down: (a) the value round-trips for the two supported levels,
|
||||
# and (b) unknown/missing values collapse to ONLY_ME on both ends.
|
||||
|
||||
|
||||
_OAUTH_PROVIDER_PATH = "langgenius/github/github"
|
||||
|
||||
|
||||
def _invoke_oauth_start(
|
||||
controller_module: ModuleType,
|
||||
app: Flask,
|
||||
user: Account,
|
||||
tenant_id: str,
|
||||
*,
|
||||
query_string: str = "",
|
||||
provider: str = _OAUTH_PROVIDER_PATH,
|
||||
):
|
||||
"""Call ToolPluginOAuthApi.get bypassing decorators (matches the pattern
|
||||
used by test_builtin_provider_credentials_get_reads_repeated_include_ids)."""
|
||||
api = controller_module.ToolPluginOAuthApi()
|
||||
path = f"/oauth/plugin/{provider}/tool/authorization-url"
|
||||
if query_string:
|
||||
path = f"{path}?{query_string}"
|
||||
with app.test_request_context(path, method="GET"):
|
||||
return unwrap(api.get)(api, tenant_id, user, provider=provider)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _mock_oauth_start_deps(controller_module: ModuleType, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Mock everything ToolPluginOAuthApi.get calls out to and capture the
|
||||
create_proxy_context kwargs so tests can assert on extra_data."""
|
||||
monkeypatch.setattr(
|
||||
controller_module.BuiltinToolManageService,
|
||||
"get_oauth_client",
|
||||
MagicMock(return_value={"client_id": "abc"}),
|
||||
)
|
||||
create_proxy_context = MagicMock(return_value="ctx-id")
|
||||
monkeypatch.setattr(controller_module.OAuthProxyService, "create_proxy_context", create_proxy_context)
|
||||
auth_response = MagicMock(authorization_url="https://oauth.example.com/authorize", state="s")
|
||||
oauth_handler_instance = MagicMock()
|
||||
oauth_handler_instance.get_authorization_url.return_value = auth_response
|
||||
monkeypatch.setattr(controller_module, "OAuthHandler", MagicMock(return_value=oauth_handler_instance))
|
||||
# dump_response is called with the pydantic response class; short-circuit it.
|
||||
monkeypatch.setattr(
|
||||
controller_module, "dump_response", lambda _cls, obj: {"authorization_url": obj.authorization_url}
|
||||
)
|
||||
yield create_proxy_context
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("query_visibility", "expected_stored"),
|
||||
[
|
||||
("only_me", "only_me"),
|
||||
("all_team_members", "all_team_members"),
|
||||
],
|
||||
)
|
||||
def test_tool_plugin_oauth_url_stashes_valid_visibility(
|
||||
app: Flask,
|
||||
controller_module: ModuleType,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
query_visibility: str,
|
||||
expected_stored: str,
|
||||
):
|
||||
user = _mock_account("user-oauth")
|
||||
_set_current_account(monkeypatch, controller_module, user, "tenant-oauth")
|
||||
|
||||
with _mock_oauth_start_deps(controller_module, monkeypatch) as create_proxy_context:
|
||||
_invoke_oauth_start(
|
||||
controller_module,
|
||||
app,
|
||||
user,
|
||||
"tenant-oauth",
|
||||
query_string=f"visibility={query_visibility}",
|
||||
)
|
||||
|
||||
_, kwargs = create_proxy_context.call_args
|
||||
assert kwargs["extra_data"] == {"visibility": expected_stored}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query_string", ["", "visibility=partial_members", "visibility=nonsense"])
|
||||
def test_tool_plugin_oauth_url_unknown_visibility_falls_back_to_only_me(
|
||||
app: Flask,
|
||||
controller_module: ModuleType,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
query_string: str,
|
||||
):
|
||||
"""Missing, partial_members (rejected for plugin creds), and garbage inputs
|
||||
all collapse to only_me — OAuth tokens are personal by default."""
|
||||
user = _mock_account("user-oauth-fallback")
|
||||
_set_current_account(monkeypatch, controller_module, user, "tenant-oauth")
|
||||
|
||||
with _mock_oauth_start_deps(controller_module, monkeypatch) as create_proxy_context:
|
||||
_invoke_oauth_start(
|
||||
controller_module,
|
||||
app,
|
||||
user,
|
||||
"tenant-oauth",
|
||||
query_string=query_string,
|
||||
)
|
||||
|
||||
_, kwargs = create_proxy_context.call_args
|
||||
assert kwargs["extra_data"] == {"visibility": "only_me"}
|
||||
|
||||
|
||||
def _invoke_oauth_callback(
|
||||
controller_module: ModuleType,
|
||||
app: Flask,
|
||||
*,
|
||||
stored_visibility: str | None,
|
||||
provider: str = _OAUTH_PROVIDER_PATH,
|
||||
):
|
||||
api = controller_module.ToolOAuthCallback()
|
||||
ctx = {"user_id": "user-oauth", "tenant_id": "tenant-oauth"}
|
||||
if stored_visibility is not None:
|
||||
ctx["visibility"] = stored_visibility
|
||||
with app.test_request_context(
|
||||
f"/oauth/plugin/{provider}/tool/callback",
|
||||
method="GET",
|
||||
headers={"Cookie": "context_id=ctx-id"},
|
||||
):
|
||||
with patch.object(controller_module.OAuthProxyService, "use_proxy_context", return_value=ctx):
|
||||
return unwrap(api.get)(api, provider=provider)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _mock_oauth_callback_deps(controller_module: ModuleType, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Mock everything ToolOAuthCallback.get calls out to and capture the
|
||||
add_builtin_tool_provider kwargs so tests can assert visibility."""
|
||||
monkeypatch.setattr(
|
||||
controller_module.BuiltinToolManageService,
|
||||
"get_oauth_client",
|
||||
MagicMock(return_value={"client_id": "abc"}),
|
||||
)
|
||||
add_provider = MagicMock()
|
||||
monkeypatch.setattr(controller_module.BuiltinToolManageService, "add_builtin_tool_provider", add_provider)
|
||||
credentials_response = MagicMock(credentials={"access_token": "tok"}, expires_at=-1)
|
||||
oauth_handler_instance = MagicMock()
|
||||
oauth_handler_instance.get_credentials.return_value = credentials_response
|
||||
monkeypatch.setattr(controller_module, "OAuthHandler", MagicMock(return_value=oauth_handler_instance))
|
||||
monkeypatch.setattr(controller_module, "redirect", lambda url: {"redirect": url})
|
||||
yield add_provider
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stored", "expected"),
|
||||
[
|
||||
("only_me", "only_me"),
|
||||
("all_team_members", "all_team_members"),
|
||||
],
|
||||
)
|
||||
def test_tool_oauth_callback_persists_stored_visibility(
|
||||
app: Flask,
|
||||
controller_module: ModuleType,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
stored: str,
|
||||
expected: str,
|
||||
):
|
||||
with _mock_oauth_callback_deps(controller_module, monkeypatch) as add_provider:
|
||||
_invoke_oauth_callback(controller_module, app, stored_visibility=stored)
|
||||
|
||||
_, kwargs = add_provider.call_args
|
||||
assert kwargs["visibility"] == expected
|
||||
assert kwargs["api_type"] == controller_module.CredentialType.OAUTH2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stored", [None, "partial_members", "garbage"])
|
||||
def test_tool_oauth_callback_unknown_stored_visibility_falls_back_to_only_me(
|
||||
app: Flask,
|
||||
controller_module: ModuleType,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
stored: str | None,
|
||||
):
|
||||
"""Older proxy contexts (pre-feature) and anything unexpected must default
|
||||
to only_me — matches the safe-default we apply on the start endpoint."""
|
||||
with _mock_oauth_callback_deps(controller_module, monkeypatch) as add_provider:
|
||||
_invoke_oauth_callback(controller_module, app, stored_visibility=stored)
|
||||
|
||||
_, kwargs = add_provider.call_args
|
||||
assert kwargs["visibility"] == "only_me"
|
||||
|
||||
@ -2,7 +2,7 @@ import io
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
from inspect import unwrap
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
@ -206,6 +206,7 @@ class TestPluginUploadFileApi:
|
||||
timestamp="123",
|
||||
nonce="abc",
|
||||
sign="sig",
|
||||
max_size=None,
|
||||
)
|
||||
tool_file_manager.create_file_by_raw.assert_called_once_with(
|
||||
user_id="account-1",
|
||||
@ -336,6 +337,65 @@ class TestPluginUploadFileApi:
|
||||
with pytest.raises(module.FileTooLargeError):
|
||||
post_fn(api)
|
||||
|
||||
@patch.object(module, "get_user", return_value=_end_user())
|
||||
@patch.object(module, "verify_plugin_file_signature", return_value=True)
|
||||
@patch.object(module, "ToolFileManager")
|
||||
def test_signed_max_size_bounds_file_read(
|
||||
self,
|
||||
mock_tool_file_manager,
|
||||
mock_verify,
|
||||
mock_get_user,
|
||||
):
|
||||
dummy_file = DummyFile(content=b"data")
|
||||
dummy_file.stream = MagicMock()
|
||||
dummy_file.stream.read.return_value = b"data"
|
||||
module.request = fake_request(
|
||||
{
|
||||
"timestamp": "123",
|
||||
"nonce": "abc",
|
||||
"sign": "sig",
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "user-1",
|
||||
"max_size": "4",
|
||||
},
|
||||
file=dummy_file,
|
||||
)
|
||||
mock_tool_file_manager.return_value.create_file_by_raw.return_value = _tool_file()
|
||||
mock_tool_file_manager.sign_file.return_value = "signed-url"
|
||||
|
||||
unwrap(module.PluginUploadFileApi().post)(module.PluginUploadFileApi())
|
||||
|
||||
dummy_file.stream.read.assert_called_once_with(5)
|
||||
assert mock_verify.call_args.kwargs["max_size"] == 4
|
||||
assert mock_tool_file_manager.return_value.create_file_by_raw.call_args.kwargs["file_binary"] == b"data"
|
||||
|
||||
@patch.object(module, "get_user", return_value=_end_user())
|
||||
@patch.object(module, "verify_plugin_file_signature", return_value=True)
|
||||
@patch.object(module, "ToolFileManager")
|
||||
def test_signed_max_size_rejects_oversized_file_before_creation(
|
||||
self,
|
||||
mock_tool_file_manager,
|
||||
mock_verify,
|
||||
mock_get_user,
|
||||
):
|
||||
dummy_file = DummyFile(content=b"oversized")
|
||||
module.request = fake_request(
|
||||
{
|
||||
"timestamp": "123",
|
||||
"nonce": "abc",
|
||||
"sign": "sig",
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "user-1",
|
||||
"max_size": "4",
|
||||
},
|
||||
file=dummy_file,
|
||||
)
|
||||
|
||||
with pytest.raises(module.FileTooLargeError):
|
||||
unwrap(module.PluginUploadFileApi().post)(module.PluginUploadFileApi())
|
||||
|
||||
mock_tool_file_manager.assert_not_called()
|
||||
|
||||
@patch.object(module, "get_user", return_value=_end_user())
|
||||
@patch.object(module, "verify_plugin_file_signature", return_value=True)
|
||||
@patch.object(module, "ToolFileManager")
|
||||
|
||||
@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
|
||||
from controllers.inner_api.agent.files import (
|
||||
AgentFileDownloadRequestApi,
|
||||
AgentFileUploadRequestApi,
|
||||
AgentFileUploadRequestPayload,
|
||||
)
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from services.file_request_service import DownloadFileRequestResult
|
||||
@ -29,6 +30,7 @@ def test_upload_request_returns_origin_free_uri(app: Flask, unbound_session: Ses
|
||||
"filename": "report.pdf",
|
||||
"mimetype": "application/pdf",
|
||||
"conversation_id": "conversation-1",
|
||||
"max_size": 64 * 1024 * 1024,
|
||||
}
|
||||
tenant = SimpleNamespace(id="tenant-1")
|
||||
user = SimpleNamespace(id="canonical-end-user-1")
|
||||
@ -51,9 +53,26 @@ def test_upload_request_returns_origin_free_uri(app: Flask, unbound_session: Ses
|
||||
user_id="canonical-end-user-1",
|
||||
conversation_id="conversation-1",
|
||||
user_from=None,
|
||||
max_size=64 * 1024 * 1024,
|
||||
)
|
||||
|
||||
|
||||
def test_upload_request_payload_requires_non_negative_max_size() -> None:
|
||||
payload = {
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "user-1",
|
||||
"filename": "report.pdf",
|
||||
"mimetype": "application/pdf",
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
AgentFileUploadRequestPayload.model_validate(payload)
|
||||
with pytest.raises(ValueError):
|
||||
AgentFileUploadRequestPayload.model_validate({**payload, "max_size": -1})
|
||||
|
||||
assert AgentFileUploadRequestPayload.model_validate({**payload, "max_size": 0}).max_size == 0
|
||||
|
||||
|
||||
def test_download_request_returns_origin_free_uri_for_sandbox(app: Flask, unbound_session: Session) -> None:
|
||||
reference = build_file_reference(record_id="tool-file-1")
|
||||
payload = {
|
||||
|
||||
@ -7,7 +7,8 @@ from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy import Engine, event
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from configs import dify_config
|
||||
@ -265,7 +266,7 @@ class TestEnterpriseInnerApiUserAuth:
|
||||
# Assert
|
||||
assert result == "no_user"
|
||||
|
||||
def test_should_pass_through_when_hmac_signature_invalid(self, app: Flask):
|
||||
def test_should_pass_through_when_hmac_signature_invalid(self, app: Flask, sqlite_engine: Engine):
|
||||
"""Invalid HMAC auth passes through without opening a database session."""
|
||||
|
||||
# Arrange
|
||||
@ -273,17 +274,20 @@ class TestEnterpriseInnerApiUserAuth:
|
||||
def protected_view(**kwargs):
|
||||
return kwargs.get("user", "no_user")
|
||||
|
||||
# Act - use wrong signature
|
||||
with app.test_request_context(
|
||||
headers={"Authorization": "Bearer user123:wrong_signature", "X-Inner-Api-Key": "valid_key"}
|
||||
):
|
||||
with patch.object(dify_config, "INNER_API", True):
|
||||
with patch("controllers.inner_api.wraps.session_factory.create_session") as mock_create_session:
|
||||
result = protected_view()
|
||||
def fail_on_query(*_args, **_kwargs):
|
||||
pytest.fail("invalid HMAC must not access the database")
|
||||
|
||||
event.listen(sqlite_engine, "before_cursor_execute", fail_on_query)
|
||||
try:
|
||||
with app.test_request_context(
|
||||
headers={"Authorization": "Bearer user123:wrong_signature", "X-Inner-Api-Key": "valid_key"}
|
||||
):
|
||||
with patch.object(dify_config, "INNER_API", True):
|
||||
result = protected_view()
|
||||
finally:
|
||||
event.remove(sqlite_engine, "before_cursor_execute", fail_on_query)
|
||||
|
||||
# Assert
|
||||
assert result == "no_user"
|
||||
mock_create_session.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(EndUser,)], indirect=True)
|
||||
def test_should_inject_user_when_hmac_signature_valid(self, app: Flask, sqlite_session: Session):
|
||||
@ -313,21 +317,13 @@ class TestEnterpriseInnerApiUserAuth:
|
||||
)
|
||||
sqlite_session.add(end_user)
|
||||
sqlite_session.commit()
|
||||
database_session_factory = sessionmaker(
|
||||
bind=sqlite_session.get_bind(),
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
# Act
|
||||
with app.test_request_context(
|
||||
headers={"Authorization": f"Bearer {user_id}:{valid_signature}", "X-Inner-Api-Key": inner_api_key}
|
||||
):
|
||||
with patch.object(dify_config, "INNER_API", True):
|
||||
with patch(
|
||||
"controllers.inner_api.wraps.session_factory.create_session",
|
||||
database_session_factory,
|
||||
):
|
||||
result = protected_view()
|
||||
result = protected_view()
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, EndUser)
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import uuid
|
||||
from contextlib import nullcontext
|
||||
from unittest.mock import MagicMock, patch
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import Engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, NotFound, Unauthorized
|
||||
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound, Unauthorized
|
||||
|
||||
from controllers.openapi.auth.data import AuthData, ExternalIdentity
|
||||
from controllers.openapi.auth.prepare import (
|
||||
@ -18,346 +19,347 @@ from controllers.openapi.auth.prepare import (
|
||||
resolve_external_user,
|
||||
)
|
||||
from libs.oauth_bearer import TokenType
|
||||
from models.account import TenantAccountRole
|
||||
from models import Account, App, EndUser, Tenant, TenantAccountJoin
|
||||
from models.account import AccountStatus, TenantAccountRole, TenantStatus
|
||||
from models.enums import AppStatus
|
||||
from models.model import AppMode, IconType
|
||||
from services import end_user_service
|
||||
from services.enterprise.enterprise_service import WebAppAccessMode
|
||||
|
||||
APP_ID = "00000000-0000-0000-0000-000000000001"
|
||||
TENANT_ID = "00000000-0000-0000-0000-000000000002"
|
||||
ACCOUNT_ID = "00000000-0000-0000-0000-000000000003"
|
||||
|
||||
|
||||
def _make_auth_data(**kwargs) -> AuthData:
|
||||
mock_fields = {k: kwargs.pop(k) for k in ("app", "tenant", "caller") if k in kwargs}
|
||||
data = AuthData(
|
||||
def _make_auth_data(**kwargs: object) -> AuthData:
|
||||
return AuthData(
|
||||
token_type=kwargs.pop("token_type", TokenType.OAUTH_ACCOUNT),
|
||||
token_hash=kwargs.pop("token_hash", "testhash"),
|
||||
scopes=kwargs.pop("scopes", frozenset()),
|
||||
**kwargs,
|
||||
)
|
||||
for k, v in mock_fields.items():
|
||||
setattr(data, k, v)
|
||||
return data
|
||||
|
||||
|
||||
_VALID_APP_UUID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
|
||||
def test_load_app_writes_app_to_data():
|
||||
app = MagicMock()
|
||||
app.status = "normal"
|
||||
app.enable_api = True
|
||||
data = _make_auth_data(path_params={"app_id": _VALID_APP_UUID})
|
||||
with patch("controllers.openapi.auth.prepare.AppService.get_app_by_id", return_value=app):
|
||||
load_app(data)
|
||||
assert data.app is app
|
||||
|
||||
|
||||
def test_load_app_raises_not_found_for_non_uuid_app_id():
|
||||
data = _make_auth_data(path_params={"app_id": "not-a-uuid"})
|
||||
with pytest.raises(NotFound):
|
||||
load_app(data)
|
||||
|
||||
|
||||
def test_load_app_raises_not_found_when_missing():
|
||||
data = _make_auth_data(path_params={"app_id": _VALID_APP_UUID})
|
||||
with patch("controllers.openapi.auth.prepare.AppService.get_app_by_id", return_value=None):
|
||||
with pytest.raises(NotFound):
|
||||
load_app(data)
|
||||
|
||||
|
||||
def test_load_app_raises_not_found_when_not_normal():
|
||||
app = MagicMock()
|
||||
app.status = "archived"
|
||||
data = _make_auth_data(path_params={"app_id": _VALID_APP_UUID})
|
||||
with patch("controllers.openapi.auth.prepare.AppService.get_app_by_id", return_value=app):
|
||||
with pytest.raises(NotFound):
|
||||
load_app(data)
|
||||
|
||||
|
||||
def test_load_app_stashes_app_even_when_api_disabled():
|
||||
app = MagicMock()
|
||||
app.status = "normal"
|
||||
app.enable_api = False
|
||||
data = _make_auth_data(path_params={"app_id": _VALID_APP_UUID})
|
||||
with patch("controllers.openapi.auth.prepare.AppService.get_app_by_id", return_value=app):
|
||||
load_app(data)
|
||||
assert data.app is app
|
||||
|
||||
|
||||
def test_load_app_skips_when_already_set():
|
||||
existing_app = MagicMock()
|
||||
data = _make_auth_data(app=existing_app, path_params={"app_id": "abc"})
|
||||
load_app(data)
|
||||
assert data.app is existing_app
|
||||
|
||||
|
||||
def test_load_tenant_writes_tenant():
|
||||
app = MagicMock()
|
||||
app.tenant_id = uuid.uuid4()
|
||||
tenant = MagicMock()
|
||||
tenant.status = "normal"
|
||||
data = _make_auth_data(app=app)
|
||||
with patch("controllers.openapi.auth.prepare.TenantService.get_tenant_by_id", return_value=tenant):
|
||||
load_tenant(data)
|
||||
assert data.tenant is tenant
|
||||
|
||||
|
||||
def test_load_tenant_skips_when_already_set():
|
||||
existing_tenant = MagicMock()
|
||||
data = _make_auth_data(app=MagicMock(), tenant=existing_tenant)
|
||||
load_tenant(data)
|
||||
assert data.tenant is existing_tenant
|
||||
|
||||
|
||||
def test_load_tenant_raises_forbidden_when_archived():
|
||||
from models.account import TenantStatus
|
||||
|
||||
app = MagicMock()
|
||||
app.tenant_id = uuid.uuid4()
|
||||
tenant = MagicMock()
|
||||
tenant.status = TenantStatus.ARCHIVE
|
||||
data = _make_auth_data(app=app)
|
||||
with patch("controllers.openapi.auth.prepare.TenantService.get_tenant_by_id", return_value=tenant):
|
||||
with pytest.raises(Forbidden):
|
||||
load_tenant(data)
|
||||
|
||||
|
||||
def test_load_tenant_raises_forbidden_when_missing():
|
||||
app = MagicMock()
|
||||
app.tenant_id = uuid.uuid4()
|
||||
data = _make_auth_data(app=app)
|
||||
with patch("controllers.openapi.auth.prepare.TenantService.get_tenant_by_id", return_value=None):
|
||||
with pytest.raises(Forbidden):
|
||||
load_tenant(data)
|
||||
|
||||
|
||||
def test_load_tenant_raises_500_when_app_not_loaded():
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
data = _make_auth_data()
|
||||
with pytest.raises(InternalServerError):
|
||||
load_tenant(data)
|
||||
|
||||
|
||||
def test_load_account_writes_caller():
|
||||
account = MagicMock()
|
||||
account_id = uuid.uuid4()
|
||||
data = _make_auth_data(account_id=account_id)
|
||||
with patch("controllers.openapi.auth.prepare.AccountService.get_account_by_id", return_value=account):
|
||||
load_account(data)
|
||||
assert data.caller is account
|
||||
assert data.caller_kind == "account"
|
||||
|
||||
|
||||
def test_load_account_skips_when_already_set():
|
||||
existing_caller = MagicMock()
|
||||
data = _make_auth_data(account_id=uuid.uuid4(), caller=existing_caller)
|
||||
load_account(data)
|
||||
assert data.caller is existing_caller
|
||||
|
||||
|
||||
def test_load_account_sets_current_tenant_when_tenant_present(sqlite_session: Session):
|
||||
account = MagicMock()
|
||||
tenant = MagicMock()
|
||||
session = sqlite_session
|
||||
data = _make_auth_data(account_id=uuid.uuid4(), tenant=tenant)
|
||||
with (
|
||||
patch("controllers.openapi.auth.prepare.AccountService.get_account_by_id", return_value=account),
|
||||
patch("controllers.openapi.auth.prepare.session_factory.create_session", return_value=nullcontext(session)),
|
||||
):
|
||||
load_account(data)
|
||||
account.set_current_tenant_with_session.assert_called_once_with(tenant, session=session)
|
||||
|
||||
|
||||
def test_load_account_raises_unauthorized_when_not_found():
|
||||
data = _make_auth_data(account_id=uuid.uuid4())
|
||||
with patch("controllers.openapi.auth.prepare.AccountService.get_account_by_id", return_value=None):
|
||||
with pytest.raises(Unauthorized):
|
||||
load_account(data)
|
||||
|
||||
|
||||
def test_resolve_external_user_writes_caller():
|
||||
tenant = MagicMock()
|
||||
app = MagicMock()
|
||||
end_user = MagicMock()
|
||||
ext = ExternalIdentity(email="user@sso.com")
|
||||
data = _make_auth_data(tenant=tenant, app=app, external_identity=ext)
|
||||
with patch("controllers.openapi.auth.prepare.EndUserService.get_or_create_end_user_by_type", return_value=end_user):
|
||||
resolve_external_user(data)
|
||||
assert data.caller is end_user
|
||||
assert data.caller_kind == "end_user"
|
||||
|
||||
|
||||
def test_resolve_external_user_raises_unauthorized_when_context_missing():
|
||||
data = _make_auth_data(tenant=None, app=MagicMock(), external_identity=ExternalIdentity(email="u@s.com"))
|
||||
with pytest.raises(Unauthorized):
|
||||
resolve_external_user(data)
|
||||
|
||||
|
||||
def test_load_app_access_mode_writes_mode():
|
||||
from services.enterprise.enterprise_service import WebAppAccessMode
|
||||
|
||||
app = MagicMock()
|
||||
app.id = "app-1"
|
||||
settings = MagicMock()
|
||||
settings.access_mode = "public"
|
||||
data = _make_auth_data(app=app)
|
||||
with patch(
|
||||
"controllers.openapi.auth.prepare.EnterpriseService.WebAppAuth.get_app_access_mode_by_id",
|
||||
return_value=settings,
|
||||
):
|
||||
load_app_access_mode(data)
|
||||
assert data.app_access_mode == WebAppAccessMode.PUBLIC
|
||||
|
||||
|
||||
def test_load_app_access_mode_writes_none_when_value_error():
|
||||
app = MagicMock()
|
||||
app.id = "app-1"
|
||||
data = _make_auth_data(app=app)
|
||||
with patch(
|
||||
"controllers.openapi.auth.prepare.EnterpriseService.WebAppAuth.get_app_access_mode_by_id",
|
||||
side_effect=ValueError("No data found."),
|
||||
):
|
||||
load_app_access_mode(data)
|
||||
assert data.app_access_mode is None
|
||||
|
||||
|
||||
def test_load_app_access_mode_no_op_when_app_missing():
|
||||
data = _make_auth_data()
|
||||
load_app_access_mode(data)
|
||||
assert data.app_access_mode is None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flask_app():
|
||||
return Flask(__name__)
|
||||
|
||||
|
||||
def test_load_tenant_from_request_from_path_params(flask_app):
|
||||
tenant = MagicMock()
|
||||
tenant.status = "normal"
|
||||
wid = str(uuid.uuid4())
|
||||
data = _make_auth_data(path_params={"workspace_id": wid})
|
||||
with flask_app.test_request_context("/test"):
|
||||
with patch("controllers.openapi.auth.prepare.TenantService.get_tenant_by_id", return_value=tenant):
|
||||
load_tenant_from_request(data)
|
||||
assert data.tenant is tenant
|
||||
|
||||
|
||||
def test_load_tenant_from_request_from_query_param(flask_app):
|
||||
tenant = MagicMock()
|
||||
tenant.status = "normal"
|
||||
wid = str(uuid.uuid4())
|
||||
data = _make_auth_data(path_params={})
|
||||
with flask_app.test_request_context(f"/test?workspace_id={wid}"):
|
||||
with patch("controllers.openapi.auth.prepare.TenantService.get_tenant_by_id", return_value=tenant):
|
||||
load_tenant_from_request(data)
|
||||
assert data.tenant is tenant
|
||||
|
||||
|
||||
def test_load_tenant_from_request_skips_when_already_set(flask_app):
|
||||
existing_tenant = MagicMock()
|
||||
data = _make_auth_data(tenant=existing_tenant, path_params={})
|
||||
with flask_app.test_request_context("/test"):
|
||||
load_tenant_from_request(data)
|
||||
assert data.tenant is existing_tenant
|
||||
|
||||
|
||||
def test_load_tenant_from_request_raises_not_found_when_no_id(flask_app):
|
||||
data = _make_auth_data(path_params={})
|
||||
with flask_app.test_request_context("/test"):
|
||||
with pytest.raises(NotFound):
|
||||
load_tenant_from_request(data)
|
||||
|
||||
|
||||
def test_load_tenant_from_request_raises_not_found_when_missing(flask_app):
|
||||
wid = str(uuid.uuid4())
|
||||
data = _make_auth_data(path_params={"workspace_id": wid})
|
||||
with flask_app.test_request_context("/test"):
|
||||
with patch("controllers.openapi.auth.prepare.TenantService.get_tenant_by_id", return_value=None):
|
||||
with pytest.raises(NotFound):
|
||||
load_tenant_from_request(data)
|
||||
|
||||
|
||||
def test_load_tenant_from_request_raises_not_found_when_archived(flask_app):
|
||||
from models.account import TenantStatus
|
||||
|
||||
tenant = MagicMock()
|
||||
tenant.status = TenantStatus.ARCHIVE
|
||||
wid = str(uuid.uuid4())
|
||||
data = _make_auth_data(path_params={"workspace_id": wid})
|
||||
with flask_app.test_request_context("/test"):
|
||||
with patch("controllers.openapi.auth.prepare.TenantService.get_tenant_by_id", return_value=tenant):
|
||||
with pytest.raises(NotFound):
|
||||
load_tenant_from_request(data)
|
||||
|
||||
|
||||
def test_load_tenant_from_request_raises_not_found_when_invalid_uuid(flask_app):
|
||||
data = _make_auth_data(path_params={"workspace_id": "not-a-uuid"})
|
||||
with flask_app.test_request_context("/test"):
|
||||
with pytest.raises(NotFound):
|
||||
load_tenant_from_request(data)
|
||||
|
||||
|
||||
# --- load_workspace_role ---
|
||||
|
||||
|
||||
def test_load_workspace_role_stashes_role():
|
||||
tenant = MagicMock()
|
||||
tenant.id = uuid.uuid4()
|
||||
caller = MagicMock()
|
||||
caller.status = "active"
|
||||
data = _make_auth_data(account_id=uuid.uuid4(), tenant=tenant, caller=caller)
|
||||
with patch(
|
||||
"controllers.openapi.auth.prepare.TenantService.get_account_role_in_tenant",
|
||||
return_value=TenantAccountRole.ADMIN,
|
||||
):
|
||||
load_workspace_role(data)
|
||||
assert data.tenant_role == TenantAccountRole.ADMIN
|
||||
|
||||
|
||||
def test_load_workspace_role_none_when_not_member():
|
||||
tenant = MagicMock()
|
||||
tenant.id = uuid.uuid4()
|
||||
caller = MagicMock()
|
||||
caller.status = "active"
|
||||
data = _make_auth_data(account_id=uuid.uuid4(), tenant=tenant, caller=caller)
|
||||
with patch(
|
||||
"controllers.openapi.auth.prepare.TenantService.get_account_role_in_tenant",
|
||||
return_value=None,
|
||||
):
|
||||
load_workspace_role(data)
|
||||
assert data.tenant_role is None
|
||||
|
||||
|
||||
def test_load_workspace_role_none_when_account_inactive():
|
||||
tenant = MagicMock()
|
||||
tenant.id = uuid.uuid4()
|
||||
caller = MagicMock()
|
||||
caller.status = "banned"
|
||||
data = _make_auth_data(account_id=uuid.uuid4(), tenant=tenant, caller=caller)
|
||||
load_workspace_role(data)
|
||||
assert data.tenant_role is None
|
||||
|
||||
|
||||
def test_load_workspace_role_skips_when_already_set():
|
||||
tenant = MagicMock()
|
||||
tenant.id = uuid.uuid4()
|
||||
caller = MagicMock()
|
||||
caller.status = "active"
|
||||
data = _make_auth_data(
|
||||
account_id=uuid.uuid4(),
|
||||
tenant=tenant,
|
||||
caller=caller,
|
||||
tenant_role=TenantAccountRole.OWNER,
|
||||
def _app(
|
||||
*,
|
||||
app_id: str = APP_ID,
|
||||
tenant_id: str = TENANT_ID,
|
||||
enable_api: bool = True,
|
||||
) -> App:
|
||||
return App(
|
||||
id=app_id,
|
||||
tenant_id=tenant_id,
|
||||
name="OpenAPI app",
|
||||
description="",
|
||||
mode=AppMode.CHAT,
|
||||
icon_type=IconType.EMOJI,
|
||||
icon="robot",
|
||||
icon_background="#FFFFFF",
|
||||
status=AppStatus.NORMAL,
|
||||
enable_site=True,
|
||||
enable_api=enable_api,
|
||||
max_active_requests=None,
|
||||
)
|
||||
load_workspace_role(data)
|
||||
assert data.tenant_role == TenantAccountRole.OWNER
|
||||
|
||||
|
||||
def test_load_workspace_role_skips_when_tenant_missing():
|
||||
data = _make_auth_data(account_id=uuid.uuid4())
|
||||
load_workspace_role(data)
|
||||
assert data.tenant_role is None
|
||||
def _tenant(*, tenant_id: str = TENANT_ID, status: TenantStatus = TenantStatus.NORMAL) -> Tenant:
|
||||
tenant = Tenant(name="OpenAPI tenant", status=status)
|
||||
tenant.id = tenant_id
|
||||
return tenant
|
||||
|
||||
|
||||
def test_load_workspace_role_skips_when_account_id_missing():
|
||||
tenant = MagicMock()
|
||||
data = _make_auth_data(tenant=tenant, account_id=None)
|
||||
load_workspace_role(data)
|
||||
assert data.tenant_role is None
|
||||
def _account(*, status: AccountStatus = AccountStatus.ACTIVE) -> Account:
|
||||
account = Account(name="OpenAPI account", email="account@example.com", status=status)
|
||||
account.id = ACCOUNT_ID
|
||||
return account
|
||||
|
||||
|
||||
def _persist(session: Session, *models: object) -> None:
|
||||
session.add_all(models)
|
||||
session.commit()
|
||||
|
||||
|
||||
class TestLoadApp:
|
||||
def test_writes_persisted_app_to_data(self, sqlite_session: Session) -> None:
|
||||
_persist(sqlite_session, _app())
|
||||
data = _make_auth_data(path_params={"app_id": APP_ID})
|
||||
|
||||
load_app(data)
|
||||
|
||||
assert data.app is not None
|
||||
assert data.app.id == APP_ID
|
||||
|
||||
def test_rejects_non_uuid_and_missing_app(self) -> None:
|
||||
with pytest.raises(NotFound, match="app not found"):
|
||||
load_app(_make_auth_data(path_params={"app_id": "not-a-uuid"}))
|
||||
with pytest.raises(NotFound, match="app not found"):
|
||||
load_app(_make_auth_data(path_params={"app_id": APP_ID}))
|
||||
|
||||
def test_rejects_non_normal_app(self) -> None:
|
||||
app = _app()
|
||||
app.status = "archived" # type: ignore[assignment]
|
||||
|
||||
with (
|
||||
patch("controllers.openapi.auth.prepare.AppService.get_app_by_id", return_value=app),
|
||||
pytest.raises(NotFound, match="app not found"),
|
||||
):
|
||||
load_app(_make_auth_data(path_params={"app_id": APP_ID}))
|
||||
|
||||
def test_stashes_app_even_when_api_disabled(self, sqlite_session: Session) -> None:
|
||||
_persist(sqlite_session, _app(enable_api=False))
|
||||
data = _make_auth_data(path_params={"app_id": APP_ID})
|
||||
|
||||
load_app(data)
|
||||
|
||||
assert data.app is not None
|
||||
assert data.app.enable_api is False
|
||||
|
||||
def test_skips_when_already_set(self) -> None:
|
||||
existing_app = _app()
|
||||
data = _make_auth_data(app=existing_app, path_params={"app_id": "invalid"})
|
||||
|
||||
load_app(data)
|
||||
|
||||
assert data.app is existing_app
|
||||
|
||||
|
||||
class TestLoadTenant:
|
||||
def test_writes_persisted_tenant(self, sqlite_session: Session) -> None:
|
||||
app = _app()
|
||||
_persist(sqlite_session, app, _tenant())
|
||||
data = _make_auth_data(app=app)
|
||||
|
||||
load_tenant(data)
|
||||
|
||||
assert data.tenant is not None
|
||||
assert data.tenant.id == TENANT_ID
|
||||
|
||||
def test_skips_when_already_set(self) -> None:
|
||||
tenant = _tenant()
|
||||
data = _make_auth_data(app=_app(), tenant=tenant)
|
||||
|
||||
load_tenant(data)
|
||||
|
||||
assert data.tenant is tenant
|
||||
|
||||
@pytest.mark.parametrize("persist_archived", [True, False])
|
||||
def test_rejects_archived_or_missing_tenant(self, sqlite_session: Session, persist_archived: bool) -> None:
|
||||
app = _app()
|
||||
models: list[object] = [app]
|
||||
if persist_archived:
|
||||
models.append(_tenant(status=TenantStatus.ARCHIVE))
|
||||
_persist(sqlite_session, *models)
|
||||
|
||||
with pytest.raises(Forbidden, match="workspace unavailable"):
|
||||
load_tenant(_make_auth_data(app=app))
|
||||
|
||||
def test_rejects_missing_app_context(self) -> None:
|
||||
with pytest.raises(InternalServerError, match="app not loaded"):
|
||||
load_tenant(_make_auth_data())
|
||||
|
||||
|
||||
class TestLoadAccount:
|
||||
def test_writes_persisted_caller(self, sqlite_session: Session) -> None:
|
||||
_persist(sqlite_session, _account())
|
||||
data = _make_auth_data(account_id=uuid.UUID(ACCOUNT_ID))
|
||||
|
||||
load_account(data)
|
||||
|
||||
assert data.caller is not None
|
||||
assert data.caller.id == ACCOUNT_ID
|
||||
assert data.caller_kind == "account"
|
||||
|
||||
def test_sets_current_tenant_from_real_membership(self, sqlite_session: Session) -> None:
|
||||
account = _account()
|
||||
tenant = _tenant()
|
||||
membership = TenantAccountJoin(
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
current=True,
|
||||
role=TenantAccountRole.ADMIN,
|
||||
)
|
||||
_persist(sqlite_session, account, tenant, membership)
|
||||
data = _make_auth_data(account_id=uuid.UUID(ACCOUNT_ID), tenant=tenant)
|
||||
|
||||
load_account(data)
|
||||
|
||||
assert isinstance(data.caller, Account)
|
||||
assert data.caller.current_tenant_id == TENANT_ID
|
||||
assert data.caller.role == TenantAccountRole.ADMIN
|
||||
|
||||
def test_skips_when_caller_already_set(self) -> None:
|
||||
account = _account()
|
||||
data = _make_auth_data(account_id=uuid.UUID(ACCOUNT_ID), caller=account)
|
||||
|
||||
load_account(data)
|
||||
|
||||
assert data.caller is account
|
||||
|
||||
def test_rejects_missing_account(self) -> None:
|
||||
with pytest.raises(Unauthorized, match="account not found"):
|
||||
load_account(_make_auth_data(account_id=uuid.UUID(ACCOUNT_ID)))
|
||||
|
||||
|
||||
class TestResolveExternalUser:
|
||||
def test_persists_and_writes_end_user(
|
||||
self,
|
||||
sqlite_engine: Engine,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
app = _app()
|
||||
tenant = _tenant()
|
||||
_persist(sqlite_session, app, tenant)
|
||||
data = _make_auth_data(
|
||||
tenant=tenant,
|
||||
app=app,
|
||||
external_identity=ExternalIdentity(email="user@sso.com"),
|
||||
)
|
||||
|
||||
with patch.object(type(end_user_service.db), "engine", new_callable=PropertyMock) as engine:
|
||||
engine.return_value = sqlite_engine
|
||||
resolve_external_user(data)
|
||||
|
||||
assert isinstance(data.caller, EndUser)
|
||||
assert data.caller_kind == "end_user"
|
||||
with Session(sqlite_engine) as observer:
|
||||
persisted = observer.scalar(select(EndUser).where(EndUser.session_id == "user@sso.com"))
|
||||
assert persisted is not None
|
||||
assert persisted.tenant_id == TENANT_ID
|
||||
assert persisted.app_id == APP_ID
|
||||
|
||||
def test_rejects_missing_context(self) -> None:
|
||||
data = _make_auth_data(app=_app(), external_identity=ExternalIdentity(email="u@s.com"))
|
||||
|
||||
with pytest.raises(Unauthorized, match="missing context"):
|
||||
resolve_external_user(data)
|
||||
|
||||
|
||||
class TestLoadAppAccessMode:
|
||||
def test_writes_mode(self) -> None:
|
||||
data = _make_auth_data(app=_app())
|
||||
settings = SimpleNamespace(access_mode="public")
|
||||
|
||||
with patch(
|
||||
"controllers.openapi.auth.prepare.EnterpriseService.WebAppAuth.get_app_access_mode_by_id",
|
||||
return_value=settings,
|
||||
):
|
||||
load_app_access_mode(data)
|
||||
|
||||
assert data.app_access_mode == WebAppAccessMode.PUBLIC
|
||||
|
||||
def test_writes_none_when_provider_raises(self) -> None:
|
||||
data = _make_auth_data(app=_app())
|
||||
with patch(
|
||||
"controllers.openapi.auth.prepare.EnterpriseService.WebAppAuth.get_app_access_mode_by_id",
|
||||
side_effect=ValueError("No data found."),
|
||||
):
|
||||
load_app_access_mode(data)
|
||||
assert data.app_access_mode is None
|
||||
|
||||
def test_noop_without_app(self) -> None:
|
||||
data = _make_auth_data()
|
||||
load_app_access_mode(data)
|
||||
assert data.app_access_mode is None
|
||||
|
||||
|
||||
class TestLoadTenantFromRequest:
|
||||
def test_loads_from_path_or_query(
|
||||
self,
|
||||
app: Flask,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
_persist(sqlite_session, _tenant())
|
||||
for path_params, path in (({"workspace_id": TENANT_ID}, "/test"), ({}, f"/test?workspace_id={TENANT_ID}")):
|
||||
data = _make_auth_data(path_params=path_params)
|
||||
with app.test_request_context(path):
|
||||
load_tenant_from_request(data)
|
||||
assert data.tenant is not None
|
||||
assert data.tenant.id == TENANT_ID
|
||||
|
||||
def test_skips_when_already_set(self, app: Flask) -> None:
|
||||
tenant = _tenant()
|
||||
data = _make_auth_data(tenant=tenant)
|
||||
with app.test_request_context("/test"):
|
||||
load_tenant_from_request(data)
|
||||
assert data.tenant is tenant
|
||||
|
||||
def test_rejects_missing_or_invalid_id(self, app: Flask) -> None:
|
||||
for path_params in ({}, {"workspace_id": "not-a-uuid"}):
|
||||
with app.test_request_context("/test"), pytest.raises(NotFound, match="workspace not found"):
|
||||
load_tenant_from_request(_make_auth_data(path_params=path_params))
|
||||
|
||||
@pytest.mark.parametrize("tenant_status", [None, TenantStatus.ARCHIVE])
|
||||
def test_rejects_missing_or_archived_tenant(
|
||||
self,
|
||||
app: Flask,
|
||||
sqlite_session: Session,
|
||||
tenant_status: TenantStatus | None,
|
||||
) -> None:
|
||||
if tenant_status is not None:
|
||||
_persist(sqlite_session, _tenant(status=tenant_status))
|
||||
data = _make_auth_data(path_params={"workspace_id": TENANT_ID})
|
||||
|
||||
with app.test_request_context("/test"), pytest.raises(NotFound, match="workspace not found"):
|
||||
load_tenant_from_request(data)
|
||||
|
||||
|
||||
class TestLoadWorkspaceRole:
|
||||
def test_loads_real_membership_role(self, sqlite_session: Session) -> None:
|
||||
account = _account()
|
||||
tenant = _tenant()
|
||||
membership = TenantAccountJoin(
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
current=True,
|
||||
role=TenantAccountRole.ADMIN,
|
||||
)
|
||||
_persist(sqlite_session, account, tenant, membership)
|
||||
data = _make_auth_data(
|
||||
account_id=uuid.UUID(ACCOUNT_ID),
|
||||
tenant=tenant,
|
||||
caller=account,
|
||||
)
|
||||
|
||||
load_workspace_role(data)
|
||||
|
||||
assert data.tenant_role == TenantAccountRole.ADMIN
|
||||
|
||||
def test_none_when_not_member(self, sqlite_session: Session) -> None:
|
||||
account = _account()
|
||||
tenant = _tenant()
|
||||
_persist(sqlite_session, account, tenant)
|
||||
data = _make_auth_data(account_id=uuid.UUID(ACCOUNT_ID), tenant=tenant, caller=account)
|
||||
|
||||
load_workspace_role(data)
|
||||
|
||||
assert data.tenant_role is None
|
||||
|
||||
def test_none_when_account_inactive(self) -> None:
|
||||
data = _make_auth_data(
|
||||
account_id=uuid.UUID(ACCOUNT_ID),
|
||||
tenant=_tenant(),
|
||||
caller=_account(status=AccountStatus.BANNED),
|
||||
)
|
||||
load_workspace_role(data)
|
||||
assert data.tenant_role is None
|
||||
|
||||
def test_skips_when_already_set(self) -> None:
|
||||
data = _make_auth_data(
|
||||
account_id=uuid.UUID(ACCOUNT_ID),
|
||||
tenant=_tenant(),
|
||||
caller=_account(),
|
||||
tenant_role=TenantAccountRole.OWNER,
|
||||
)
|
||||
load_workspace_role(data)
|
||||
assert data.tenant_role == TenantAccountRole.OWNER
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data",
|
||||
[
|
||||
_make_auth_data(account_id=uuid.UUID(ACCOUNT_ID)),
|
||||
_make_auth_data(tenant=_tenant(), account_id=None),
|
||||
],
|
||||
)
|
||||
def test_skips_without_tenant_or_account(self, data: AuthData) -> None:
|
||||
load_workspace_role(data)
|
||||
assert data.tenant_role is None
|
||||
|
||||
@ -1,25 +1,21 @@
|
||||
"""Unit tests for Service API dataset controller behavior.
|
||||
|
||||
Service boundaries stay mocked, while ORM collaborators are concrete model instances
|
||||
persisted in one in-memory SQLite session. The controller's ``db.session`` and the
|
||||
session passed to unwrapped ``@with_session`` endpoints both use that same session,
|
||||
so model properties and service call contracts exercise real SQLAlchemy behavior.
|
||||
persisted through the shared SQLite session fixture.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from inspect import unwrap
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
import services
|
||||
from controllers.service_api.dataset.error import DatasetInUseError, DatasetNameDuplicateError, InvalidActionError
|
||||
from extensions.ext_database import db
|
||||
from models.account import Account, Tenant, TenantAccountRole
|
||||
from models.dataset import AppDatasetJoin, Dataset, DatasetMetadata, Document
|
||||
from models.enums import PermissionEnum
|
||||
@ -42,15 +38,9 @@ DATASET_MODEL_TABLES = (
|
||||
pytestmark = pytest.mark.parametrize("sqlite_session", [DATASET_MODEL_TABLES], indirect=True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def controller_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Session:
|
||||
"""Route controller and model database access through the test's SQLite session."""
|
||||
|
||||
# Flask-SQLAlchemy exposes a callable registry that also proxies Session methods.
|
||||
# Seed that registry with this fixture's Session so both access styles share one transaction.
|
||||
existing_session_factory = cast(sessionmaker[Session], lambda: sqlite_session)
|
||||
session_registry = scoped_session(existing_session_factory)
|
||||
monkeypatch.setattr(db, "session", session_registry)
|
||||
@pytest.fixture
|
||||
def controller_session(sqlite_session: Session) -> Session:
|
||||
"""Expose the shared SQLite session under the controller-focused fixture name."""
|
||||
return sqlite_session
|
||||
|
||||
|
||||
|
||||
@ -7,15 +7,13 @@ cover the concrete objects and session passed across the controller boundary.
|
||||
|
||||
import uuid
|
||||
from inspect import unwrap
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models.account import Account, Tenant, TenantAccountRole
|
||||
from models.enums import TagType
|
||||
from models.model import Tag
|
||||
@ -24,15 +22,9 @@ TAG_MODEL_TABLES = (Account, Tenant, Tag)
|
||||
pytestmark = pytest.mark.parametrize("sqlite_session", [TAG_MODEL_TABLES], indirect=True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def controller_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Session:
|
||||
"""Route controller database access through the test's SQLite session."""
|
||||
|
||||
# Flask-SQLAlchemy exposes a callable registry that also proxies Session methods.
|
||||
# Seed that registry with this fixture's Session so both access styles share one transaction.
|
||||
existing_session_factory = cast(sessionmaker[Session], lambda: sqlite_session)
|
||||
session_registry = scoped_session(existing_session_factory)
|
||||
monkeypatch.setattr(db, "session", session_registry)
|
||||
@pytest.fixture
|
||||
def controller_session(sqlite_session: Session) -> Session:
|
||||
"""Expose the shared SQLite session under the controller-focused fixture name."""
|
||||
return sqlite_session
|
||||
|
||||
|
||||
|
||||
@ -18,16 +18,19 @@ Focus on:
|
||||
import inspect
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import Mock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from controllers.common.errors import FileTooLargeError as FileTooLargeHTTPError
|
||||
from controllers.service_api.dataset import document as document_module
|
||||
from controllers.service_api.dataset.document import (
|
||||
DeprecatedDocumentAddByTextApi,
|
||||
DeprecatedDocumentUpdateByFileApi,
|
||||
@ -45,8 +48,19 @@ from controllers.service_api.dataset.document import (
|
||||
)
|
||||
from controllers.service_api.dataset.error import ArchivedDocumentImmutableError
|
||||
from core.rag.index_processor.constant.index_type import IndexStructureType
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from models.account import Account
|
||||
from models.dataset import Dataset, Document, DocumentSegment
|
||||
from models.enums import DataSourceType, DocumentCreatedFrom, DocumentDocType, IndexingStatus, SegmentStatus
|
||||
from models.enums import (
|
||||
ApiTokenType,
|
||||
CreatorUserRole,
|
||||
DataSourceType,
|
||||
DocumentCreatedFrom,
|
||||
DocumentDocType,
|
||||
IndexingStatus,
|
||||
SegmentStatus,
|
||||
)
|
||||
from models.model import ApiToken, UploadFile
|
||||
from services.dataset_ref_service import DatasetRef
|
||||
from services.dataset_service import DocumentService
|
||||
from services.entities.knowledge_entities.knowledge_entities import ProcessRule, RetrievalModel
|
||||
@ -57,6 +71,30 @@ def _document_data_source_info() -> dict[str, str]:
|
||||
return {"type": "website_crawl", "url": "https://example.com/docs", "title": "Docs"}
|
||||
|
||||
|
||||
def _account() -> Account:
|
||||
account = Account(name="Document API User", email=f"document-api-{uuid.uuid4()}@example.com")
|
||||
account.id = "user-1"
|
||||
return account
|
||||
|
||||
|
||||
def _upload_file() -> UploadFile:
|
||||
upload_file = UploadFile(
|
||||
tenant_id="tenant-1",
|
||||
storage_type=StorageType.LOCAL,
|
||||
key="documents/file.txt",
|
||||
name="file.txt",
|
||||
size=10,
|
||||
extension="txt",
|
||||
mime_type="text/plain",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="user-1",
|
||||
created_at=datetime.now(UTC),
|
||||
used=True,
|
||||
)
|
||||
upload_file.id = str(uuid.uuid4())
|
||||
return upload_file
|
||||
|
||||
|
||||
def _unwrap_non_wrapped_controller(view):
|
||||
while view.__closure__:
|
||||
inner_functions = [cell.cell_contents for cell in view.__closure__ if inspect.isfunction(cell.cell_contents)]
|
||||
@ -603,8 +641,8 @@ class TestDocumentServiceFileOperations:
|
||||
@patch("services.dataset_service.DocumentService._get_upload_file_for_upload_file_document")
|
||||
def test_get_document_download_url(self, mock_get_file, mock_signed_url, sqlite_session: Session):
|
||||
"""Test generation of download URL."""
|
||||
mock_doc = Mock()
|
||||
mock_file = Mock()
|
||||
mock_doc = make_serializable_document()
|
||||
mock_file = _upload_file()
|
||||
mock_file.id = "file_id"
|
||||
mock_get_file.return_value = mock_file
|
||||
mock_signed_url.return_value = "https://example.com/download"
|
||||
@ -622,11 +660,9 @@ class TestDocumentServiceSaveValidation:
|
||||
|
||||
@patch("services.dataset_service.DatasetService.check_doc_form")
|
||||
@patch("services.dataset_service.FeatureService.get_features")
|
||||
@patch("services.dataset_service.current_user")
|
||||
def test_save_document_validates_doc_form(self, mock_user, mock_features, mock_check_form, sqlite_session: Session):
|
||||
def test_save_document_validates_doc_form(self, mock_features, mock_check_form, sqlite_session: Session):
|
||||
"""Test that doc_form is validated during save."""
|
||||
mock_user.current_tenant_id = "tenant_id"
|
||||
dataset = Mock()
|
||||
dataset = make_dataset(tenant_id="tenant_id")
|
||||
config = Mock()
|
||||
features = Mock()
|
||||
features.billing.enabled = False
|
||||
@ -641,7 +677,7 @@ class TestDocumentServiceSaveValidation:
|
||||
# Skip actual logic by mocking dependent calls or raising error to stop early
|
||||
with pytest.raises(TestStopError):
|
||||
# We just want to check check_doc_form is called early
|
||||
DocumentService.save_document_with_dataset_id(dataset, config, Mock(), session=session)
|
||||
DocumentService.save_document_with_dataset_id(dataset, config, _account(), session=session)
|
||||
|
||||
# This will fail if we raise exception before check_doc_form,
|
||||
# but check_doc_form is the first thing called.
|
||||
@ -972,7 +1008,24 @@ class TestDocumentApiGet:
|
||||
)
|
||||
|
||||
|
||||
class TestDocumentApiDelete:
|
||||
class SQLiteControllerTest:
|
||||
session: Session
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _use_sqlite_session(self, sqlite_session: Session, sqlite_engine: Engine) -> Iterator[None]:
|
||||
self.session = sqlite_session
|
||||
with (
|
||||
patch.object(type(document_module.db), "engine", new_callable=PropertyMock, return_value=sqlite_engine),
|
||||
patch.object(document_module, "current_user", _account()),
|
||||
):
|
||||
yield
|
||||
|
||||
def _persist_dataset(self, dataset: Dataset) -> None:
|
||||
self.session.add(dataset)
|
||||
self.session.commit()
|
||||
|
||||
|
||||
class TestDocumentApiDelete(SQLiteControllerTest):
|
||||
"""Test suite for DocumentApi.delete() endpoint.
|
||||
|
||||
``delete`` is wrapped by ``@cloud_edition_billing_rate_limit_check`` which
|
||||
@ -982,13 +1035,12 @@ 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: Flask, mock_tenant, mock_document):
|
||||
def test_delete_document_success(self, mock_doc_svc, app: Flask, mock_tenant, mock_document):
|
||||
"""Test successful document deletion."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid.uuid4())
|
||||
mock_dataset = make_dataset(id=dataset_id, tenant_id=mock_tenant)
|
||||
mock_db.session.scalar.return_value = mock_dataset
|
||||
self._persist_dataset(mock_dataset)
|
||||
|
||||
mock_doc_svc.get_document.return_value = mock_document
|
||||
mock_doc_svc.check_archived.return_value = False
|
||||
@ -1003,7 +1055,7 @@ class TestDocumentApiDelete:
|
||||
delete = inspect.unwrap(type(api).delete)
|
||||
response = delete(
|
||||
api,
|
||||
mock_db.session,
|
||||
self.session,
|
||||
tenant_id=mock_tenant,
|
||||
dataset_id=dataset_id,
|
||||
document_id=mock_document.id,
|
||||
@ -1011,17 +1063,16 @@ class TestDocumentApiDelete:
|
||||
|
||||
# Assert
|
||||
assert response == ("", 204)
|
||||
mock_doc_svc.delete_document.assert_called_once_with(mock_document, mock_db.session)
|
||||
mock_doc_svc.delete_document.assert_called_once_with(mock_document, self.session)
|
||||
|
||||
@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: Flask, mock_tenant):
|
||||
def test_delete_document_not_found(self, mock_doc_svc, app: Flask, mock_tenant):
|
||||
"""Test 404 when document not found."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid.uuid4())
|
||||
document_id = str(uuid.uuid4())
|
||||
mock_dataset = make_dataset(id=dataset_id, tenant_id=mock_tenant)
|
||||
mock_db.session.scalar.return_value = mock_dataset
|
||||
self._persist_dataset(mock_dataset)
|
||||
|
||||
mock_doc_svc.get_document.return_value = None
|
||||
|
||||
@ -1035,20 +1086,19 @@ class TestDocumentApiDelete:
|
||||
with pytest.raises(NotFound):
|
||||
delete(
|
||||
api,
|
||||
mock_db.session,
|
||||
self.session,
|
||||
tenant_id=mock_tenant,
|
||||
dataset_id=dataset_id,
|
||||
document_id=document_id,
|
||||
)
|
||||
|
||||
@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: Flask, mock_tenant, mock_document):
|
||||
def test_delete_document_archived_forbidden(self, mock_doc_svc, app: Flask, mock_tenant, mock_document):
|
||||
"""Test ArchivedDocumentImmutableError when deleting archived document."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid.uuid4())
|
||||
mock_dataset = make_dataset(id=dataset_id, tenant_id=mock_tenant)
|
||||
mock_db.session.scalar.return_value = mock_dataset
|
||||
self._persist_dataset(mock_dataset)
|
||||
|
||||
mock_doc_svc.get_document.return_value = mock_document
|
||||
mock_doc_svc.check_archived.return_value = True
|
||||
@ -1063,20 +1113,18 @@ class TestDocumentApiDelete:
|
||||
with pytest.raises(ArchivedDocumentImmutableError):
|
||||
delete(
|
||||
api,
|
||||
mock_db.session,
|
||||
self.session,
|
||||
tenant_id=mock_tenant,
|
||||
dataset_id=dataset_id,
|
||||
document_id=mock_document.id,
|
||||
)
|
||||
|
||||
@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: Flask, mock_tenant):
|
||||
def test_delete_document_dataset_not_found(self, mock_doc_svc, app: Flask, mock_tenant):
|
||||
"""Test ValueError when dataset not found."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid.uuid4())
|
||||
document_id = str(uuid.uuid4())
|
||||
mock_db.session.scalar.return_value = None
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
@ -1088,23 +1136,22 @@ class TestDocumentApiDelete:
|
||||
with pytest.raises(ValueError, match="Dataset does not exist."):
|
||||
delete(
|
||||
api,
|
||||
mock_db.session,
|
||||
self.session,
|
||||
tenant_id=mock_tenant,
|
||||
dataset_id=dataset_id,
|
||||
document_id=document_id,
|
||||
)
|
||||
|
||||
|
||||
class TestDocumentListApi:
|
||||
class TestDocumentListApi(SQLiteControllerTest):
|
||||
"""Test suite for DocumentListApi endpoint."""
|
||||
|
||||
@patch("controllers.service_api.dataset.document.paginate_query")
|
||||
@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_paginate, app: Flask, mock_tenant, mock_dataset):
|
||||
def test_list_documents_success(self, mock_doc_svc, mock_paginate, app: Flask, mock_tenant, mock_dataset):
|
||||
"""Test successful document list retrieval."""
|
||||
# Arrange
|
||||
mock_db.session.scalar.side_effect = [mock_dataset, 0, 0]
|
||||
self._persist_dataset(mock_dataset)
|
||||
|
||||
documents = [
|
||||
make_serializable_document(
|
||||
@ -1128,7 +1175,7 @@ class TestDocumentListApi:
|
||||
):
|
||||
api = DocumentListApi()
|
||||
response = inspect.unwrap(type(api).get)(
|
||||
api, mock_db.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
api, self.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
)
|
||||
|
||||
# Assert
|
||||
@ -1142,11 +1189,9 @@ class TestDocumentListApi:
|
||||
assert "data_source_info_dict" not in response["data"][0]
|
||||
assert "doc_metadata_details" not in response["data"][0]
|
||||
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
def test_list_documents_dataset_not_found(self, mock_db, app: Flask, mock_tenant, mock_dataset):
|
||||
def test_list_documents_dataset_not_found(self, app: Flask, mock_tenant, mock_dataset):
|
||||
"""Test 404 when dataset not found."""
|
||||
# Arrange
|
||||
mock_db.session.scalar.return_value = None
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
@ -1155,15 +1200,14 @@ class TestDocumentListApi:
|
||||
):
|
||||
api = DocumentListApi()
|
||||
with pytest.raises(NotFound):
|
||||
inspect.unwrap(type(api).get)(api, mock_db.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id)
|
||||
inspect.unwrap(type(api).get)(api, self.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id)
|
||||
|
||||
|
||||
class TestDocumentIndexingStatusApi:
|
||||
class TestDocumentIndexingStatusApi(SQLiteControllerTest):
|
||||
"""Test suite for DocumentIndexingStatusApi endpoint."""
|
||||
|
||||
@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, app: Flask, mock_tenant, mock_dataset):
|
||||
def test_get_indexing_status_success(self, mock_doc_svc, app: Flask, mock_tenant, mock_dataset):
|
||||
"""Test successful indexing status retrieval."""
|
||||
# Arrange
|
||||
batch_id = "batch_123"
|
||||
@ -1176,8 +1220,25 @@ class TestDocumentIndexingStatusApi:
|
||||
|
||||
mock_doc_svc.get_batch_documents.return_value = [document]
|
||||
|
||||
# scalar() called 3 times: dataset lookup, completed_segments count, total_segments count
|
||||
mock_db.session.scalar.side_effect = [mock_dataset, 5, 5]
|
||||
self._persist_dataset(mock_dataset)
|
||||
self.session.add_all(
|
||||
[
|
||||
DocumentSegment(
|
||||
tenant_id=mock_tenant,
|
||||
dataset_id=mock_dataset.id,
|
||||
document_id=document.id,
|
||||
position=position,
|
||||
content=f"Segment {position}",
|
||||
word_count=2,
|
||||
tokens=2,
|
||||
created_by="user-1",
|
||||
status=SegmentStatus.COMPLETED,
|
||||
completed_at=datetime(2021, 1, 1, tzinfo=UTC),
|
||||
)
|
||||
for position in range(1, 6)
|
||||
]
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
# Act
|
||||
with app.test_request_context(
|
||||
@ -1187,7 +1248,7 @@ class TestDocumentIndexingStatusApi:
|
||||
api = DocumentIndexingStatusApi()
|
||||
response = inspect.unwrap(type(api).get)(
|
||||
api,
|
||||
mock_db.session,
|
||||
self.session,
|
||||
tenant_id=mock_tenant,
|
||||
dataset_id=mock_dataset.id,
|
||||
batch=batch_id,
|
||||
@ -1216,12 +1277,10 @@ class TestDocumentIndexingStatusApi:
|
||||
]
|
||||
}
|
||||
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
def test_get_indexing_status_dataset_not_found(self, mock_db, app: Flask, mock_tenant, mock_dataset):
|
||||
def test_get_indexing_status_dataset_not_found(self, app: Flask, mock_tenant, mock_dataset):
|
||||
"""Test 404 when dataset not found."""
|
||||
# Arrange
|
||||
batch_id = "batch_123"
|
||||
mock_db.session.scalar.return_value = None
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
@ -1232,21 +1291,18 @@ class TestDocumentIndexingStatusApi:
|
||||
with pytest.raises(NotFound):
|
||||
inspect.unwrap(type(api).get)(
|
||||
api,
|
||||
mock_db.session,
|
||||
self.session,
|
||||
tenant_id=mock_tenant,
|
||||
dataset_id=mock_dataset.id,
|
||||
batch=batch_id,
|
||||
)
|
||||
|
||||
@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: Flask, mock_tenant, mock_dataset
|
||||
):
|
||||
def test_get_indexing_status_documents_not_found(self, mock_doc_svc, app: Flask, mock_tenant, mock_dataset):
|
||||
"""Test 404 when no documents found for batch."""
|
||||
# Arrange
|
||||
batch_id = "batch_empty"
|
||||
mock_db.session.scalar.return_value = mock_dataset
|
||||
self._persist_dataset(mock_dataset)
|
||||
mock_doc_svc.get_batch_documents.return_value = []
|
||||
|
||||
# Act & Assert
|
||||
@ -1258,14 +1314,14 @@ class TestDocumentIndexingStatusApi:
|
||||
with pytest.raises(NotFound):
|
||||
inspect.unwrap(type(api).get)(
|
||||
api,
|
||||
mock_db.session,
|
||||
self.session,
|
||||
tenant_id=mock_tenant,
|
||||
dataset_id=mock_dataset.id,
|
||||
batch=batch_id,
|
||||
)
|
||||
|
||||
|
||||
class TestDocumentAddByTextApi:
|
||||
class TestDocumentAddByTextApi(SQLiteControllerTest):
|
||||
"""Test suite for DocumentAddByTextApi.post() endpoint.
|
||||
|
||||
``post`` is wrapped by ``@cloud_edition_billing_resource_check`` and
|
||||
@ -1286,9 +1342,8 @@ class TestDocumentAddByTextApi:
|
||||
``FeatureService.get_knowledge_rate_limit``.
|
||||
Both call ``validate_and_get_api_token`` first.
|
||||
"""
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.tenant_id = tenant_id
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
api_token = ApiToken(tenant_id=tenant_id, type=ApiTokenType.DATASET, token="dataset-token")
|
||||
mock_validate_token.return_value = api_token
|
||||
|
||||
mock_features = Mock()
|
||||
mock_features.billing.enabled = False
|
||||
@ -1306,16 +1361,12 @@ class TestDocumentAddByTextApi:
|
||||
@patch("controllers.service_api.dataset.document.DocumentService")
|
||||
@patch("controllers.service_api.dataset.document.KnowledgeConfig")
|
||||
@patch("controllers.service_api.dataset.document.FileService")
|
||||
@patch("controllers.service_api.dataset.document.current_user")
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
@patch("controllers.service_api.wraps.FeatureService")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_create_document_by_text_success(
|
||||
self,
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_db,
|
||||
mock_current_user,
|
||||
mock_file_svc_cls,
|
||||
mock_knowledge_config,
|
||||
mock_doc_svc,
|
||||
@ -1327,12 +1378,9 @@ class TestDocumentAddByTextApi:
|
||||
# Arrange — neutralise billing decorators
|
||||
self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant)
|
||||
|
||||
mock_db.session.scalar.side_effect = [mock_dataset, None, 0]
|
||||
self._persist_dataset(mock_dataset)
|
||||
mock_dataset.indexing_technique = "economy"
|
||||
mock_current_user.id = str(uuid.uuid4())
|
||||
|
||||
mock_upload_file = Mock()
|
||||
mock_upload_file.id = str(uuid.uuid4())
|
||||
mock_upload_file = _upload_file()
|
||||
mock_file_svc = Mock()
|
||||
mock_file_svc.upload_text.return_value = mock_upload_file
|
||||
mock_file_svc_cls.return_value = mock_file_svc
|
||||
@ -1357,7 +1405,7 @@ class TestDocumentAddByTextApi:
|
||||
):
|
||||
api = DocumentAddByTextApi()
|
||||
response, status = _unwrap_non_wrapped_controller(type(api).post)(
|
||||
api, mock_db.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
api, self.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
)
|
||||
|
||||
# Assert
|
||||
@ -1366,20 +1414,17 @@ class TestDocumentAddByTextApi:
|
||||
200,
|
||||
)
|
||||
assert "data_source_info_dict" not in response["document"]
|
||||
assert mock_doc_svc.save_document_with_dataset_id.call_args.kwargs["session"] is mock_db.session
|
||||
assert mock_doc_svc.save_document_with_dataset_id.call_args.kwargs["session"] is self.session
|
||||
|
||||
@patch("controllers.service_api.wraps.FeatureService")
|
||||
@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: Flask, mock_tenant, mock_dataset
|
||||
self, mock_validate_token, mock_feature_svc, app: Flask, mock_tenant, mock_dataset
|
||||
):
|
||||
"""Test ValueError when dataset not found."""
|
||||
# Arrange — neutralise billing decorators
|
||||
self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant)
|
||||
|
||||
mock_db.session.scalar.return_value = None
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
f"/datasets/{mock_dataset.id}/document/create-by-text",
|
||||
@ -1390,14 +1435,13 @@ class TestDocumentAddByTextApi:
|
||||
api = DocumentAddByTextApi()
|
||||
with pytest.raises(ValueError, match="Dataset does not exist."):
|
||||
_unwrap_non_wrapped_controller(type(api).post)(
|
||||
api, mock_db.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
api, self.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
)
|
||||
|
||||
@patch("controllers.service_api.wraps.FeatureService")
|
||||
@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: Flask, mock_tenant, mock_dataset
|
||||
self, mock_validate_token, mock_feature_svc, app: Flask, mock_tenant, mock_dataset
|
||||
):
|
||||
"""Test error when both dataset and payload lack indexing_technique.
|
||||
|
||||
@ -1409,7 +1453,7 @@ class TestDocumentAddByTextApi:
|
||||
self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant)
|
||||
|
||||
mock_dataset.indexing_technique = None
|
||||
mock_db.session.scalar.return_value = mock_dataset
|
||||
self._persist_dataset(mock_dataset)
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
@ -1421,7 +1465,7 @@ class TestDocumentAddByTextApi:
|
||||
api = DocumentAddByTextApi()
|
||||
with pytest.raises(ValueError, match="indexing_technique is required."):
|
||||
_unwrap_non_wrapped_controller(type(api).post)(
|
||||
api, mock_db.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
api, self.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
)
|
||||
|
||||
|
||||
@ -1474,9 +1518,8 @@ class TestDocumentRouteDeprecation:
|
||||
|
||||
def _setup_billing_mocks(mock_validate_token, mock_feature_svc, tenant_id: str):
|
||||
"""Configure mocks to neutralise billing/auth decorators."""
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.tenant_id = tenant_id
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
api_token = ApiToken(tenant_id=tenant_id, type=ApiTokenType.DATASET, token="dataset-token")
|
||||
mock_validate_token.return_value = api_token
|
||||
mock_features = Mock()
|
||||
mock_features.billing.enabled = False
|
||||
mock_feature_svc.get_features.return_value = mock_features
|
||||
@ -1489,7 +1532,7 @@ def _setup_billing_mocks(mock_validate_token, mock_feature_svc, tenant_id: str):
|
||||
mock_feature_svc.get_knowledge_rate_limit.return_value = mock_rate_limit
|
||||
|
||||
|
||||
class TestDocumentUpdateByTextApiPost:
|
||||
class TestDocumentUpdateByTextApiPost(SQLiteControllerTest):
|
||||
"""Test suite for DocumentUpdateByTextApi.post() endpoint.
|
||||
|
||||
``post`` is wrapped by ``@cloud_edition_billing_resource_check`` and
|
||||
@ -1498,16 +1541,12 @@ class TestDocumentUpdateByTextApiPost:
|
||||
|
||||
@patch("controllers.service_api.dataset.document.DocumentService")
|
||||
@patch("controllers.service_api.dataset.document.FileService")
|
||||
@patch("controllers.service_api.dataset.document.current_user")
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
@patch("controllers.service_api.wraps.FeatureService")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_update_by_text_success(
|
||||
self,
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_db,
|
||||
mock_current_user,
|
||||
mock_file_svc_cls,
|
||||
mock_doc_svc,
|
||||
app: Flask,
|
||||
@ -1518,11 +1557,9 @@ class TestDocumentUpdateByTextApiPost:
|
||||
"""Test successful document update by text."""
|
||||
_setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant)
|
||||
mock_dataset.indexing_technique = "economy"
|
||||
mock_db.session.scalar.side_effect = [mock_dataset, None, 0]
|
||||
self._persist_dataset(mock_dataset)
|
||||
|
||||
mock_current_user.id = "user-1"
|
||||
mock_upload = Mock()
|
||||
mock_upload.id = str(uuid.uuid4())
|
||||
mock_upload = _upload_file()
|
||||
mock_file_svc_cls.return_value.upload_text.return_value = mock_upload
|
||||
|
||||
mock_document = make_serializable_document(id="doc-update-text", name="Updated Doc")
|
||||
@ -1539,7 +1576,7 @@ class TestDocumentUpdateByTextApiPost:
|
||||
api = DocumentUpdateByTextApi()
|
||||
response, status = _unwrap_non_wrapped_controller(type(api).post)(
|
||||
api,
|
||||
mock_db.session,
|
||||
self.session,
|
||||
tenant_id=mock_tenant,
|
||||
dataset_id=mock_dataset.id,
|
||||
document_id=doc_id,
|
||||
@ -1550,21 +1587,18 @@ class TestDocumentUpdateByTextApiPost:
|
||||
200,
|
||||
)
|
||||
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
@patch("controllers.service_api.wraps.FeatureService")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_update_by_text_dataset_not_found(
|
||||
self,
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
):
|
||||
"""Test ValueError when dataset not found."""
|
||||
_setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant)
|
||||
mock_db.session.scalar.return_value = None
|
||||
|
||||
doc_id = str(uuid.uuid4())
|
||||
with app.test_request_context(
|
||||
@ -1577,14 +1611,14 @@ class TestDocumentUpdateByTextApiPost:
|
||||
with pytest.raises(ValueError, match="Dataset does not exist"):
|
||||
_unwrap_non_wrapped_controller(type(api).post)(
|
||||
api,
|
||||
mock_db.session,
|
||||
self.session,
|
||||
tenant_id=mock_tenant,
|
||||
dataset_id=mock_dataset.id,
|
||||
document_id=doc_id,
|
||||
)
|
||||
|
||||
|
||||
class TestDocumentAddByFileApiPost:
|
||||
class TestDocumentAddByFileApiPost(SQLiteControllerTest):
|
||||
"""Test suite for DocumentAddByFileApi.post() endpoint.
|
||||
|
||||
``post`` is wrapped by two ``@cloud_edition_billing_resource_check``
|
||||
@ -1593,16 +1627,12 @@ class TestDocumentAddByFileApiPost:
|
||||
|
||||
@patch("controllers.service_api.dataset.document.DocumentService")
|
||||
@patch("controllers.service_api.dataset.document.FileService")
|
||||
@patch("controllers.service_api.dataset.document.current_user")
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
@patch("controllers.service_api.wraps.FeatureService")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_add_by_file_success_serializes_document_and_batch_shape(
|
||||
self,
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_db,
|
||||
mock_current_user,
|
||||
mock_file_svc_cls,
|
||||
mock_doc_svc,
|
||||
app: Flask,
|
||||
@ -1614,11 +1644,9 @@ class TestDocumentAddByFileApiPost:
|
||||
mock_dataset.provider = "vendor"
|
||||
mock_dataset.indexing_technique = "economy"
|
||||
mock_dataset.chunk_structure = None
|
||||
mock_db.session.scalar.side_effect = [mock_dataset, 0]
|
||||
self._persist_dataset(mock_dataset)
|
||||
|
||||
mock_current_user.id = "user-1"
|
||||
mock_upload = Mock()
|
||||
mock_upload.id = str(uuid.uuid4())
|
||||
mock_upload = _upload_file()
|
||||
mock_file_svc_cls.return_value.upload_file.return_value = mock_upload
|
||||
|
||||
mock_document = make_serializable_document(id="doc-create-file", name="File Document")
|
||||
@ -1640,7 +1668,7 @@ class TestDocumentAddByFileApiPost:
|
||||
):
|
||||
api = DocumentAddByFileApi()
|
||||
response, status = _unwrap_non_wrapped_controller(type(api).post)(
|
||||
api, mock_db.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
api, self.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
)
|
||||
|
||||
assert (response, status) == (
|
||||
@ -1653,12 +1681,8 @@ class TestDocumentAddByFileApiPost:
|
||||
return_value=15,
|
||||
)
|
||||
@patch("controllers.service_api.dataset.document.FileService")
|
||||
@patch("controllers.service_api.dataset.document.current_user")
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
def test_add_by_file_too_large_returns_http_413(
|
||||
self,
|
||||
mock_db,
|
||||
mock_current_user,
|
||||
mock_file_svc_cls,
|
||||
mock_get_limit,
|
||||
app: Flask,
|
||||
@ -1668,8 +1692,7 @@ class TestDocumentAddByFileApiPost:
|
||||
mock_dataset.provider = "vendor"
|
||||
mock_dataset.indexing_technique = "economy"
|
||||
mock_dataset.chunk_structure = None
|
||||
mock_db.session.scalar.return_value = mock_dataset
|
||||
mock_current_user.__bool__ = Mock(return_value=True)
|
||||
self._persist_dataset(mock_dataset)
|
||||
mock_file_svc_cls.return_value.upload_file.side_effect = FileTooLargeServiceError()
|
||||
|
||||
from io import BytesIO
|
||||
@ -1687,28 +1710,25 @@ class TestDocumentAddByFileApiPost:
|
||||
api = DocumentAddByFileApi()
|
||||
with pytest.raises(FileTooLargeHTTPError) as exc_info:
|
||||
_unwrap_non_wrapped_controller(type(api).post)(
|
||||
api, mock_db.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
api, self.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
)
|
||||
|
||||
assert exc_info.value.code == 413
|
||||
assert exc_info.value.error_code == "file_too_large"
|
||||
mock_get_limit.assert_called_once_with(mock_tenant)
|
||||
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
@patch("controllers.service_api.wraps.FeatureService")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_add_by_file_dataset_not_found(
|
||||
self,
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
):
|
||||
"""Test ValueError when dataset not found."""
|
||||
_setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant)
|
||||
mock_db.session.scalar.return_value = None
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
@ -1723,17 +1743,15 @@ class TestDocumentAddByFileApiPost:
|
||||
api = DocumentAddByFileApi()
|
||||
with pytest.raises(ValueError, match="Dataset does not exist"):
|
||||
_unwrap_non_wrapped_controller(type(api).post)(
|
||||
api, mock_db.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
api, self.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
)
|
||||
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
@patch("controllers.service_api.wraps.FeatureService")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_add_by_file_external_dataset(
|
||||
self,
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
@ -1741,7 +1759,7 @@ class TestDocumentAddByFileApiPost:
|
||||
"""Test ValueError when dataset is external."""
|
||||
_setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant)
|
||||
mock_dataset.provider = "external"
|
||||
mock_db.session.scalar.return_value = mock_dataset
|
||||
self._persist_dataset(mock_dataset)
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
@ -1756,17 +1774,15 @@ class TestDocumentAddByFileApiPost:
|
||||
api = DocumentAddByFileApi()
|
||||
with pytest.raises(ValueError, match="External datasets"):
|
||||
_unwrap_non_wrapped_controller(type(api).post)(
|
||||
api, mock_db.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
api, self.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
)
|
||||
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
@patch("controllers.service_api.wraps.FeatureService")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_add_by_file_no_file_uploaded(
|
||||
self,
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
@ -1778,7 +1794,7 @@ class TestDocumentAddByFileApiPost:
|
||||
mock_dataset.provider = "vendor"
|
||||
mock_dataset.indexing_technique = "economy"
|
||||
mock_dataset.chunk_structure = None
|
||||
mock_db.session.scalar.return_value = mock_dataset
|
||||
self._persist_dataset(mock_dataset)
|
||||
|
||||
with app.test_request_context(
|
||||
f"/datasets/{mock_dataset.id}/document/create_by_file",
|
||||
@ -1790,17 +1806,15 @@ class TestDocumentAddByFileApiPost:
|
||||
api = DocumentAddByFileApi()
|
||||
with pytest.raises(NoFileUploadedError):
|
||||
_unwrap_non_wrapped_controller(type(api).post)(
|
||||
api, mock_db.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
api, self.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
)
|
||||
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
@patch("controllers.service_api.wraps.FeatureService")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_add_by_file_missing_indexing_technique(
|
||||
self,
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
@ -1810,7 +1824,7 @@ class TestDocumentAddByFileApiPost:
|
||||
mock_dataset.provider = "vendor"
|
||||
mock_dataset.indexing_technique = None
|
||||
mock_dataset.chunk_structure = None
|
||||
mock_db.session.scalar.return_value = mock_dataset
|
||||
self._persist_dataset(mock_dataset)
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
@ -1825,11 +1839,11 @@ class TestDocumentAddByFileApiPost:
|
||||
api = DocumentAddByFileApi()
|
||||
with pytest.raises(ValueError, match="indexing_technique is required"):
|
||||
_unwrap_non_wrapped_controller(type(api).post)(
|
||||
api, mock_db.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
api, self.session, tenant_id=mock_tenant, dataset_id=mock_dataset.id
|
||||
)
|
||||
|
||||
|
||||
class TestDocumentUpdateByFileApiPatch:
|
||||
class TestDocumentUpdateByFileApiPatch(SQLiteControllerTest):
|
||||
"""Test suite for the canonical document file update endpoint.
|
||||
|
||||
``patch`` is wrapped by ``@cloud_edition_billing_resource_check`` and
|
||||
@ -1885,21 +1899,18 @@ class TestDocumentUpdateByFileApiPatch:
|
||||
document_id=doc_id,
|
||||
)
|
||||
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
@patch("controllers.service_api.wraps.FeatureService")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_update_by_file_dataset_not_found(
|
||||
self,
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
):
|
||||
"""Test ValueError when dataset not found."""
|
||||
_setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant)
|
||||
mock_db.session.scalar.return_value = None
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
@ -1916,20 +1927,18 @@ class TestDocumentUpdateByFileApiPatch:
|
||||
with pytest.raises(ValueError, match="Dataset does not exist"):
|
||||
_unwrap_non_wrapped_controller(type(api).patch)(
|
||||
api,
|
||||
mock_db.session,
|
||||
self.session,
|
||||
tenant_id=mock_tenant,
|
||||
dataset_id=mock_dataset.id,
|
||||
document_id=doc_id,
|
||||
)
|
||||
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
@patch("controllers.service_api.wraps.FeatureService")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_update_by_file_external_dataset(
|
||||
self,
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
@ -1937,7 +1946,7 @@ class TestDocumentUpdateByFileApiPatch:
|
||||
"""Test ValueError when dataset is external."""
|
||||
_setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant)
|
||||
mock_dataset.provider = "external"
|
||||
mock_db.session.scalar.return_value = mock_dataset
|
||||
self._persist_dataset(mock_dataset)
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
@ -1954,7 +1963,7 @@ class TestDocumentUpdateByFileApiPatch:
|
||||
with pytest.raises(ValueError, match="External datasets"):
|
||||
_unwrap_non_wrapped_controller(type(api).patch)(
|
||||
api,
|
||||
mock_db.session,
|
||||
self.session,
|
||||
tenant_id=mock_tenant,
|
||||
dataset_id=mock_dataset.id,
|
||||
document_id=doc_id,
|
||||
@ -1962,16 +1971,12 @@ class TestDocumentUpdateByFileApiPatch:
|
||||
|
||||
@patch("controllers.service_api.dataset.document.DocumentService")
|
||||
@patch("controllers.service_api.dataset.document.FileService")
|
||||
@patch("controllers.service_api.dataset.document.current_user")
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
@patch("controllers.service_api.wraps.FeatureService")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_update_by_file_success(
|
||||
self,
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_db,
|
||||
mock_current_user,
|
||||
mock_file_svc_cls,
|
||||
mock_doc_svc,
|
||||
app: Flask,
|
||||
@ -1983,11 +1988,9 @@ class TestDocumentUpdateByFileApiPatch:
|
||||
mock_dataset.indexing_technique = "economy"
|
||||
mock_dataset.provider = "vendor"
|
||||
mock_dataset.chunk_structure = None
|
||||
mock_db.session.scalar.side_effect = [mock_dataset, None, 0]
|
||||
self._persist_dataset(mock_dataset)
|
||||
|
||||
mock_current_user.id = "user-1"
|
||||
mock_upload = Mock()
|
||||
mock_upload.id = str(uuid.uuid4())
|
||||
mock_upload = _upload_file()
|
||||
mock_file_svc_cls.return_value.upload_file.return_value = mock_upload
|
||||
|
||||
mock_document = make_serializable_document(id="doc-update-file", name="File Document", batch="batch-1")
|
||||
@ -2008,7 +2011,7 @@ class TestDocumentUpdateByFileApiPatch:
|
||||
api = DocumentApi()
|
||||
response, status = _unwrap_non_wrapped_controller(type(api).patch)(
|
||||
api,
|
||||
mock_db.session,
|
||||
self.session,
|
||||
tenant_id=mock_tenant,
|
||||
dataset_id=mock_dataset.id,
|
||||
document_id=doc_id,
|
||||
|
||||
@ -17,13 +17,14 @@ Decorator strategy:
|
||||
|
||||
import uuid
|
||||
from inspect import unwrap
|
||||
from unittest.mock import ANY, MagicMock, Mock, patch
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.service_api.dataset import metadata as metadata_module
|
||||
from controllers.service_api.dataset.metadata import (
|
||||
DatasetMetadataBuiltInFieldActionServiceApi,
|
||||
DatasetMetadataBuiltInFieldServiceApi,
|
||||
@ -31,21 +32,43 @@ from controllers.service_api.dataset.metadata import (
|
||||
DatasetMetadataServiceApi,
|
||||
DocumentMetadataEditServiceApi,
|
||||
)
|
||||
from models.account import Account, Tenant
|
||||
from models.dataset import Dataset
|
||||
from models.enums import PermissionEnum
|
||||
from services.errors.metadata import MetadataResourceNotFoundError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tenant():
|
||||
tenant = Mock()
|
||||
def mock_tenant() -> Tenant:
|
||||
tenant = Tenant(name="Metadata API Tenant")
|
||||
tenant.id = str(uuid.uuid4())
|
||||
return tenant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_dataset():
|
||||
dataset = Mock()
|
||||
dataset.id = str(uuid.uuid4())
|
||||
return dataset
|
||||
def account() -> Account:
|
||||
account = Account(name="Metadata API User", email=f"metadata-api-{uuid.uuid4()}@example.com")
|
||||
account.id = str(uuid.uuid4())
|
||||
return account
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_dataset(mock_tenant: Tenant, account: Account) -> Dataset:
|
||||
return Dataset(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=mock_tenant.id,
|
||||
name="Metadata Dataset",
|
||||
description="",
|
||||
provider="vendor",
|
||||
permission=PermissionEnum.ONLY_ME,
|
||||
indexing_technique="economy",
|
||||
created_by=account.id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _use_current_user(monkeypatch: pytest.MonkeyPatch, account: Account) -> None:
|
||||
monkeypatch.setattr(metadata_module, "current_user", account)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -79,10 +102,8 @@ class TestDatasetMetadataCreatePost(_UsesSQLiteSession):
|
||||
|
||||
@patch("controllers.service_api.dataset.metadata.MetadataService")
|
||||
@patch("controllers.service_api.dataset.metadata.DatasetService")
|
||||
@patch("controllers.service_api.dataset.metadata.current_user")
|
||||
def test_create_metadata_success(
|
||||
self,
|
||||
mock_current_user,
|
||||
mock_dataset_svc,
|
||||
mock_meta_svc,
|
||||
app: Flask,
|
||||
@ -213,15 +234,14 @@ class TestDatasetMetadataServiceApiPatch(_UsesSQLiteSession):
|
||||
|
||||
@patch("controllers.service_api.dataset.metadata.MetadataService")
|
||||
@patch("controllers.service_api.dataset.metadata.DatasetService")
|
||||
@patch("controllers.service_api.dataset.metadata.current_user")
|
||||
def test_update_metadata_name_success(
|
||||
self,
|
||||
mock_current_user,
|
||||
mock_dataset_svc,
|
||||
mock_meta_svc,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
account: Account,
|
||||
):
|
||||
"""Test successful metadata name update."""
|
||||
metadata_id = str(uuid.uuid4())
|
||||
@ -250,7 +270,7 @@ class TestDatasetMetadataServiceApiPatch(_UsesSQLiteSession):
|
||||
str(mock_dataset.id), mock_tenant.id, session=session
|
||||
)
|
||||
mock_meta_svc.update_metadata_name.assert_called_once_with(
|
||||
mock_dataset, metadata_id, "New Name", mock_current_user, session=session
|
||||
mock_dataset, metadata_id, "New Name", account, session=session
|
||||
)
|
||||
|
||||
@patch("controllers.service_api.dataset.metadata.DatasetService")
|
||||
@ -294,10 +314,8 @@ class TestDatasetMetadataServiceApiDelete(_UsesSQLiteSession):
|
||||
|
||||
@patch("controllers.service_api.dataset.metadata.MetadataService")
|
||||
@patch("controllers.service_api.dataset.metadata.DatasetService")
|
||||
@patch("controllers.service_api.dataset.metadata.current_user")
|
||||
def test_delete_metadata_success(
|
||||
self,
|
||||
mock_current_user,
|
||||
mock_dataset_svc,
|
||||
mock_meta_svc,
|
||||
app: Flask,
|
||||
@ -410,10 +428,8 @@ class TestDatasetMetadataBuiltInFieldAction(_UsesSQLiteSession):
|
||||
|
||||
@patch("controllers.service_api.dataset.metadata.MetadataService")
|
||||
@patch("controllers.service_api.dataset.metadata.DatasetService")
|
||||
@patch("controllers.service_api.dataset.metadata.current_user")
|
||||
def test_enable_built_in_field(
|
||||
self,
|
||||
mock_current_user,
|
||||
mock_dataset_svc,
|
||||
mock_meta_svc,
|
||||
app: Flask,
|
||||
@ -444,10 +460,8 @@ class TestDatasetMetadataBuiltInFieldAction(_UsesSQLiteSession):
|
||||
|
||||
@patch("controllers.service_api.dataset.metadata.MetadataService")
|
||||
@patch("controllers.service_api.dataset.metadata.DatasetService")
|
||||
@patch("controllers.service_api.dataset.metadata.current_user")
|
||||
def test_disable_built_in_field(
|
||||
self,
|
||||
mock_current_user,
|
||||
mock_dataset_svc,
|
||||
mock_meta_svc,
|
||||
app: Flask,
|
||||
@ -519,15 +533,14 @@ class TestDocumentMetadataEditPost(_UsesSQLiteSession):
|
||||
|
||||
@patch("controllers.service_api.dataset.metadata.MetadataService")
|
||||
@patch("controllers.service_api.dataset.metadata.DatasetService")
|
||||
@patch("controllers.service_api.dataset.metadata.current_user")
|
||||
def test_update_documents_metadata_success(
|
||||
self,
|
||||
mock_current_user,
|
||||
mock_dataset_svc,
|
||||
mock_meta_svc,
|
||||
app: Flask,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
account: Account,
|
||||
):
|
||||
"""Test successful documents metadata update."""
|
||||
mock_dataset_svc.get_dataset_for_tenant.return_value = mock_dataset
|
||||
@ -553,7 +566,7 @@ class TestDocumentMetadataEditPost(_UsesSQLiteSession):
|
||||
mock_meta_svc.update_documents_metadata.assert_called_once_with(
|
||||
mock_dataset,
|
||||
ANY,
|
||||
mock_current_user,
|
||||
account,
|
||||
session=session,
|
||||
)
|
||||
|
||||
@ -585,10 +598,8 @@ class TestDocumentMetadataEditPost(_UsesSQLiteSession):
|
||||
|
||||
@patch("controllers.service_api.dataset.metadata.MetadataService")
|
||||
@patch("controllers.service_api.dataset.metadata.DatasetService")
|
||||
@patch("controllers.service_api.dataset.metadata.current_user")
|
||||
def test_update_documents_metadata_translates_missing_resource(
|
||||
self,
|
||||
mock_current_user,
|
||||
mock_dataset_svc,
|
||||
mock_meta_svc,
|
||||
app: Flask,
|
||||
@ -607,7 +618,7 @@ class TestDocumentMetadataEditPost(_UsesSQLiteSession):
|
||||
with pytest.raises(NotFound) as exc_info:
|
||||
self._call_post(
|
||||
api,
|
||||
MagicMock(),
|
||||
self.session,
|
||||
tenant_id=mock_tenant.id,
|
||||
dataset_id=mock_dataset.id,
|
||||
)
|
||||
|
||||
@ -4,12 +4,12 @@ Unit tests for Service API wraps (authentication decorators)
|
||||
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, scoped_session
|
||||
from werkzeug.exceptions import Forbidden, NotFound, ServiceUnavailable, Unauthorized
|
||||
|
||||
from controllers.service_api.wraps import (
|
||||
@ -36,11 +36,9 @@ def _configure_current_app_mock(mock_current_app):
|
||||
mock_current_app._get_current_object = Mock(return_value=Mock())
|
||||
|
||||
|
||||
def _session_proxy(session: Session) -> MagicMock:
|
||||
"""Emulate Flask-SQLAlchemy's callable scoped-session proxy around a test session."""
|
||||
proxy = MagicMock(wraps=session)
|
||||
proxy.return_value = session
|
||||
return proxy
|
||||
def _session_proxy(session: Session) -> scoped_session[Session]:
|
||||
"""Expose the real SQLite session through Flask-SQLAlchemy's callable shape."""
|
||||
return scoped_session(lambda: session)
|
||||
|
||||
|
||||
def _api_token(*, tenant_id: str, app_id: str | None = None, token_type: ApiTokenType) -> ApiToken:
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@ -71,3 +72,11 @@ class TestTriggerEndpoint:
|
||||
|
||||
assert status == 500
|
||||
assert response["error"] == "Internal server error"
|
||||
|
||||
@patch.object(module.TriggerService, "process_endpoint", side_effect=Exception("boom"))
|
||||
def test_unexpected_exception_logs_endpoint_id(self, mock_trigger, caplog):
|
||||
with caplog.at_level(logging.ERROR, logger=module.logger.name):
|
||||
module.trigger_endpoint(VALID_UUID)
|
||||
|
||||
assert VALID_UUID in caplog.text
|
||||
assert "{endpoint_id}" not in caplog.text
|
||||
|
||||
@ -18,6 +18,8 @@ from dify_agent.protocol import (
|
||||
CancelRunRequest,
|
||||
CancelRunResponse,
|
||||
PydanticAIStreamRunEvent,
|
||||
RunCancelledEvent,
|
||||
RunCancelledEventData,
|
||||
RunEvent,
|
||||
RunFailedEvent,
|
||||
RunFailedEventData,
|
||||
@ -39,6 +41,7 @@ from sqlalchemy import event, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendError,
|
||||
AgentBackendRunEventAdapter,
|
||||
AgentBackendRunFailedError,
|
||||
AgentBackendRunFailedInternalEvent,
|
||||
@ -67,9 +70,13 @@ from models.model import MessageAgentThought
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def bind_agent_db(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
|
||||
"""Bind the runner's ORM writes to the shared SQLite session."""
|
||||
def bind_agent_dependencies(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
|
||||
"""Bind local runner dependencies without reaching external services."""
|
||||
monkeypatch.setattr(app_runner_module.db, "session", sqlite_session)
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.resolve_model_context_window",
|
||||
lambda **_kwargs: None,
|
||||
)
|
||||
|
||||
|
||||
def _thought_rows(session: Session) -> list[MessageAgentThought]:
|
||||
@ -108,12 +115,38 @@ class _RecordingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.cancelled_run_ids: list[str] = []
|
||||
self.cancel_after: list[str | None] = []
|
||||
|
||||
@override
|
||||
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
|
||||
self.cancelled_run_ids.append(run_id)
|
||||
return super().cancel_run(run_id, request=request)
|
||||
|
||||
@override
|
||||
def cancel_run_and_wait(
|
||||
self,
|
||||
run_id: str,
|
||||
request: CancelRunRequest | None = None,
|
||||
*,
|
||||
after: str | None = None,
|
||||
) -> RunCancelledEvent:
|
||||
self.cancel_after.append(after)
|
||||
return super().cancel_run_and_wait(run_id, request=request, after=after)
|
||||
|
||||
|
||||
class _CancelAndWaitFailingClient(_RecordingFakeAgentBackendRunClient):
|
||||
@override
|
||||
def cancel_run_and_wait(
|
||||
self,
|
||||
run_id: str,
|
||||
request: CancelRunRequest | None = None,
|
||||
*,
|
||||
after: str | None = None,
|
||||
) -> RunCancelledEvent:
|
||||
del request
|
||||
self.cancel_after.append(after)
|
||||
raise RuntimeError(f"failed to finish cancelling {run_id}")
|
||||
|
||||
|
||||
class _RunLimitBindingLostFakeAgentBackendRunClient(FakeAgentBackendRunClient):
|
||||
@override
|
||||
@ -139,6 +172,38 @@ class _RunLimitBindingLostFakeAgentBackendRunClient(FakeAgentBackendRunClient):
|
||||
)
|
||||
|
||||
|
||||
class _TerminalWithoutSnapshotFakeAgentBackendRunClient(FakeAgentBackendRunClient):
|
||||
def __init__(self, *, terminal_type: str) -> None:
|
||||
super().__init__()
|
||||
self.terminal_type = terminal_type
|
||||
|
||||
@override
|
||||
def stream_events(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
after: str | None = None,
|
||||
should_stop: Callable[[], bool] | None = None,
|
||||
) -> Iterator[RunEvent]:
|
||||
del after, should_stop
|
||||
created_at = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at)
|
||||
if self.terminal_type == "failed":
|
||||
yield RunFailedEvent(
|
||||
id="2-0",
|
||||
run_id=run_id,
|
||||
created_at=created_at,
|
||||
data=RunFailedEventData(error="failed without snapshot"),
|
||||
)
|
||||
else:
|
||||
yield RunCancelledEvent(
|
||||
id="2-0",
|
||||
run_id=run_id,
|
||||
created_at=created_at,
|
||||
data=RunCancelledEventData(reason="cancelled without snapshot"),
|
||||
)
|
||||
|
||||
|
||||
class _StreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
|
||||
@override
|
||||
def stream_events(
|
||||
@ -439,6 +504,26 @@ class _FakeSessionStore:
|
||||
self.saved.append((scope, binding_id, snapshot, pending_form_id, pending_tool_call_id))
|
||||
|
||||
|
||||
class _ExplodingSessionStore(_FakeSessionStore):
|
||||
def __init__(self, loaded: CompositorSessionSnapshot | None = None) -> None:
|
||||
super().__init__(loaded=loaded)
|
||||
self.save_attempts: list[CompositorSessionSnapshot | None] = []
|
||||
|
||||
@override
|
||||
def save_active_snapshot(
|
||||
self,
|
||||
*,
|
||||
scope: AgentAppSessionScope,
|
||||
binding_id: str,
|
||||
snapshot: CompositorSessionSnapshot | None,
|
||||
pending_form_id: str | None = None,
|
||||
pending_tool_call_id: str | None = None,
|
||||
) -> None:
|
||||
del scope, binding_id, pending_form_id, pending_tool_call_id
|
||||
self.save_attempts.append(snapshot)
|
||||
raise RuntimeError("session save failed")
|
||||
|
||||
|
||||
class _MonotonicClock:
|
||||
def __init__(self, *values: float) -> None:
|
||||
self._values = list(values)
|
||||
@ -768,6 +853,7 @@ def test_streaming_turn_cancels_after_persisting_seen_agent_answer(
|
||||
assert len(rows) == 1
|
||||
assert rows[0].answer == "hello "
|
||||
assert client.cancelled_run_ids == ["fake-run-1"]
|
||||
assert client.cancel_after == ["3-0"]
|
||||
|
||||
|
||||
def test_tool_result_without_identity_does_not_attach_to_previous_tool(
|
||||
@ -1158,8 +1244,40 @@ def test_failed_run_raises_agent_backend_error() -> None:
|
||||
|
||||
with pytest.raises(AgentBackendRunFailedError, match="fake failure .*agent_run_id=fake-run-1"):
|
||||
_run(_runner(client, store), qm)
|
||||
# No message-end on failure; no snapshot saved.
|
||||
# No message-end on failure; post-exit session state is still saved.
|
||||
assert not [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
|
||||
assert store.saved[0][2] == CompositorSessionSnapshot(layers=[])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("outcome", ["failed", "stopped"])
|
||||
def test_snapshot_save_failure_preserves_original_app_outcome(outcome: str) -> None:
|
||||
store = _ExplodingSessionStore()
|
||||
queue_manager: _FakeQueueManager = _FakeQueueManager() if outcome == "failed" else _StoppedQueueManager()
|
||||
client = FakeAgentBackendRunClient(
|
||||
scenario=FakeAgentBackendScenario.FAILED if outcome == "failed" else FakeAgentBackendScenario.SUCCESS
|
||||
)
|
||||
expected_error = AgentBackendRunFailedError if outcome == "failed" else GenerateTaskStoppedError
|
||||
|
||||
with pytest.raises(expected_error, match="fake failure" if outcome == "failed" else None):
|
||||
_run(_runner(client, store), queue_manager)
|
||||
|
||||
assert store.save_attempts == [CompositorSessionSnapshot(layers=[])]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("terminal_type", "expected_error"),
|
||||
[("failed", AgentBackendRunFailedError), ("cancelled", AgentBackendError)],
|
||||
)
|
||||
def test_terminal_without_snapshot_preserves_prior_app_session_without_write(
|
||||
terminal_type: str,
|
||||
expected_error: type[Exception],
|
||||
) -> None:
|
||||
store = _FakeSessionStore()
|
||||
client = _TerminalWithoutSnapshotFakeAgentBackendRunClient(terminal_type=terminal_type)
|
||||
|
||||
with pytest.raises(expected_error):
|
||||
_run(_runner(client, store), _FakeQueueManager())
|
||||
|
||||
assert store.saved == []
|
||||
|
||||
|
||||
@ -1229,7 +1347,7 @@ def test_agent_backend_failure_to_exception_prefers_run_failure_type_over_known_
|
||||
}
|
||||
|
||||
|
||||
def test_stopped_task_cancels_agent_backend_run_and_skips_session_save() -> None:
|
||||
def test_stopped_task_waits_for_cancelled_snapshot_and_saves_session() -> None:
|
||||
client = _RecordingFakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore()
|
||||
qm = _StoppedQueueManager()
|
||||
@ -1238,6 +1356,18 @@ def test_stopped_task_cancels_agent_backend_run_and_skips_session_save() -> None
|
||||
_run(_runner(client, store), qm)
|
||||
|
||||
assert client.cancelled_run_ids == ["fake-run-1"]
|
||||
assert len(store.saved) == 1
|
||||
assert store.saved[0][2] == CompositorSessionSnapshot(layers=[])
|
||||
|
||||
|
||||
def test_cancel_and_wait_failure_preserves_stopped_app_outcome() -> None:
|
||||
client = _CancelAndWaitFailingClient()
|
||||
store = _FakeSessionStore()
|
||||
|
||||
with pytest.raises(GenerateTaskStoppedError):
|
||||
_run(_runner(client, store), _StoppedQueueManager())
|
||||
|
||||
assert client.cancel_after == [None]
|
||||
assert store.saved == []
|
||||
|
||||
|
||||
|
||||
@ -28,6 +28,21 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def model_context_window_calls(monkeypatch: pytest.MonkeyPatch) -> list[tuple[object, str, str]]:
|
||||
calls: list[tuple[object, str, str]] = []
|
||||
|
||||
def resolve(*, run_context: object, provider_name: str, model_name: str) -> int:
|
||||
calls.append((run_context, provider_name, model_name))
|
||||
return 32_768
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.resolve_model_context_window",
|
||||
resolve,
|
||||
)
|
||||
return calls
|
||||
|
||||
|
||||
def _exec_ctx() -> DifyExecutionContextLayerConfig:
|
||||
return DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
@ -176,11 +191,12 @@ def _soul_with_model() -> AgentSoulConfig:
|
||||
|
||||
|
||||
class TestAgentAppRuntimeRequestBuilder:
|
||||
def test_build_maps_soul_to_run_request(self):
|
||||
def test_build_maps_soul_to_run_request(self, model_context_window_calls: list[tuple[object, str, str]]):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
result = builder.build(_ctx(_soul_with_model()))
|
||||
context = _ctx(_soul_with_model())
|
||||
result = builder.build(context)
|
||||
|
||||
req = result.request
|
||||
names = [layer.name for layer in req.composition.layers]
|
||||
@ -198,6 +214,8 @@ class TestAgentAppRuntimeRequestBuilder:
|
||||
llm = next(layer for layer in req.composition.layers if layer.name == "llm")
|
||||
assert llm.config.plugin_id == "langgenius/openai"
|
||||
assert llm.config.model_provider == "openai"
|
||||
assert llm.config.context_window_tokens == 32_768
|
||||
assert model_context_window_calls == [(context.dify_context, "langgenius/openai/openai", "gpt-4o-mini")]
|
||||
# execution context carries conversation + agent_app invoke source.
|
||||
exec_ctx = next(layer for layer in req.composition.layers if layer.name == "execution_context")
|
||||
assert exec_ctx.config.conversation_id == "conv-1"
|
||||
|
||||
@ -1,16 +1,31 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
import core.app.apps.pipeline.pipeline_config_manager as module
|
||||
from core.app.apps.pipeline.pipeline_config_manager import PipelineConfigManager
|
||||
from models.dataset import Pipeline
|
||||
from models.model import AppMode
|
||||
from models.workflow import Workflow, WorkflowType
|
||||
|
||||
|
||||
def test_get_pipeline_config(mocker: MockerFixture):
|
||||
pipeline = MagicMock(tenant_id="tenant", id="pipe1")
|
||||
workflow = MagicMock(id="wf1")
|
||||
pipeline = Pipeline(tenant_id="tenant", name="Pipeline", description="")
|
||||
pipeline.id = "pipe1"
|
||||
workflow = Workflow.new(
|
||||
tenant_id="tenant",
|
||||
app_id="pipe1",
|
||||
type=WorkflowType.RAG_PIPELINE.value,
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
graph=json.dumps({"nodes": [], "edges": []}),
|
||||
features="{}",
|
||||
created_by="user",
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
rag_pipeline_variables=[],
|
||||
)
|
||||
workflow.id = "wf1"
|
||||
|
||||
mocker.patch.object(
|
||||
module.WorkflowVariablesConfigManager,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import contextlib
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, PropertyMock
|
||||
|
||||
@ -6,13 +7,13 @@ import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import core.app.apps.pipeline.pipeline_generator as module
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.datasource.entities.datasource_entities import DatasourceProviderType
|
||||
from models.dataset import Document, DocumentPipelineExecutionLog
|
||||
from models.dataset import Dataset, Document, DocumentPipelineExecutionLog, Pipeline
|
||||
from models.enums import DataSourceType, EndUserType
|
||||
from models.model import EndUser
|
||||
from models.workflow import Workflow, WorkflowType
|
||||
@ -49,28 +50,51 @@ def generator(mocker: MockerFixture, sqlite_engine: Engine):
|
||||
|
||||
|
||||
def _build_pipeline_dataset():
|
||||
return SimpleNamespace(
|
||||
return Dataset(
|
||||
id=DATASET_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
name="dataset",
|
||||
description="desc",
|
||||
created_by=USER_ID,
|
||||
pipeline_id=PIPELINE_ID,
|
||||
chunk_structure="text_model",
|
||||
built_in_field_enabled=True,
|
||||
tenant_id=TENANT_ID,
|
||||
)
|
||||
|
||||
|
||||
def _build_pipeline():
|
||||
pipeline = MagicMock(tenant_id=TENANT_ID, id=PIPELINE_ID)
|
||||
pipeline.retrieve_dataset.return_value = _build_pipeline_dataset()
|
||||
pipeline = Pipeline(tenant_id=TENANT_ID, name="Pipeline", description="desc")
|
||||
pipeline.id = PIPELINE_ID
|
||||
pipeline.workflow_id = WORKFLOW_ID
|
||||
return pipeline
|
||||
|
||||
|
||||
def _build_workflow():
|
||||
return MagicMock(id=WORKFLOW_ID, graph_dict={"nodes": [], "edges": []}, tenant_id=TENANT_ID)
|
||||
def _build_workflow(*, graph: dict | None = None):
|
||||
workflow = Workflow.new(
|
||||
tenant_id=TENANT_ID,
|
||||
app_id=PIPELINE_ID,
|
||||
type=WorkflowType.RAG_PIPELINE.value,
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
graph=json.dumps(graph if graph is not None else {"nodes": [], "edges": []}),
|
||||
features="{}",
|
||||
created_by=USER_ID,
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
rag_pipeline_variables=[],
|
||||
)
|
||||
workflow.id = WORKFLOW_ID
|
||||
return workflow
|
||||
|
||||
|
||||
def _build_user():
|
||||
return SimpleNamespace(id=USER_ID, name="User", session_id="session")
|
||||
return EndUser(
|
||||
id=USER_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
app_id=PIPELINE_ID,
|
||||
type=EndUserType.BROWSER,
|
||||
name="User",
|
||||
session_id="session",
|
||||
)
|
||||
|
||||
|
||||
def _build_args():
|
||||
@ -86,10 +110,19 @@ def _patch_sqlite_engine(mocker: MockerFixture, sqlite_engine: Engine) -> None:
|
||||
mocker.patch.object(type(module.db), "engine", new_callable=PropertyMock, return_value=sqlite_engine)
|
||||
|
||||
|
||||
def _patch_db_session(mocker: MockerFixture, sqlite_session_factory: sessionmaker[Session]) -> scoped_session[Session]:
|
||||
session_proxy = scoped_session(sqlite_session_factory)
|
||||
mocker.patch.object(module.db, "session", session_proxy)
|
||||
return session_proxy
|
||||
def _persist_pipeline_scope(
|
||||
session: Session,
|
||||
*,
|
||||
pipeline: Pipeline | None = None,
|
||||
dataset: Dataset | None = None,
|
||||
workflow: Workflow | None = None,
|
||||
) -> tuple[Pipeline, Dataset, Workflow]:
|
||||
pipeline = pipeline or _build_pipeline()
|
||||
dataset = dataset or _build_pipeline_dataset()
|
||||
workflow = workflow or _build_workflow()
|
||||
session.add_all([pipeline, dataset, workflow])
|
||||
session.commit()
|
||||
return pipeline, dataset, workflow
|
||||
|
||||
|
||||
def _persist_worker_records(session: Session) -> None:
|
||||
@ -122,7 +155,6 @@ def _dummy_preserve(*args, **kwargs):
|
||||
|
||||
def test_generate_dataset_missing(generator, sqlite_session: Session):
|
||||
pipeline = _build_pipeline()
|
||||
pipeline.retrieve_dataset.return_value = None
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
generator.generate(
|
||||
@ -137,8 +169,7 @@ def test_generate_dataset_missing(generator, sqlite_session: Session):
|
||||
|
||||
|
||||
def test_generate_debugger_calls_generate(generator, mocker: MockerFixture, sqlite_session: Session):
|
||||
pipeline = _build_pipeline()
|
||||
workflow = _build_workflow()
|
||||
pipeline, _, workflow = _persist_pipeline_scope(sqlite_session)
|
||||
|
||||
mocker.patch.object(
|
||||
generator,
|
||||
@ -181,8 +212,7 @@ def test_generate_debugger_calls_generate(generator, mocker: MockerFixture, sqli
|
||||
def test_generate_published_pipeline_creates_documents_and_delay(
|
||||
generator, mocker: MockerFixture, sqlite_session: Session
|
||||
):
|
||||
pipeline = _build_pipeline()
|
||||
workflow = _build_workflow()
|
||||
pipeline, _, workflow = _persist_pipeline_scope(sqlite_session)
|
||||
|
||||
datasource_info_list = [{"name": "file1"}, {"name": "file2"}]
|
||||
|
||||
@ -245,8 +275,7 @@ def test_generate_published_pipeline_creates_documents_and_delay(
|
||||
def test_generate_published_pipeline_rejects_when_document_creation_limits_exceeded(
|
||||
generator, mocker: MockerFixture, sqlite_session: Session
|
||||
):
|
||||
pipeline = _build_pipeline()
|
||||
workflow = _build_workflow()
|
||||
pipeline, _, workflow = _persist_pipeline_scope(sqlite_session)
|
||||
|
||||
datasource_info_list = [{"name": "file1"}, {"name": "file2"}]
|
||||
mocker.patch.object(
|
||||
@ -283,8 +312,7 @@ def test_generate_published_pipeline_rejects_when_document_creation_limits_excee
|
||||
|
||||
|
||||
def test_generate_is_retry_calls_generate(generator, mocker: MockerFixture, sqlite_session: Session):
|
||||
pipeline = _build_pipeline()
|
||||
workflow = _build_workflow()
|
||||
pipeline, _, workflow = _persist_pipeline_scope(sqlite_session)
|
||||
|
||||
mocker.patch.object(
|
||||
generator,
|
||||
@ -336,14 +364,12 @@ def test_generate_worker_handles_errors(
|
||||
mocker: MockerFixture,
|
||||
sqlite_session: Session,
|
||||
sqlite_engine: Engine,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
):
|
||||
flask_app = MagicMock()
|
||||
flask_app.app_context.return_value = contextlib.nullcontext()
|
||||
mocker.patch.object(module, "preserve_flask_contexts", _dummy_preserve)
|
||||
_persist_worker_records(sqlite_session)
|
||||
_patch_sqlite_engine(mocker, sqlite_engine)
|
||||
_patch_db_session(mocker, sqlite_session_factory)
|
||||
|
||||
application_generate_entity = FakeRagPipelineGenerateEntity(
|
||||
app_config=SimpleNamespace(tenant_id=TENANT_ID, app_id=PIPELINE_ID, workflow_id=WORKFLOW_ID),
|
||||
@ -374,14 +400,12 @@ def test_generate_worker_sets_system_user_id_for_external_call(
|
||||
mocker: MockerFixture,
|
||||
sqlite_session: Session,
|
||||
sqlite_engine: Engine,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
):
|
||||
flask_app = MagicMock()
|
||||
flask_app.app_context.return_value = contextlib.nullcontext()
|
||||
mocker.patch.object(module, "preserve_flask_contexts", _dummy_preserve)
|
||||
_persist_worker_records(sqlite_session)
|
||||
_patch_sqlite_engine(mocker, sqlite_engine)
|
||||
_patch_db_session(mocker, sqlite_session_factory)
|
||||
|
||||
application_generate_entity = FakeRagPipelineGenerateEntity(
|
||||
app_config=SimpleNamespace(tenant_id=TENANT_ID, app_id=PIPELINE_ID, workflow_id=WORKFLOW_ID),
|
||||
@ -503,7 +527,6 @@ def test_single_iteration_generate_validates_inputs(generator, sqlite_session: S
|
||||
|
||||
def test_single_iteration_generate_dataset_required(generator, sqlite_session: Session):
|
||||
pipeline = _build_pipeline()
|
||||
pipeline.retrieve_dataset.return_value = None
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
generator.single_iteration_generate(
|
||||
@ -520,9 +543,8 @@ def test_single_iteration_generate_success(
|
||||
generator,
|
||||
mocker: MockerFixture,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
):
|
||||
pipeline = _build_pipeline()
|
||||
pipeline, _, workflow = _persist_pipeline_scope(sqlite_session)
|
||||
|
||||
mocker.patch.object(
|
||||
module.PipelineConfigManager,
|
||||
@ -539,8 +561,6 @@ def test_single_iteration_generate_success(
|
||||
"create_workflow_node_execution_repository",
|
||||
return_value=MagicMock(),
|
||||
)
|
||||
_patch_db_session(mocker, sqlite_session_factory)
|
||||
|
||||
mocker.patch.object(module, "WorkflowDraftVariableService", return_value=MagicMock())
|
||||
mocker.patch.object(module, "DraftVarLoader", return_value=MagicMock())
|
||||
|
||||
@ -548,7 +568,7 @@ def test_single_iteration_generate_success(
|
||||
|
||||
result = generator.single_iteration_generate(
|
||||
pipeline,
|
||||
_build_workflow(),
|
||||
workflow,
|
||||
"node",
|
||||
_build_user(),
|
||||
{"inputs": {"a": 1}},
|
||||
@ -563,9 +583,8 @@ def test_single_loop_generate_success(
|
||||
generator,
|
||||
mocker: MockerFixture,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
):
|
||||
pipeline = _build_pipeline()
|
||||
pipeline, _, workflow = _persist_pipeline_scope(sqlite_session)
|
||||
|
||||
mocker.patch.object(
|
||||
module.PipelineConfigManager,
|
||||
@ -582,8 +601,6 @@ def test_single_loop_generate_success(
|
||||
"create_workflow_node_execution_repository",
|
||||
return_value=MagicMock(),
|
||||
)
|
||||
_patch_db_session(mocker, sqlite_session_factory)
|
||||
|
||||
mocker.patch.object(module, "WorkflowDraftVariableService", return_value=MagicMock())
|
||||
mocker.patch.object(module, "DraftVarLoader", return_value=MagicMock())
|
||||
|
||||
@ -591,7 +608,7 @@ def test_single_loop_generate_success(
|
||||
|
||||
result = generator.single_loop_generate(
|
||||
pipeline,
|
||||
_build_workflow(),
|
||||
workflow,
|
||||
"node",
|
||||
_build_user(),
|
||||
{"inputs": {"a": 1}},
|
||||
@ -622,12 +639,7 @@ def test_handle_response_value_error_triggers_generate_task_stopped(generator, m
|
||||
)
|
||||
|
||||
|
||||
def test_build_document_sets_metadata_for_builtin_fields(generator, mocker: MockerFixture):
|
||||
class DummyDocument(SimpleNamespace):
|
||||
pass
|
||||
|
||||
mocker.patch.object(module, "Document", side_effect=lambda **kwargs: DummyDocument(**kwargs))
|
||||
|
||||
def test_build_document_sets_metadata_for_builtin_fields(generator):
|
||||
document = generator._build_document(
|
||||
tenant_id="tenant",
|
||||
dataset_id="ds",
|
||||
@ -693,7 +705,7 @@ def test_format_datasource_info_list_non_online_drive(generator):
|
||||
|
||||
|
||||
def test_format_datasource_info_list_missing_node_data(generator):
|
||||
workflow = MagicMock(graph_dict={"nodes": []})
|
||||
workflow = _build_workflow()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
generator._format_datasource_info_list(
|
||||
@ -707,8 +719,8 @@ def test_format_datasource_info_list_missing_node_data(generator):
|
||||
|
||||
|
||||
def test_format_datasource_info_list_online_drive_folder(generator, mocker: MockerFixture):
|
||||
workflow = MagicMock(
|
||||
graph_dict={
|
||||
workflow = _build_workflow(
|
||||
graph={
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
@ -720,7 +732,7 @@ def test_format_datasource_info_list_online_drive_folder(generator, mocker: Mock
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
runtime = MagicMock()
|
||||
|
||||
@ -1,10 +1,3 @@
|
||||
"""
|
||||
Unit tests for PipelineRunner behavior.
|
||||
Asserts correct event handling, error propagation, and user invocation logic.
|
||||
Primary collaborators: PipelineRunner, InvokeFrom, GraphRunFailedEvent, UserFrom, and mocked dependencies.
|
||||
Cross-references: core.app.apps.pipeline.pipeline_runner, core.app.entities.app_invoke_entities.
|
||||
"""
|
||||
|
||||
"""Unit tests for PipelineRunner behavior.
|
||||
|
||||
This module validates core control-flow outcomes for
|
||||
@ -18,16 +11,100 @@ Primary collaborators include ``PipelineRunner``,
|
||||
``UserFrom``, and patched DB/runtime dependencies used by the runner.
|
||||
"""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import core.app.apps.pipeline.pipeline_runner as module
|
||||
from core.app.apps.pipeline.pipeline_runner import PipelineRunner
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from graphon.graph_events import GraphRunFailedEvent
|
||||
from models.dataset import Dataset, Document, Pipeline
|
||||
from models.enums import DataSourceType, DocumentCreatedFrom, EndUserType
|
||||
from models.model import EndUser
|
||||
from models.workflow import Workflow, WorkflowType
|
||||
|
||||
|
||||
def _pipeline(*, tenant_id: str = "tenant", pipeline_id: str = "pipe") -> Pipeline:
|
||||
pipeline = Pipeline(tenant_id=tenant_id, name="Pipeline", description="")
|
||||
pipeline.id = pipeline_id
|
||||
pipeline.workflow_id = "wf"
|
||||
return pipeline
|
||||
|
||||
|
||||
def _dataset(*, tenant_id: str = "tenant", dataset_id: str = "ds", pipeline_id: str = "pipe") -> Dataset:
|
||||
return Dataset(
|
||||
id=dataset_id,
|
||||
tenant_id=tenant_id,
|
||||
name="Dataset",
|
||||
description="",
|
||||
created_by="user",
|
||||
pipeline_id=pipeline_id,
|
||||
)
|
||||
|
||||
|
||||
def _workflow(*, tenant_id: str = "tenant", pipeline_id: str = "pipe", graph: dict | None = None) -> Workflow:
|
||||
return Workflow.new(
|
||||
tenant_id=tenant_id,
|
||||
app_id=pipeline_id,
|
||||
type=WorkflowType.RAG_PIPELINE.value,
|
||||
version="v1",
|
||||
graph=json.dumps(graph if graph is not None else {"nodes": [], "edges": []}),
|
||||
features="{}",
|
||||
created_by="user",
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
rag_pipeline_variables=[],
|
||||
)
|
||||
|
||||
|
||||
def _end_user() -> EndUser:
|
||||
return EndUser(
|
||||
id="user",
|
||||
tenant_id="tenant",
|
||||
app_id="pipe",
|
||||
type=EndUserType.BROWSER,
|
||||
name="User",
|
||||
session_id="sess",
|
||||
)
|
||||
|
||||
|
||||
def _document(*, document_id: str = "doc", dataset_id: str = "ds", tenant_id: str = "tenant") -> Document:
|
||||
return Document(
|
||||
id=document_id,
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset_id,
|
||||
position=1,
|
||||
data_source_type=DataSourceType.UPLOAD_FILE,
|
||||
batch="batch",
|
||||
name="Document",
|
||||
created_from=DocumentCreatedFrom.API,
|
||||
created_by="user",
|
||||
)
|
||||
|
||||
|
||||
def _persist_scope(
|
||||
session: Session,
|
||||
*,
|
||||
pipeline: Pipeline | None = None,
|
||||
dataset: Dataset | None = None,
|
||||
workflow: Workflow | None = None,
|
||||
end_user: EndUser | None = None,
|
||||
documents: tuple[Document, ...] = (),
|
||||
) -> tuple[Pipeline, Dataset, Workflow]:
|
||||
pipeline = pipeline or _pipeline()
|
||||
dataset = dataset or _dataset(tenant_id=pipeline.tenant_id, pipeline_id=pipeline.id)
|
||||
workflow = workflow or _workflow(tenant_id=pipeline.tenant_id, pipeline_id=pipeline.id)
|
||||
workflow.id = "wf"
|
||||
session.add_all([pipeline, dataset, workflow, *(documents or ()), *([end_user] if end_user else [])])
|
||||
session.commit()
|
||||
return pipeline, dataset, workflow
|
||||
|
||||
|
||||
def _build_app_generate_entity() -> SimpleNamespace:
|
||||
@ -53,38 +130,12 @@ def _build_app_generate_entity() -> SimpleNamespace:
|
||||
)
|
||||
|
||||
|
||||
def _patch_create_session(mocker: MockerFixture, session: MagicMock, *, events: list[str] | None = None):
|
||||
"""Patch create_session() to yield ``session`` inside its ``with`` body and ``begin()`` block.
|
||||
|
||||
The runner now obtains short-lived sessions via ``create_session()`` instead of the
|
||||
Flask scoped ``db.session``, so tests patch the module-level ``create_session`` and
|
||||
hand back a context manager that yields the mock session.
|
||||
"""
|
||||
session_context = MagicMock()
|
||||
|
||||
def enter_session():
|
||||
if events is not None:
|
||||
events.append("session_enter")
|
||||
return session
|
||||
|
||||
def exit_session(*args):
|
||||
if events is not None:
|
||||
events.append("session_exit")
|
||||
return False
|
||||
|
||||
session_context.__enter__.side_effect = enter_session
|
||||
session_context.__exit__.side_effect = exit_session
|
||||
session.begin.return_value.__enter__.return_value = session
|
||||
session.begin.return_value.__exit__.return_value = False
|
||||
return mocker.patch.object(module, "create_session", return_value=session_context)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner():
|
||||
app_generate_entity = _build_app_generate_entity()
|
||||
queue_manager = MagicMock()
|
||||
variable_loader = MagicMock()
|
||||
workflow = MagicMock()
|
||||
workflow = _workflow()
|
||||
workflow_execution_repository = MagicMock()
|
||||
workflow_node_execution_repository = MagicMock()
|
||||
|
||||
@ -103,105 +154,91 @@ def test_get_app_id(runner):
|
||||
assert runner._get_app_id() == "pipe"
|
||||
|
||||
|
||||
def test_get_workflow_returns_workflow(runner):
|
||||
pipeline = MagicMock(tenant_id="tenant", id="pipe")
|
||||
workflow = MagicMock(id="wf")
|
||||
def test_get_workflow_returns_workflow(runner, sqlite_session: Session):
|
||||
pipeline, _, workflow = _persist_scope(sqlite_session)
|
||||
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = workflow
|
||||
|
||||
result = runner.get_workflow(session=session, pipeline=pipeline, workflow_id="wf")
|
||||
result = runner.get_workflow(session=sqlite_session, pipeline=pipeline, workflow_id="wf")
|
||||
|
||||
assert result == workflow
|
||||
|
||||
|
||||
def test_init_rag_pipeline_graph_invalid_config(mocker, runner):
|
||||
workflow = MagicMock(id="wf", tenant_id="tenant", graph_dict={})
|
||||
workflow = _workflow(graph={})
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
runner._init_rag_pipeline_graph(workflow=workflow, graph_runtime_state=MagicMock())
|
||||
|
||||
workflow.graph_dict = {"nodes": "bad", "edges": []}
|
||||
workflow.graph = json.dumps({"nodes": "bad", "edges": []})
|
||||
with pytest.raises(ValueError):
|
||||
runner._init_rag_pipeline_graph(workflow=workflow, graph_runtime_state=MagicMock())
|
||||
|
||||
workflow.graph_dict = {"nodes": [], "edges": "bad"}
|
||||
workflow.graph = json.dumps({"nodes": [], "edges": "bad"})
|
||||
with pytest.raises(ValueError):
|
||||
runner._init_rag_pipeline_graph(workflow=workflow, graph_runtime_state=MagicMock())
|
||||
|
||||
|
||||
def test_init_rag_pipeline_graph_not_found(mocker, runner):
|
||||
workflow = MagicMock(id="wf", tenant_id="tenant", graph_dict={"nodes": [], "edges": []})
|
||||
workflow = _workflow()
|
||||
mocker.patch.object(module.Graph, "init", return_value=None)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
runner._init_rag_pipeline_graph(workflow=workflow, graph_runtime_state=MagicMock())
|
||||
|
||||
|
||||
def test_update_document_status_on_failure(mocker, runner):
|
||||
document = MagicMock()
|
||||
document_ref = MagicMock()
|
||||
|
||||
session = MagicMock()
|
||||
_patch_create_session(mocker, session)
|
||||
get_document_by_ref = mocker.patch.object(
|
||||
module.DatasetRefService,
|
||||
"get_document_by_ref",
|
||||
return_value=document,
|
||||
)
|
||||
def test_update_document_status_on_failure(runner, sqlite_session: Session):
|
||||
document = _document()
|
||||
_, dataset, _ = _persist_scope(sqlite_session, documents=(document,))
|
||||
dataset_ref = module.DatasetRefService.create_dataset_ref(dataset)
|
||||
document_ref = module.DatasetRefService.create_document_ref_from_id(dataset_ref, document.id)
|
||||
|
||||
event = GraphRunFailedEvent(error="boom")
|
||||
|
||||
runner._update_document_status(event, document_ref)
|
||||
|
||||
get_document_by_ref.assert_called_once_with(document_ref, session=session)
|
||||
assert document.indexing_status == "error"
|
||||
assert document.error == "boom"
|
||||
session.add.assert_called_once_with(document)
|
||||
session.begin.assert_called_once()
|
||||
session.begin.return_value.__enter__.assert_called_once()
|
||||
session.begin.return_value.__exit__.assert_called_once()
|
||||
sqlite_session.expire_all()
|
||||
updated = sqlite_session.get(Document, document.id)
|
||||
assert updated is not None
|
||||
assert updated.indexing_status == "error"
|
||||
assert updated.error == "boom"
|
||||
|
||||
|
||||
def test_update_document_status_skips_when_document_not_found(mocker, runner):
|
||||
document_ref = MagicMock()
|
||||
session = MagicMock()
|
||||
_patch_create_session(mocker, session)
|
||||
get_document_by_ref = mocker.patch.object(
|
||||
module.DatasetRefService,
|
||||
"get_document_by_ref",
|
||||
return_value=None,
|
||||
)
|
||||
def test_update_document_status_skips_when_document_not_found(runner, sqlite_session: Session):
|
||||
_, dataset, _ = _persist_scope(sqlite_session)
|
||||
dataset_ref = module.DatasetRefService.create_dataset_ref(dataset)
|
||||
document_ref = module.DatasetRefService.create_document_ref_from_id(dataset_ref, "missing")
|
||||
|
||||
runner._update_document_status(GraphRunFailedEvent(error="boom"), document_ref)
|
||||
|
||||
get_document_by_ref.assert_called_once_with(document_ref, session=session)
|
||||
session.add.assert_not_called()
|
||||
assert sqlite_session.get(Document, "missing") is None
|
||||
|
||||
|
||||
def test_update_document_status_skips_without_document_ref(mocker, runner):
|
||||
create_session = mocker.patch.object(module, "create_session")
|
||||
def test_update_document_status_skips_without_document_ref(runner, sqlite_engine: Engine):
|
||||
checkouts = 0
|
||||
|
||||
runner._update_document_status(GraphRunFailedEvent(error="boom"), None)
|
||||
def record_checkout(*_args) -> None:
|
||||
nonlocal checkouts
|
||||
checkouts += 1
|
||||
|
||||
create_session.assert_not_called()
|
||||
event.listen(sqlite_engine, "checkout", record_checkout)
|
||||
try:
|
||||
runner._update_document_status(GraphRunFailedEvent(error="boom"), None)
|
||||
finally:
|
||||
event.remove(sqlite_engine, "checkout", record_checkout)
|
||||
|
||||
assert checkouts == 0
|
||||
|
||||
|
||||
def test_run_pipeline_not_found(mocker: MockerFixture):
|
||||
def test_run_pipeline_not_found():
|
||||
app_generate_entity = _build_app_generate_entity()
|
||||
app_generate_entity.invoke_from = InvokeFrom.WEB_APP
|
||||
app_generate_entity.single_iteration_run = None
|
||||
app_generate_entity.single_loop_run = None
|
||||
|
||||
session = MagicMock()
|
||||
session.get.side_effect = [None, None]
|
||||
_patch_create_session(mocker, session)
|
||||
|
||||
runner = PipelineRunner(
|
||||
application_generate_entity=app_generate_entity,
|
||||
queue_manager=MagicMock(),
|
||||
variable_loader=MagicMock(),
|
||||
workflow=MagicMock(),
|
||||
workflow=_workflow(),
|
||||
system_user_id="sys",
|
||||
workflow_execution_repository=MagicMock(),
|
||||
workflow_node_execution_repository=MagicMock(),
|
||||
@ -211,177 +248,116 @@ def test_run_pipeline_not_found(mocker: MockerFixture):
|
||||
runner.run()
|
||||
|
||||
|
||||
def test_run_pipeline_from_other_tenant_is_not_found(mocker: MockerFixture, runner: PipelineRunner):
|
||||
pipeline = MagicMock(id="pipe", tenant_id="other-tenant")
|
||||
session = MagicMock()
|
||||
session.get.side_effect = [None, pipeline]
|
||||
_patch_create_session(mocker, session)
|
||||
def test_run_pipeline_from_other_tenant_is_not_found(runner: PipelineRunner, sqlite_session: Session):
|
||||
pipeline = _pipeline(tenant_id="other-tenant")
|
||||
sqlite_session.add(pipeline)
|
||||
sqlite_session.commit()
|
||||
|
||||
with pytest.raises(ValueError, match="Pipeline not found"):
|
||||
runner.run()
|
||||
|
||||
pipeline.retrieve_dataset.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dataset",
|
||||
[
|
||||
pytest.param(None, id="missing"),
|
||||
pytest.param(SimpleNamespace(id="ds", tenant_id="other-tenant"), id="other-tenant"),
|
||||
pytest.param(SimpleNamespace(id="other-dataset", tenant_id="tenant"), id="other-dataset"),
|
||||
pytest.param(_dataset(tenant_id="other-tenant"), id="other-tenant"),
|
||||
pytest.param(_dataset(dataset_id="other-dataset"), id="other-dataset"),
|
||||
],
|
||||
)
|
||||
def test_run_rejects_unowned_pipeline_dataset(
|
||||
mocker: MockerFixture,
|
||||
runner: PipelineRunner,
|
||||
dataset: SimpleNamespace | None,
|
||||
dataset: Dataset | None,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
pipeline = MagicMock(id="pipe", tenant_id="tenant")
|
||||
pipeline.retrieve_dataset.return_value = dataset
|
||||
session = MagicMock()
|
||||
session.get.side_effect = [None, pipeline]
|
||||
_patch_create_session(mocker, session)
|
||||
pipeline = _pipeline()
|
||||
sqlite_session.add(pipeline)
|
||||
if dataset is not None:
|
||||
sqlite_session.add(dataset)
|
||||
sqlite_session.commit()
|
||||
runner.get_workflow = MagicMock()
|
||||
|
||||
with pytest.raises(ValueError, match="Pipeline dataset not found"):
|
||||
runner.run()
|
||||
|
||||
pipeline.retrieve_dataset.assert_called_once_with(session)
|
||||
runner.get_workflow.assert_not_called()
|
||||
|
||||
|
||||
def test_run_rejects_document_outside_pipeline_dataset_after_async_boundary(
|
||||
mocker: MockerFixture,
|
||||
runner: PipelineRunner,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
runner.application_generate_entity.document_id = "foreign-doc"
|
||||
runner.application_generate_entity.original_document_id = "foreign-doc"
|
||||
pipeline = MagicMock(id="pipe", tenant_id="tenant")
|
||||
pipeline.retrieve_dataset.return_value = SimpleNamespace(id="ds", tenant_id="tenant")
|
||||
session = MagicMock()
|
||||
session.get.side_effect = [None, pipeline]
|
||||
_patch_create_session(mocker, session)
|
||||
_persist_scope(sqlite_session)
|
||||
runner.get_workflow = MagicMock()
|
||||
get_document_by_ref = mocker.patch.object(
|
||||
module.DatasetRefService,
|
||||
"get_document_by_ref",
|
||||
return_value=None,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Pipeline document not found"):
|
||||
runner.run()
|
||||
|
||||
document_ref = get_document_by_ref.call_args.args[0]
|
||||
assert document_ref.dataset.tenant_id == "tenant"
|
||||
assert document_ref.dataset.dataset_id == "ds"
|
||||
assert document_ref.document_id == "foreign-doc"
|
||||
get_document_by_ref.assert_called_once_with(document_ref, session=session)
|
||||
runner.get_workflow.assert_not_called()
|
||||
|
||||
|
||||
def test_run_rejects_original_document_outside_pipeline_dataset_after_async_boundary(
|
||||
mocker: MockerFixture,
|
||||
runner: PipelineRunner,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
runner.application_generate_entity.document_id = "doc"
|
||||
runner.application_generate_entity.original_document_id = "foreign-doc"
|
||||
pipeline = MagicMock(id="pipe", tenant_id="tenant")
|
||||
pipeline.retrieve_dataset.return_value = SimpleNamespace(id="ds", tenant_id="tenant")
|
||||
session = MagicMock()
|
||||
session.get.side_effect = [None, pipeline]
|
||||
_patch_create_session(mocker, session)
|
||||
_persist_scope(sqlite_session, documents=(_document(),))
|
||||
runner.get_workflow = MagicMock()
|
||||
get_document_by_ref = mocker.patch.object(
|
||||
module.DatasetRefService,
|
||||
"get_document_by_ref",
|
||||
side_effect=[SimpleNamespace(id="doc"), None],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Pipeline original document not found"):
|
||||
runner.run()
|
||||
|
||||
document_refs = [call.args[0] for call in get_document_by_ref.call_args_list]
|
||||
assert [document_ref.document_id for document_ref in document_refs] == ["doc", "foreign-doc"]
|
||||
for document_ref in document_refs:
|
||||
assert document_ref.dataset.tenant_id == "tenant"
|
||||
assert document_ref.dataset.dataset_id == "ds"
|
||||
runner.get_workflow.assert_not_called()
|
||||
|
||||
|
||||
def test_run_workflow_not_initialized(mocker: MockerFixture):
|
||||
def test_run_workflow_not_initialized(sqlite_session: Session):
|
||||
app_generate_entity = _build_app_generate_entity()
|
||||
|
||||
pipeline = MagicMock(id="pipe", tenant_id="tenant")
|
||||
pipeline.retrieve_dataset.return_value = SimpleNamespace(id="ds", tenant_id="tenant")
|
||||
|
||||
session = MagicMock()
|
||||
session.get.side_effect = [None, pipeline]
|
||||
_patch_create_session(mocker, session)
|
||||
pipeline = _pipeline()
|
||||
dataset = _dataset()
|
||||
document = _document()
|
||||
sqlite_session.add_all([pipeline, dataset, document])
|
||||
sqlite_session.commit()
|
||||
|
||||
runner = PipelineRunner(
|
||||
application_generate_entity=app_generate_entity,
|
||||
queue_manager=MagicMock(),
|
||||
variable_loader=MagicMock(),
|
||||
workflow=MagicMock(),
|
||||
workflow=_workflow(),
|
||||
system_user_id="sys",
|
||||
workflow_execution_repository=MagicMock(),
|
||||
workflow_node_execution_repository=MagicMock(),
|
||||
)
|
||||
runner.get_workflow = MagicMock(return_value=None)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
runner.run()
|
||||
|
||||
|
||||
def test_run_single_iteration_path(mocker: MockerFixture):
|
||||
def test_run_single_iteration_path(mocker: MockerFixture, sqlite_session: Session):
|
||||
app_generate_entity = _build_app_generate_entity()
|
||||
app_generate_entity.single_iteration_run = MagicMock()
|
||||
|
||||
pipeline = MagicMock(id="pipe", tenant_id="tenant")
|
||||
dataset = SimpleNamespace(id="ds", tenant_id="tenant")
|
||||
pipeline.retrieve_dataset.return_value = dataset
|
||||
session = MagicMock()
|
||||
session.get.return_value = pipeline
|
||||
_patch_create_session(mocker, session)
|
||||
_, dataset, _ = _persist_scope(sqlite_session, documents=(_document(),))
|
||||
dataset_ref = module.DatasetRefService.create_dataset_ref(dataset)
|
||||
document_ref = module.DatasetRefService.create_document_ref_from_id(dataset_ref, "doc")
|
||||
|
||||
runner = PipelineRunner(
|
||||
application_generate_entity=app_generate_entity,
|
||||
queue_manager=MagicMock(),
|
||||
variable_loader=MagicMock(),
|
||||
workflow=MagicMock(),
|
||||
workflow=_workflow(),
|
||||
system_user_id="sys",
|
||||
workflow_execution_repository=MagicMock(),
|
||||
workflow_node_execution_repository=MagicMock(),
|
||||
)
|
||||
|
||||
runner._resolve_user_from = MagicMock(return_value=UserFrom.ACCOUNT)
|
||||
runner.get_workflow = MagicMock(
|
||||
return_value=MagicMock(
|
||||
id="wf",
|
||||
tenant_id="tenant",
|
||||
app_id="pipe",
|
||||
graph_dict={},
|
||||
type="rag-pipeline",
|
||||
version="v1",
|
||||
)
|
||||
)
|
||||
runner._prepare_single_node_execution = MagicMock(return_value=("graph", "pool", "state"))
|
||||
runner._update_document_status = MagicMock()
|
||||
runner._handle_event = MagicMock()
|
||||
|
||||
dataset_ref = MagicMock()
|
||||
document_ref = MagicMock()
|
||||
create_dataset_ref = mocker.patch.object(
|
||||
module.DatasetRefService,
|
||||
"create_dataset_ref",
|
||||
return_value=dataset_ref,
|
||||
)
|
||||
create_document_ref_from_id = mocker.patch.object(
|
||||
module.DatasetRefService,
|
||||
"create_document_ref_from_id",
|
||||
return_value=document_ref,
|
||||
)
|
||||
|
||||
event = MagicMock()
|
||||
workflow_entry = MagicMock()
|
||||
workflow_entry.graph_engine = MagicMock()
|
||||
@ -392,34 +368,30 @@ def test_run_single_iteration_path(mocker: MockerFixture):
|
||||
|
||||
runner.run()
|
||||
|
||||
create_dataset_ref.assert_called_once_with(dataset)
|
||||
create_document_ref_from_id.assert_called_once_with(dataset_ref, "doc")
|
||||
runner._prepare_single_node_execution.assert_called_once()
|
||||
runner._update_document_status.assert_called_once_with(event, document_ref)
|
||||
runner._handle_event.assert_called()
|
||||
|
||||
|
||||
def test_run_normal_path_builds_graph(mocker: MockerFixture):
|
||||
def test_run_normal_path_builds_graph(mocker: MockerFixture, sqlite_session: Session, sqlite_engine: Engine):
|
||||
app_generate_entity = _build_app_generate_entity()
|
||||
|
||||
pipeline = MagicMock(id="pipe", tenant_id="tenant")
|
||||
pipeline.retrieve_dataset.return_value = SimpleNamespace(id="ds", tenant_id="tenant")
|
||||
end_user = MagicMock(session_id="sess")
|
||||
events = []
|
||||
|
||||
session = MagicMock()
|
||||
session.get.side_effect = [end_user, pipeline]
|
||||
_patch_create_session(mocker, session, events=events)
|
||||
|
||||
workflow = MagicMock(
|
||||
id="wf",
|
||||
tenant_id="tenant",
|
||||
app_id="pipe",
|
||||
graph_dict={"nodes": [], "edges": []},
|
||||
environment_variables=[],
|
||||
rag_pipeline_variables=[{"variable": "input1", "belong_to_node_id": "start"}],
|
||||
type="rag-pipeline",
|
||||
version="v1",
|
||||
workflow = _workflow()
|
||||
workflow.rag_pipeline_variables = [
|
||||
{
|
||||
"variable": "input1",
|
||||
"belong_to_node_id": "start",
|
||||
"type": "text-input",
|
||||
"label": "Input",
|
||||
}
|
||||
]
|
||||
workflow.id = "wf"
|
||||
_persist_scope(
|
||||
sqlite_session,
|
||||
workflow=workflow,
|
||||
end_user=_end_user(),
|
||||
documents=(_document(),),
|
||||
)
|
||||
|
||||
runner = PipelineRunner(
|
||||
@ -433,18 +405,10 @@ def test_run_normal_path_builds_graph(mocker: MockerFixture):
|
||||
)
|
||||
|
||||
runner._resolve_user_from = MagicMock(return_value=UserFrom.ACCOUNT)
|
||||
runner.get_workflow = MagicMock(return_value=workflow)
|
||||
runner._init_rag_pipeline_graph = MagicMock(return_value="graph")
|
||||
runner._update_document_status = MagicMock()
|
||||
runner._handle_event = MagicMock()
|
||||
|
||||
mocker.patch.object(
|
||||
module.RAGPipelineVariable,
|
||||
"model_validate",
|
||||
return_value=SimpleNamespace(belong_to_node_id="start", variable="input1"),
|
||||
)
|
||||
mocker.patch.object(module, "RAGPipelineVariableInput", side_effect=lambda **kwargs: SimpleNamespace(**kwargs))
|
||||
|
||||
class FakeVariablePool:
|
||||
def add(self, selector, value):
|
||||
return None
|
||||
@ -457,7 +421,15 @@ def test_run_normal_path_builds_graph(mocker: MockerFixture):
|
||||
mocker.patch.object(module, "WorkflowEntry", return_value=workflow_entry)
|
||||
mocker.patch.object(module, "WorkflowPersistenceLayer", return_value=MagicMock())
|
||||
|
||||
runner.run()
|
||||
def record_checkin(*_args) -> None:
|
||||
events.append("session_checkin")
|
||||
|
||||
assert events == ["session_enter", "session_exit", "workflow_run"]
|
||||
event.listen(sqlite_engine, "checkin", record_checkin)
|
||||
try:
|
||||
runner.run()
|
||||
finally:
|
||||
event.remove(sqlite_engine, "checkin", record_checkin)
|
||||
|
||||
assert events[-1] == "workflow_run"
|
||||
assert "session_checkin" in events[:-1]
|
||||
runner._init_rag_pipeline_graph.assert_called_once()
|
||||
|
||||
@ -2,7 +2,12 @@ from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.apps.execution_coordinator import AppExecutionCoordinator, AppExecutionState
|
||||
from core.app.apps.execution_coordinator import (
|
||||
AppExecutionCoordinator,
|
||||
AppExecutionState,
|
||||
app_task_command_channel_key,
|
||||
clear_app_task_cancellation_signals,
|
||||
)
|
||||
|
||||
|
||||
def test_listener_close_does_not_abort_running_attempt() -> None:
|
||||
@ -74,6 +79,54 @@ def test_pausing_started_attempt_cancels_watchdog() -> None:
|
||||
assert coordinator.state is AppExecutionState.PAUSED
|
||||
|
||||
|
||||
def test_clearing_cancellation_signals_drops_stop_flag_and_queued_commands() -> None:
|
||||
channel = Mock()
|
||||
channel.fetch_commands.return_value = [Mock()]
|
||||
with (
|
||||
patch("core.app.apps.execution_coordinator.redis_client") as redis_client,
|
||||
patch("core.app.apps.execution_coordinator.RedisChannel", return_value=channel) as redis_channel,
|
||||
):
|
||||
clear_app_task_cancellation_signals("task")
|
||||
|
||||
redis_channel.assert_called_once_with(redis_client, "workflow:task:commands")
|
||||
channel.fetch_commands.assert_called_once_with()
|
||||
assert redis_client.delete.call_args_list == [
|
||||
(("generate_task_stopped:task",), {}),
|
||||
(("workflow:task:commands",), {}),
|
||||
]
|
||||
|
||||
|
||||
def test_clearing_cancellation_signals_ignores_empty_task_id() -> None:
|
||||
with (
|
||||
patch("core.app.apps.execution_coordinator.redis_client") as redis_client,
|
||||
patch("core.app.apps.execution_coordinator.RedisChannel") as redis_channel,
|
||||
):
|
||||
clear_app_task_cancellation_signals("")
|
||||
|
||||
redis_client.delete.assert_not_called()
|
||||
redis_channel.assert_not_called()
|
||||
|
||||
|
||||
def test_clearing_cancellation_signals_survives_command_channel_failure(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with (
|
||||
patch("core.app.apps.execution_coordinator.redis_client") as redis_client,
|
||||
patch("core.app.apps.execution_coordinator.RedisChannel") as redis_channel,
|
||||
):
|
||||
redis_channel.return_value.fetch_commands.side_effect = RuntimeError("redis read failed")
|
||||
|
||||
clear_app_task_cancellation_signals("task")
|
||||
|
||||
# The stop flag is cleared first, so a command-channel failure cannot leave it armed.
|
||||
redis_client.delete.assert_called_once_with("generate_task_stopped:task")
|
||||
assert "Failed to clear pending GraphEngine commands for app task task" in caplog.text
|
||||
|
||||
|
||||
def test_command_channel_key_matches_the_channel_the_stop_command_targets() -> None:
|
||||
assert app_task_command_channel_key("task") == "workflow:task:commands"
|
||||
|
||||
|
||||
def test_stop_flag_failure_does_not_block_graph_stop(caplog: pytest.LogCaptureFixture) -> None:
|
||||
on_timeout = Mock()
|
||||
with (
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
@ -106,10 +107,19 @@ class _StubToolNode(Node[_StubToolNodeData]):
|
||||
def _patch_tool_node(mocker: MockerFixture):
|
||||
original_resolve_node_class = node_factory_module.resolve_workflow_node_class
|
||||
|
||||
def _patched_resolve_node_class(*, node_type: NodeType, node_version: str) -> type[Node]:
|
||||
def _patched_resolve_node_class(
|
||||
*,
|
||||
node_type: NodeType,
|
||||
node_version: str,
|
||||
node_data: Mapping[str, Any] | BaseNodeData | None = None,
|
||||
) -> type[Node]:
|
||||
if node_type == BuiltinNodeTypes.TOOL:
|
||||
return _StubToolNode
|
||||
return original_resolve_node_class(node_type=node_type, node_version=node_version)
|
||||
return original_resolve_node_class(
|
||||
node_type=node_type,
|
||||
node_version=node_version,
|
||||
node_data=node_data,
|
||||
)
|
||||
|
||||
mocker.patch.object(node_factory_module, "resolve_workflow_node_class", side_effect=_patched_resolve_node_class)
|
||||
|
||||
|
||||
60
api/tests/unit_tests/core/app/llm/test_model_access.py
Normal file
60
api/tests/unit_tests/core/app/llm/test_model_access.py
Normal file
@ -0,0 +1,60 @@
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext
|
||||
from core.app.llm import model_access
|
||||
from graphon.model_runtime.entities.model_entities import ModelPropertyKey
|
||||
|
||||
|
||||
def _stub_model_factory(monkeypatch: pytest.MonkeyPatch, context_window: object) -> dict[str, object]:
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
class FakeModelFactory:
|
||||
def __init__(self, *, run_context: DifyRunContext) -> None:
|
||||
calls["run_context"] = run_context
|
||||
|
||||
def init_model_instance(self, provider_name: str, model_name: str) -> object:
|
||||
calls["provider_name"] = provider_name
|
||||
calls["model_name"] = model_name
|
||||
schema = SimpleNamespace(model_properties={ModelPropertyKey.CONTEXT_SIZE: context_window})
|
||||
return SimpleNamespace(get_model_schema=lambda: schema)
|
||||
|
||||
monkeypatch.setattr(model_access, "DifyModelFactory", FakeModelFactory)
|
||||
return calls
|
||||
|
||||
|
||||
def test_resolve_model_context_window_reads_selected_model_schema(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = _stub_model_factory(monkeypatch, 128_000)
|
||||
run_context = cast(DifyRunContext, object())
|
||||
|
||||
context_window = model_access.resolve_model_context_window(
|
||||
run_context=run_context,
|
||||
provider_name="langgenius/openai/openai",
|
||||
model_name="gpt-4o",
|
||||
)
|
||||
|
||||
assert context_window == 128_000
|
||||
assert calls == {
|
||||
"run_context": run_context,
|
||||
"provider_name": "langgenius/openai/openai",
|
||||
"model_name": "gpt-4o",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("context_window", [None, 0, -1, True, False, "128000", 128_000.0])
|
||||
def test_resolve_model_context_window_ignores_invalid_schema_values(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
context_window: object,
|
||||
) -> None:
|
||||
_ = _stub_model_factory(monkeypatch, context_window)
|
||||
|
||||
assert (
|
||||
model_access.resolve_model_context_window(
|
||||
run_context=cast(DifyRunContext, object()),
|
||||
provider_name="openai",
|
||||
model_name="gpt-4o",
|
||||
)
|
||||
is None
|
||||
)
|
||||
@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.rag.index_processor import index_processor as index_processor_module
|
||||
from core.rag.index_processor.constant.index_type import IndexTechniqueType
|
||||
from core.rag.index_processor.index_processor import IndexProcessor
|
||||
from core.workflow.nodes.knowledge_index.protocols import Preview, PreviewItem
|
||||
@ -237,12 +238,10 @@ class TestIndexProcessor:
|
||||
"core.rag.index_processor.index_processor.current_app",
|
||||
SimpleNamespace(_get_current_object=lambda: flask_app),
|
||||
),
|
||||
patch(
|
||||
"core.rag.index_processor.index_processor.session_factory",
|
||||
SimpleNamespace(create_session=sqlite_session_factory),
|
||||
),
|
||||
patch(
|
||||
"core.rag.index_processor.index_processor.ParagraphIndexProcessor.generate_summary",
|
||||
patch.object(index_processor_module.session_factory, "create_session", sqlite_session_factory),
|
||||
patch.object(
|
||||
index_processor_module.ParagraphIndexProcessor,
|
||||
"generate_summary",
|
||||
side_effect=generate_summary,
|
||||
) as generate_summary,
|
||||
):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user