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

This commit is contained in:
FFXN 2026-08-18 14:02:10 +08:00
commit f5000ecb88
412 changed files with 12678 additions and 5361 deletions

View File

@ -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`.

View File

@ -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'

View File

@ -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'

View File

@ -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'

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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'

View File

@ -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,

View File

@ -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__}")

View File

@ -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:

View File

@ -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,
),
),
]

View File

@ -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,

View File

@ -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:

View File

@ -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

View File

@ -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(),
)

View File

@ -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

View File

@ -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")

View File

@ -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,

View File

@ -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(

View File

@ -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

View File

@ -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()

View File

@ -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:

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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.

View File

@ -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,

View File

@ -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,

View File

@ -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.

View File

@ -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}"

View File

@ -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."""

View File

@ -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:

View 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
)

View File

@ -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":

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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 |

View File

@ -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

View File

@ -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:

View File

@ -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,

View File

@ -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)
)

View File

@ -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}

View File

@ -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(

View File

@ -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]]:

View File

@ -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(

View 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)

View File

@ -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,

View File

@ -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",

View File

@ -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)

View 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,)

View File

@ -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)

View File

@ -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

View File

@ -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"

View File

@ -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."""

View File

@ -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")

View File

@ -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"

View File

@ -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(

View File

@ -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:

View File

@ -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"}],
},

View File

@ -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)

View File

@ -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"

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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")

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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)

View File

@ -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:

View File

@ -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)

View File

@ -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"

View File

@ -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")

View File

@ -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 = {

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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,
)

View File

@ -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:

View File

@ -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

View File

@ -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 == []

View File

@ -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"

View File

@ -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,

View File

@ -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()

View File

@ -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()

View File

@ -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 (

View File

@ -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)

View 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
)

View File

@ -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