diff --git a/.agents/skills/frontend-code-review/references/component-architecture.md b/.agents/skills/frontend-code-review/references/component-architecture.md index ffabbf4cd69..9eda6589fd0 100644 --- a/.agents/skills/frontend-code-review/references/component-architecture.md +++ b/.agents/skills/frontend-code-review/references/component-architecture.md @@ -61,7 +61,7 @@ Flag effects that: - Transform props/state for rendering. - Copy one state value into another representing the same concept. - Handle user actions that belong in event handlers. -- Reset state from props when a keyed reset, stable ID, or render-time derivation would work. +- Reset local state from props or visibility when derivation, a stable semantic identity, or the intended mounted owner already expresses the lifecycle. - Fetch data that belongs in framework APIs or TanStack Query. If an effect remains, it must synchronize with a named external system: browser API, subscription, timer, analytics-on-visibility, non-React widget, or imperative DOM integration. @@ -71,11 +71,24 @@ If an effect remains, it must synchronize with a named external system: browser Flag: - Storing derived booleans, disabled flags, default tabs, or loading labels that can be calculated from current query/feature state. +- Per-session state held by a longer-lived visibility coordinator and cleared through an open-state Effect or a generated key when the primitive's mounted-content lifecycle already matches the intended state lifetime. +- A DOM field mirrored into competing prop, default, and React state sources when editing does not require those sources to synchronize. - Local state used to fake server data or generated contract fields. - UI state persisted to localStorage when it is live app state. - Feature-local mock shells wired to unrelated existing APIs before the real API is confirmed. -Prefer render-time derivation. Keep true local state for user choices, transient input, controlled popups, and feature UI state that has no server source. +Review state lifetime before its storage mechanism. For a hidden surface, distinguish the +visibility coordinator from mounted content. State private to one mounted session belongs in that +content owner; promote it only when the draft must survive that content owner's unmount or another +owner coordinates it. A stable semantic identity key may create a new snapshot when the represented +identity changes; a generated key is not a routine reset command. + +Prefer render-time derivation. Keep true local state for user choices, transient input, controlled +popups, and feature UI state that has no server source. Submit-only DOM fields may remain +uncontrolled; use local controlled state when React must own the current value to drive rendering +or coordination. Observing change events or tracking a derived fact such as dirty state does not +require mirroring the field value. Do not flag controlled state by itself without a concrete +competing-source, stale-state, or ownership defect. ## Navigation diff --git a/.agents/skills/frontend-code-review/references/testing.md b/.agents/skills/frontend-code-review/references/testing.md index 2f81d10589e..3bda124c01b 100644 --- a/.agents/skills/frontend-code-review/references/testing.md +++ b/.agents/skills/frontend-code-review/references/testing.md @@ -9,6 +9,7 @@ Flag missing coverage when a change alters a reachable contract such as: - User interaction, navigation, form submission, validation, or permissions. - Query or mutation behavior, URL state, persistence, or one-shot signals. - Loading, error, empty, and recovery states that users can encounter. +- A hidden surface whose close-and-reopen behavior changes whether in-progress state resets or persists. - Accessibility-critical labels, keyboard flow, focus, disabled state, or overlay behavior. - A regression-prone business rule or bug fix that can be reproduced through a public boundary. diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index 2b0e50f538d..b6599743227 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -9,10 +9,11 @@ Use this skill to route component architecture decisions to its bundled referenc ## First Decisions -| Question | Default | Promote only when | +| Question | Default | Choose differently when | | --- | --- | --- | | Where should code live? | In the product workflow, route, or feature owner. | Several verticals need the same stable contract. | -| Who owns state and handlers? | The lowest visual owner that consumes them. | A parent coordinates one workflow or consistent snapshot. | +| Who owns state and handlers? | The lowest owner that consumes them and whose lifetime matches the state. | Another owner coordinates the value or it must survive the local owner's unmount. | +| Should React control a value? | Leave submit-only DOM fields uncontrolled. | The workflow must own the current value to drive rendering or coordination. | | Should state enter Jotai? | Keep component and form state local. | Siblings need one source of truth or scoped workflow persistence. | | Who owns URL state? | Next.js route APIs and `nuqs`. | Atoms require a read-only route-identity bridge. | | Who owns remote state? | TanStack Query at the lowest consumer. | Atom state drives the query or shared derivations consume it. | @@ -24,12 +25,12 @@ Use this skill to route component architecture decisions to its bundled referenc - Component moves, module boundaries, props, types, or owner placement: read [`references/ownership.md`][ownership]. - Jotai, form drafts, route identity, URL state, or persistence: read [`references/state.md`][state]. - Generated contracts, nullable API data, Query, mutations, SSR, auth, or workspace state: read [`references/data.md`][data]. -- Hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces: read [`references/interactions.md`][interactions] and the overlay guide it references when applicable. +- Hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces: read [`references/interactions.md`][interactions] and the overlay guide it references when applicable. Also read [`references/state.md`][state] when the surface owns a draft or other local session state. - Effects, navigation, memoization, preloading, or render cost: read [`references/runtime.md`][runtime]. ## Workflow -1. Identify the behavior owner and the public contract being changed. +1. Identify the behavior owner, the required state lifetime, and the public contract being changed. 2. Read the nearby implementation, tests, and only the routed skill references. 3. Implement one coherent vertical slice. Do not expand into equivalent patterns elsewhere unless the current contract cannot be completed without them. 4. Verify observable behavior at the narrowest sufficient boundary, then run the checks documented by the owning package: `web/docs/test.md` or `web/docs/lint.md` for Web, and `packages/dify-ui/docs/testing.md` for Dify UI. diff --git a/.agents/skills/how-to-write-component/references/interactions.md b/.agents/skills/how-to-write-component/references/interactions.md index 68233987b61..ba9315f981e 100644 --- a/.agents/skills/how-to-write-component/references/interactions.md +++ b/.agents/skills/how-to-write-component/references/interactions.md @@ -23,8 +23,10 @@ Read this document when a change involves application hotkeys, focus, dialogs, m - Follow the [overlay contract] for primitive choice and shared mechanics. The nearest consumer `AGENTS.md` owns application-specific composite reuse policy. - Separate behavior ownership from placement ownership: the action may own trigger, open state, and menu content while the caller owns slots, offsets, and alignment. - Keep menu and dialog surfaces as siblings when a menu command opens a dialog. Mount the dialog outside popup content. -- Mount controlled overlays unconditionally unless unmounting is required for performance or reset semantics. Prefer keyed or owner-local reset over conditional wrappers. -- Put query and mutation work inside dialog or alert-dialog content when it should mount only after opening. -- Prefer uncontrolled roots when the primitive can own open state. Use controlled state only for business coordination, analytics, cleanup, or explicit reset behavior. +- Keep overlay open-state ownership separate from content-session ownership. A controlled root does not require controlled fields or root-owned drafts. +- Match transient state to the primitive's content mount lifecycle. State below an unmounting content boundary gets a fresh instance after unmount; intentionally kept-mounted content needs an explicit persistence or reset policy. +- Keep a controlled overlay root at its coordination owner so the primitive can complete exit transitions, focus restoration, and detached-handle behavior. Do not conditionally remove the root to reset content state, and use keys only for stable semantic identity. +- Place query subscriptions and mutation observers at the owner whose lifetime matches when they should run. Mounted-session work may belong inside content; work that must start or stop exactly with `open` needs an explicit open-state condition. +- Prefer primitive-owned open state unless another owner must observe or coordinate it. Analytics callbacks and local cleanup alone do not require a controlled root. [overlay contract]: ../../../../packages/dify-ui/docs/overlays.md diff --git a/.agents/skills/how-to-write-component/references/ownership.md b/.agents/skills/how-to-write-component/references/ownership.md index d8da521aafc..886421ef394 100644 --- a/.agents/skills/how-to-write-component/references/ownership.md +++ b/.agents/skills/how-to-write-component/references/ownership.md @@ -12,8 +12,8 @@ Read this document when adding, moving, splitting, or refactoring React componen ## Component Ownership -- Put state, data access, loading, empty, error, and handlers in the lowest visual owner that uses them. -- Keep coordination in a parent only when it needs one consistent snapshot or coordinates submission, shared selection, batch behavior, navigation, or cross-section loading and errors. +- Put state, data access, loading, empty, error, and handlers in the lowest owner that uses them and whose mounted lifetime matches the required persistence. +- Keep coordination in a parent only when it needs one consistent snapshot, the value must intentionally survive the local owner's unmount, or the parent coordinates submission, shared selection, batch behavior, navigation, or cross-section loading and errors. - Repeated TanStack Query calls in siblings are acceptable when each sibling independently consumes the data; the cache already deduplicates requests. - Pass stable domain identity across boundaries. Do not pass raw server data together with separately derived flags for the same concept. - One pass-through prop layer is acceptable. Repeated forwarding means ownership should move closer to the consumer or into feature-scoped shared state. @@ -23,7 +23,8 @@ Read this document when adding, moving, splitting, or refactoring React componen ## Boundaries - State-heavy wizards, drawers, modals, and secondary workflows can form a small vertical surface with an entrypoint, optional feature-local state, and shallow owners matching real visual regions. -- The entrypoint owns route integration, provider wiring, close behavior, and mounting. Composition owners handle workflow branches; the closest visual owner handles section branches. +- The entrypoint owns route integration, provider wiring, placement, and open-state coordination. A content or session owner keeps state scoped to that mounted surface. +- Judge hook lifetime by the component that declares the hook and the primitive's mount contract, not only by where its rendered controls appear in JSX. - Separate hidden dialogs, dropdowns, and popovers into small local owners when their content obscures the parent flow. - Keep cohesive forms, menu bodies, and one-off helpers local unless they have their own state, reuse, or semantic boundary. - Avoid wrapper components and wrapper DOM that only rename props, pass children through, or hide the real primitive. A wrapper must own behavior, validation, state, accessibility, layout, or library integration. diff --git a/.agents/skills/how-to-write-component/references/runtime.md b/.agents/skills/how-to-write-component/references/runtime.md index 24554d895f5..42131944637 100644 --- a/.agents/skills/how-to-write-component/references/runtime.md +++ b/.agents/skills/how-to-write-component/references/runtime.md @@ -7,7 +7,7 @@ Read this document when a change introduces Effects, navigation side effects, me - Keep render pure: do not read or write `ref.current` during render except for predictable null-guarded lazy initialization. Update interaction-owned refs in event handlers, synchronize external-system refs after commit, and use state or derivation for rendered values. - Use Effects only to synchronize with a named external system such as a browser API, subscription, timer, analytics integration, non-React widget, or imperative DOM API. - Do not use Effects to transform render state, handle user actions, copy query data, reset state from props, or fetch data owned by framework APIs or TanStack Query. -- Initialize query-backed forms with keyed remounts or surface-entry hydration instead of copying data through Effects. +- Initialize query-backed form sessions after their defaults are available instead of copying data through Effects. Use a stable semantic identity key when the represented identity changes; use the intended surface lifecycle for per-session reset. ## Navigation diff --git a/.agents/skills/how-to-write-component/references/state.md b/.agents/skills/how-to-write-component/references/state.md index 0ede0a85e92..680d77fb8b9 100644 --- a/.agents/skills/how-to-write-component/references/state.md +++ b/.agents/skills/how-to-write-component/references/state.md @@ -9,10 +9,12 @@ Read this document when a change involves Jotai, form drafts, route identity, sh - Keep server and cache state in TanStack Query. Use existing feature stores for complex, high-frequency interaction state such as workflow canvas drag, resize, and runtime panels. - Use feature-owned storage only for low-frequency client preferences, dismissed notices, and UI defaults. Live application state does not belong in local storage. -## Forms +## Forms And Sessions -- Prefer uncontrolled Dify UI form and field controls when values are only read at submit time. Initialize query-backed defaults with `defaultValue` and keyed remounts. -- Promote form values to atoms only when another owner reacts to in-progress values, the draft must survive scoped unmounting, or several workflow steps edit the same draft. +- Keep form state in the narrowest owner whose lifetime matches the draft. A draft scoped to one mounted surface belongs to that content or session owner; a draft that must survive its current owner's unmount belongs to an explicit longer-lived feature owner. +- Prefer uncontrolled fields when values are only read at submit time. Use local controlled state only when React must own the current value to drive dependent UI or linked fields; track derived facts such as dirty state without mirroring the field value. Controlledness does not decide whether a draft is local or persisted. +- For query-backed defaults, establish the form session after the required defaults are available. `defaultValue` initializes the current mount; a stable semantic identity key may create a fresh snapshot when the represented identity changes. Do not use a generated key as a routine reset command. +- Promote drafts beyond the session only when another owner reacts to in-progress values, several workflow steps share one draft, or the draft must intentionally survive unmounting. Start with the lowest shared React owner; use feature-scoped atoms only when their coordination or persistence contract is needed. - Keep validation, source priority, fallback behavior, dirty checks, and payload assembly in the workflow that owns submission. ## Route And URL State diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml index 0c23cda5f58..51c5485ce0f 100644 --- a/.github/workflows/cli-e2e.yml +++ b/.github/workflows/cli-e2e.yml @@ -112,7 +112,7 @@ jobs: # ════════════════════════════════════════════════════════════════════════════ suite-framework-output-error: name: 'Suite: framework + output + error-handling' - if: ${{ inputs.suite_framework_output_error != 'false' }} + if: ${{ inputs.suite_framework_output_error }} needs: provision runs-on: ubuntu-latest timeout-minutes: 20 @@ -129,9 +129,12 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } + with: + bun-version: latest - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - with: { package_json_field: packageManager, run_install: false } + with: + package_json_field: packageManager + run_install: false - run: pnpm install --frozen-lockfile - run: pnpm tree:gen @@ -159,7 +162,7 @@ jobs: # ════════════════════════════════════════════════════════════════════════════ suite-discovery: name: 'Suite: discovery' - if: ${{ inputs.suite_discovery != 'false' }} + if: ${{ inputs.suite_discovery }} needs: provision runs-on: ubuntu-latest timeout-minutes: 20 @@ -176,9 +179,12 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } + with: + bun-version: latest - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - with: { package_json_field: packageManager, run_install: false } + with: + package_json_field: packageManager + run_install: false - run: pnpm install --frozen-lockfile - run: pnpm tree:gen @@ -207,7 +213,7 @@ jobs: # ════════════════════════════════════════════════════════════════════════════ suite-run: name: 'Suite: run / ${{ matrix.name }}' - if: ${{ inputs.suite_run != 'false' }} + if: ${{ inputs.suite_run }} needs: provision runs-on: ubuntu-latest timeout-minutes: 20 @@ -239,9 +245,12 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } + with: + bun-version: latest - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - with: { package_json_field: packageManager, run_install: false } + with: + package_json_field: packageManager + run_install: false - run: pnpm install --frozen-lockfile - run: pnpm tree:gen @@ -284,7 +293,7 @@ jobs: # ════════════════════════════════════════════════════════════════════════════ suite-auth-safe: name: 'Suite: auth (login / status / whoami)' - if: ${{ inputs.suite_auth != 'false' }} + if: ${{ inputs.suite_auth }} needs: provision runs-on: ubuntu-latest timeout-minutes: 15 @@ -301,9 +310,12 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } + with: + bun-version: latest - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - with: { package_json_field: packageManager, run_install: false } + with: + package_json_field: packageManager + run_install: false - run: pnpm install --frozen-lockfile - run: pnpm tree:gen @@ -333,7 +345,7 @@ jobs: suite-last: name: 'Suite: auth-use + devices + logout + agent (last, serial)' # Runs when auth is selected; also runs after all parallel jobs finish - if: ${{ inputs.suite_auth != 'false' || inputs.suite_agent != 'false' }} + if: ${{ inputs.suite_auth || inputs.suite_agent }} needs: - provision - suite-framework-output-error @@ -357,9 +369,12 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } + with: + bun-version: latest - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - with: { package_json_field: packageManager, run_install: false } + with: + package_json_field: packageManager + run_install: false - run: pnpm install --frozen-lockfile - run: pnpm tree:gen diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 6e0670d11e7..172ff0e8cf4 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -7,12 +7,22 @@ on: description: Dify release tag to attach difyctl assets to (blank = latest stable) required: false type: string + dry_run: + description: Build and checksum only — skip asset upload and stale-asset prune + required: false + type: boolean + default: false workflow_call: inputs: release_tag: description: Dify release tag to attach difyctl assets to (blank = latest stable) required: false type: string + dry_run: + description: Build and checksum only — skip asset upload and stale-asset prune + required: false + type: boolean + default: false release: types: [released] @@ -39,11 +49,8 @@ jobs: with: persist-credentials: false - - name: Export manifest to env - run: node scripts/release-naming.mjs github-env >> "$GITHUB_ENV" - - name: Validate manifest - run: scripts/release-validate-manifest.sh + run: node scripts/release-naming.mjs validate - name: Resolve target Dify release id: resolve @@ -75,15 +82,6 @@ jobs: DIFY_TAG: ${{ steps.resolve.outputs.dify_tag }} run: node scripts/release-naming.mjs compat-check "$DIFY_TAG" - - name: Reject duplicate difyctl version - env: - GH_TOKEN: ${{ github.token }} - run: | - if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${difyctlTag}" >/dev/null 2>&1; then - echo "::error::difyctl ${version} already released (tag ${difyctlTag} exists); bump cli/package.json version" - exit 1 - fi - release: name: build + attach standalone binaries (all targets) needs: validate @@ -120,6 +118,7 @@ jobs: - name: Compile standalone binaries (all targets) run: | + CLI_VERSION="$version" \ DIFYCTL_COMMIT="$(git rev-parse HEAD)" \ DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \ pnpm build:bin @@ -128,6 +127,7 @@ jobs: run: scripts/release-write-checksums.sh - name: Attach difyctl assets to Dify release + if: ${{ !inputs.dry_run }} env: GH_TOKEN: ${{ github.token }} run: | @@ -135,6 +135,7 @@ jobs: --repo "$GITHUB_REPOSITORY" --clobber - name: Prune stale difyctl assets + if: ${{ !inputs.dry_run }} env: GH_TOKEN: ${{ github.token }} run: | @@ -149,18 +150,3 @@ jobs: --repo "$GITHUB_REPOSITORY" --yes fi done - - - name: Create provenance tag - env: - GH_TOKEN: ${{ github.token }} - run: | - ref="refs/tags/${difyctlTag}" - sha="$(git rev-parse HEAD)" - status="$(gh api -X POST "repos/${GITHUB_REPOSITORY}/git/refs" \ - -f ref="$ref" -f sha="$sha" --silent --include 2>/dev/null \ - | awk 'NR==1 {print $2; exit}' || true)" - case "$status" in - 201) echo "::notice::created ${ref}" ;; - 422) echo "::notice::tag ${ref} already exists; skipping (immutable)" ;; - *) echo "::error::provenance tag ${ref} not created (HTTP ${status:-unknown})"; exit 1 ;; - esac diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml index 39fb7647177..9a3c59babc8 100644 --- a/.github/workflows/cli-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -44,7 +44,7 @@ jobs: - name: Validate release manifest if: matrix.os == 'depot-ubuntu-24.04' - run: scripts/release-validate-manifest.sh + run: node scripts/release-naming.mjs validate - name: CI pipeline (tree, coverage, build) run: pnpm run ci diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index c3953513449..20446914e6e 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -162,7 +162,7 @@ jobs: - name: Run Claude Code for Translation Sync if: steps.context.outputs.CHANGED_FILES != '' - uses: anthropics/claude-code-action@dcb57747bfceeaa1fa72638cae52295d1d853d4a # v1.0.199 + uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1.0.210 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/api/.importlinter b/api/.importlinter index f3609e5826a..7cf69c0c515 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -207,6 +207,7 @@ source_modules = services.account_avatar_service services.account_change_email_ports services.account_change_email_service + services.account_email_registration_service services.account_deletion_service services.account_deletion_feedback_service services.account_education_service diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index 8b9e8fb08c2..730dd7b1a57 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -1,3 +1,4 @@ +from typing import Literal from uuid import UUID from flask import abort, request @@ -6,6 +7,7 @@ from pydantic import AliasChoices, BaseModel, Field, field_validator from sqlalchemy import func, or_, select from sqlalchemy.orm import Session +from configs import dify_config from controllers.common.schema import ( query_params_from_model, query_params_from_request, @@ -76,16 +78,27 @@ from services.agent.observability_service import ( AgentStatisticsQueryParams, ) from services.agent.roster_service import AgentRosterService -from services.app_service import AppListParams, AppService, CreateAppParams +from services.app_service import AgentAppPublicationCounts, AppListParams, AppService, CreateAppParams +from services.enterprise import rbac_service as enterprise_rbac_service from services.enterprise.enterprise_service import EnterpriseService from services.entities.agent_entities import ComposerSavePayload, RosterListQuery from services.feature_service import FeatureService +from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task + +AgentPublicationStatus = Literal["published", "drafts"] class AgentInviteOptionsQuery(RosterListQuery): app_id: str | None = Field(default=None, description="Workflow app id for in-current-workflow markers") +class AgentAppListQuery(AppListQuery): + publication_status: AgentPublicationStatus | None = Field( + default=None, + description="Filter by published or draft Agent configuration status", + ) + + class AgentIdPath(BaseModel): agent_id: str @@ -299,7 +312,19 @@ class AgentSimpleResultResponse(BaseModel): result: str +class AgentPublicationCountsResponse(ResponseModel): + published: int = Field( + ge=0, + description="Published Agent Apps in the current list scope, excluding the publication status filter", + ) + drafts: int = Field( + ge=0, + description="Draft Agent Apps in the current list scope, excluding the publication status filter", + ) + + class AgentAppPagination(GenericAppPagination): + publication_counts: AgentPublicationCountsResponse data: list[AgentAppPartial] = Field( # type: ignore[assignment] # pyrefly: ignore[bad-override-mutable-attribute] validation_alias=AliasChoices("items", "data") ) @@ -314,6 +339,7 @@ register_schema_models( AgentBuildDraftCheckoutPayload, ComposerSavePayload, AgentApiStatusPayload, + AgentAppListQuery, AgentInviteOptionsQuery, AgentLogsQuery, AgentStatisticsQuery, @@ -323,6 +349,7 @@ register_schema_models( ) register_response_schema_models( console_ns, + AgentPublicationCountsResponse, AgentAppPagination, AgentApiAccessResponse, AgentAppPublishedReferenceResponse, @@ -408,7 +435,14 @@ def _serialize_agent_app_detail( return payload -def _serialize_agent_app_pagination(session: Session, app_pagination, *, tenant_id: str, current_user: Account) -> dict: +def _serialize_agent_app_pagination( + session: Session, + app_pagination, + *, + tenant_id: str, + current_user: Account, + publication_counts: AgentAppPublicationCounts, +) -> dict: """Serialize Agent App lists with roster-shaped items. Each item starts from the shared App list shape, then drops @@ -441,8 +475,17 @@ def _serialize_agent_app_pagination(session: Session, app_pagination, *, tenant_ account_id=current_user.id, ) payload = AgentAppPagination.model_validate( - app_pagination, - from_attributes=True, + { + "page": app_pagination.page, + "limit": app_pagination.per_page, + "total": app_pagination.total, + "has_more": app_pagination.has_next, + "data": app_pagination.items, + "publication_counts": { + "published": publication_counts.published, + "drafts": publication_counts.drafts, + }, + }, context={"session": session}, ).model_dump(mode="json") for item in payload["data"]: @@ -562,7 +605,7 @@ def _query_values(name: str, alias_name: str | None = None) -> list[str]: @console_ns.route("/agent") class AgentAppListApi(Resource): - @console_ns.doc(params=query_params_from_model(AppListQuery)) + @console_ns.doc(params=query_params_from_model(AgentAppListQuery)) @console_ns.response(200, "Agent app list", console_ns.models[AgentAppPagination.__name__]) @setup_required @login_required @@ -572,7 +615,9 @@ class AgentAppListApi(Resource): @with_current_tenant_id @with_session def get(self, session: Session, current_tenant_id: str, current_user: Account): - args = query_params_from_request(AppListQuery, list_fields=APP_LIST_QUERY_ARRAY_FIELDS) + args = query_params_from_request(AgentAppListQuery, list_fields=APP_LIST_QUERY_ARRAY_FIELDS) + agent_is_published = None if args.publication_status is None else args.publication_status == "published" + params = AppListParams( page=args.page, limit=args.limit, @@ -583,11 +628,29 @@ class AgentAppListApi(Resource): creator_ids=args.creator_ids, is_created_by_me=args.is_created_by_me, status="normal", + agent_is_published=agent_is_published, ) - app_pagination = AppService().get_paginate_apps(current_user.id, current_tenant_id, params, session) + app_service = AppService() + publication_counts = app_service.get_agent_publication_counts( + current_user.id, + current_tenant_id, + params, + session, + ) + app_pagination = app_service.get_paginate_apps(current_user.id, current_tenant_id, params, session) if app_pagination is None: - empty = AgentAppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) + empty = AgentAppPagination( + page=args.page, + limit=args.limit, + total=0, + has_more=False, + publication_counts=AgentPublicationCountsResponse( + published=publication_counts.published, + drafts=publication_counts.drafts, + ), + data=[], + ) return empty.model_dump(mode="json") return _serialize_agent_app_pagination( @@ -595,6 +658,7 @@ class AgentAppListApi(Resource): app_pagination, tenant_id=current_tenant_id, current_user=current_user, + publication_counts=publication_counts, ) @console_ns.expect(console_ns.models[AgentAppCreatePayload.__name__]) @@ -623,6 +687,15 @@ class AgentAppListApi(Resource): ) app = AppService().create_app(current_tenant_id, params, current_user, session=session) + if dify_config.RBAC_ENABLED: + enterprise_rbac_service.RBACService.AppAccess.replace_whitelist( + current_tenant_id, + current_user.id, + str(app.id), + enterprise_rbac_service.ReplaceMemberBindings(automatic_include_workspace_members=True), + ) + initialize_created_app_rbac_access_task.delay(current_tenant_id, current_user.id, app_id=app.id) + return _serialize_agent_app_detail(session, app, current_user=current_user), 201 diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index af1d9d6c971..358fd60c0fe 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -642,13 +642,13 @@ class AppListApi(Resource): ) permissions = enterprise_rbac_service.RBACService.MyPermissions.get( - str(current_tenant_id), + current_tenant_id, current_user_id, session=session, ) if dify_config.RBAC_ENABLED: access_filter = resolve_app_access_filter( - str(current_tenant_id), + current_tenant_id, current_user_id, session=session, permissions=permissions, @@ -675,7 +675,7 @@ class AppListApi(Resource): pagination_model = pagination_model.model_copy( update={ "data": [ - item.model_copy(update={"permission_keys": permission_keys_map.get(str(item.id), [])}) + item.model_copy(update={"permission_keys": permission_keys_map.get(item.id, [])}) for item in pagination_model.data ] } @@ -712,7 +712,7 @@ class AppListApi(Resource): app_service = AppService() app = app_service.create_app(current_tenant_id, params, current_user, session=session) permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get( - str(current_tenant_id), + current_tenant_id, current_user.id, [str(app.id)], session=session, @@ -882,7 +882,7 @@ class AppApi(Resource): app_model.access_mode = app_setting.access_mode permissions = enterprise_rbac_service.RBACService.MyPermissions.get( - str(current_tenant_id), + current_tenant_id, current_user.id, app_id=str(app_model.id), session=session, @@ -1020,7 +1020,7 @@ class AppCopyApi(Resource): raise NotFound("App not found") permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get( - str(current_tenant_id), + current_tenant_id, current_user.id, [str(app.id)], session=session, @@ -1088,7 +1088,7 @@ class AppPublishToCreatorsPlatformApi(Resource): # TODO: Move this configuration and OAuth orchestration into the Creators Platform application service # when that domain is refactored. This controller-level integration is a temporary compatibility bridge. oauth_code = None - client_id = str(dify_config.CREATORS_PLATFORM_OAUTH_CLIENT_ID or "") + client_id = dify_config.CREATORS_PLATFORM_OAUTH_CLIENT_ID or "" if client_id: authorization = application_services().oauth_server.issue_authorization_code( client_id=client_id, diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index cfa18235cd0..5f3982a65dc 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -16,6 +16,7 @@ from controllers.common.schema import register_response_schema_models, register_ from controllers.console import console_ns from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model from controllers.console.app.error import ( + AgentSessionConfigurationChangedError, AppUnavailableError, CompletionRequestError, ConversationCompletedError, @@ -620,6 +621,10 @@ def _raise_agent_stream_error_before_response(response): if isinstance(response, _ClosableStream): response.close() message = error_payload.get("message") + if error_payload.get("code") == AgentSessionConfigurationChangedError.error_code: + raise AgentSessionConfigurationChangedError( + str(message or AgentSessionConfigurationChangedError.description) + ) raise CompletionRequestError(str(message or "Agent App chat failed.")) return _prepend_stream_chunks(buffered, chunk, iterator) diff --git a/api/controllers/console/app/error.py b/api/controllers/console/app/error.py index 1bb6fafb224..2a84336596e 100644 --- a/api/controllers/console/app/error.py +++ b/api/controllers/console/app/error.py @@ -1,3 +1,7 @@ +from core.app.apps.agent_app.errors import ( + AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE, + AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE, +) from libs.exception import BaseHTTPException @@ -49,6 +53,12 @@ class CompletionRequestError(BaseHTTPException): code = 400 +class AgentSessionConfigurationChangedError(BaseHTTPException): + error_code = AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE + description = AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE + code = 409 + + class AppMoreLikeThisDisabledError(BaseHTTPException): error_code = "app_more_like_this_disabled" description = "The 'More like this' feature is disabled. Please refresh your page." diff --git a/api/controllers/console/app/generator.py b/api/controllers/console/app/generator.py index 6fba79cfcbb..d29f8a1911b 100644 --- a/api/controllers/console/app/generator.py +++ b/api/controllers/console/app/generator.py @@ -412,6 +412,7 @@ class InstructionGenerateApi(Resource): model_config=req_data.model_config_data, ideal_output=req_data.ideal_output, workflow_service=WorkflowService(), + session=session, ) return {"error": "incompatible parameters"}, 400 except ProviderTokenNotInitError as ex: diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 4b9ff67c0e8..c7e51f663a4 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -44,6 +44,7 @@ from controllers.console.wraps import ( RBACResourceScope, account_initialization_required, edit_permission_required, + model_validate, rbac_permission_required, setup_required, with_current_tenant_id, @@ -352,7 +353,7 @@ class WorkflowResponse(ResponseModel): return [_serialize_environment_variable(item) for item in value] -class _WorkflowResponseSource: +class WorkflowResponseSource: def __init__(self, workflow: Workflow, *, session: Session) -> None: self._workflow = workflow self._session = session @@ -589,7 +590,8 @@ class DraftWorkflowApi(Resource): """ # fetch draft workflow by app_model workflow_service = WorkflowService() - workflow = workflow_service.get_draft_workflow(app_model=app_model, session=db.session()) + session = db.session() + workflow = workflow_service.get_draft_workflow(app_model=app_model, session=session) if not workflow: raise DraftWorkflowNotExist() @@ -598,9 +600,11 @@ class DraftWorkflowApi(Resource): # Return workflow with response-only Agent node job projection so the # front-end can treat draft graph node data as the editing source. - response = WorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json") + response = WorkflowResponse.model_validate( + WorkflowResponseSource(workflow, session=session), from_attributes=True + ).model_dump(mode="json") response["graph"] = WorkflowAgentPublishService.project_draft_bindings_to_graph( - session=db.session(), + session=session, draft_workflow=workflow, ) return response @@ -705,12 +709,12 @@ class AdvancedChatDraftWorkflowRunApi(Resource): @edit_permission_required @with_session @get_app_model(mode=[AppMode.ADVANCED_CHAT]) - def post(self, session: Session, current_user: Account, app_model: App): + @model_validate(AdvancedChatWorkflowRunPayload) + def post(self, payload: AdvancedChatWorkflowRunPayload, session: Session, current_user: Account, app_model: App): """ Run draft workflow """ - args_model = AdvancedChatWorkflowRunPayload.model_validate(console_ns.payload or {}) - args = args_model.model_dump(exclude_none=True) + args = payload.model_dump(exclude_none=True) external_trace_id = get_external_trace_id(request) if external_trace_id: @@ -760,11 +764,12 @@ class AdvancedChatDraftRunIterationNodeApi(Resource): @get_app_model(mode=[AppMode.ADVANCED_CHAT]) @with_current_user @edit_permission_required - def post(self, current_user: Account, app_model: App, node_id: str): + @model_validate(IterationNodeRunPayload) + def post(self, payload: IterationNodeRunPayload, current_user: Account, app_model: App, node_id: str): """ Run draft workflow iteration node """ - args = IterationNodeRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True) + args = payload.model_dump(exclude_none=True) try: response = AppGenerateService.generate_single_iteration( @@ -808,11 +813,12 @@ class WorkflowDraftRunIterationNodeApi(Resource): @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required - def post(self, current_user: Account, app_model: App, node_id: str): + @model_validate(IterationNodeRunPayload) + def post(self, payload: IterationNodeRunPayload, current_user: Account, app_model: App, node_id: str): """ Run draft workflow iteration node """ - args = IterationNodeRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True) + args = payload.model_dump(exclude_none=True) try: response = AppGenerateService.generate_single_iteration( @@ -852,11 +858,11 @@ class AdvancedChatDraftRunLoopNodeApi(Resource): @get_app_model(mode=[AppMode.ADVANCED_CHAT]) @with_current_user @edit_permission_required - def post(self, current_user: Account, app_model: App, node_id: str): + @model_validate(LoopNodeRunPayload) + def post(self, args: LoopNodeRunPayload, current_user: Account, app_model: App, node_id: str): """ Run draft workflow loop node """ - args = LoopNodeRunPayload.model_validate(console_ns.payload or {}) try: response = AppGenerateService.generate_single_loop( @@ -900,11 +906,11 @@ class WorkflowDraftRunLoopNodeApi(Resource): @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required - def post(self, current_user: Account, app_model: App, node_id: str): + @model_validate(LoopNodeRunPayload) + def post(self, args: LoopNodeRunPayload, current_user: Account, app_model: App, node_id: str): """ Run draft workflow loop node """ - args = LoopNodeRunPayload.model_validate(console_ns.payload or {}) try: response = AppGenerateService.generate_single_loop( @@ -977,11 +983,11 @@ class AdvancedChatDraftHumanInputFormPreviewApi(Resource): @with_current_user @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) - def post(self, current_user: Account, app_model: App, node_id: str): + @model_validate(HumanInputFormPreviewPayload) + def post(self, args: HumanInputFormPreviewPayload, current_user: Account, app_model: App, node_id: str): """ Preview human input form content and placeholders """ - args = HumanInputFormPreviewPayload.model_validate(console_ns.payload or {}) inputs = args.inputs workflow_service = WorkflowService() @@ -1013,11 +1019,11 @@ class AdvancedChatDraftHumanInputFormRunApi(Resource): @get_app_model(mode=[AppMode.ADVANCED_CHAT]) @with_current_user @edit_permission_required - def post(self, current_user: Account, app_model: App, node_id: str): + @model_validate(HumanInputFormSubmitPayload) + def post(self, args: HumanInputFormSubmitPayload, current_user: Account, app_model: App, node_id: str): """ Submit human input form preview """ - args = HumanInputFormSubmitPayload.model_validate(console_ns.payload or {}) workflow_service = WorkflowService() result = workflow_service.submit_human_input_form_preview( app_model=app_model, @@ -1045,11 +1051,11 @@ class WorkflowDraftHumanInputFormPreviewApi(Resource): @with_current_user @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) - def post(self, current_user: Account, app_model: App, node_id: str): + @model_validate(HumanInputFormPreviewPayload) + def post(self, args: HumanInputFormPreviewPayload, current_user: Account, app_model: App, node_id: str): """ Preview human input form content and placeholders """ - args = HumanInputFormPreviewPayload.model_validate(console_ns.payload or {}) inputs = args.inputs workflow_service = WorkflowService() @@ -1081,12 +1087,12 @@ class WorkflowDraftHumanInputFormRunApi(Resource): @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required - def post(self, current_user: Account, app_model: App, node_id: str): + @model_validate(HumanInputFormSubmitPayload) + def post(self, args: HumanInputFormSubmitPayload, current_user: Account, app_model: App, node_id: str): """ Submit human input form preview """ workflow_service = WorkflowService() - args = HumanInputFormSubmitPayload.model_validate(console_ns.payload or {}) result = workflow_service.submit_human_input_form_preview( app_model=app_model, account=current_user, @@ -1113,12 +1119,12 @@ class WorkflowDraftHumanInputDeliveryTestApi(Resource): @with_current_user @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) - def post(self, current_user: Account, app_model: App, node_id: str): + @model_validate(HumanInputDeliveryTestPayload) + def post(self, args: HumanInputDeliveryTestPayload, current_user: Account, app_model: App, node_id: str): """ Test human input delivery """ workflow_service = WorkflowService() - args = HumanInputDeliveryTestPayload.model_validate(console_ns.payload or {}) workflow_service.test_human_input_delivery( app_model=app_model, account=current_user, @@ -1150,11 +1156,12 @@ class DraftWorkflowRunApi(Resource): @edit_permission_required @with_session @get_app_model(mode=[AppMode.WORKFLOW]) - def post(self, session: Session, current_user: Account, app_model: App): + @model_validate(DraftWorkflowRunPayload) + def post(self, payload: DraftWorkflowRunPayload, session: Session, current_user: Account, app_model: App): """ Run draft workflow """ - args = DraftWorkflowRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True) + args = payload.model_dump(exclude_none=True) external_trace_id = get_external_trace_id(request) if external_trace_id: @@ -1223,14 +1230,14 @@ class DraftWorkflowNodeRunApi(Resource): @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @with_current_user @edit_permission_required - def post(self, current_user: Account, app_model: App, node_id: str): + @model_validate(DraftWorkflowNodeRunPayload) + def post(self, payload: DraftWorkflowNodeRunPayload, current_user: Account, app_model: App, node_id: str): """ Run draft workflow node """ - args_model = DraftWorkflowNodeRunPayload.model_validate(console_ns.payload or {}) - args = args_model.model_dump(exclude_none=True) + args = payload.model_dump(exclude_none=True) - user_inputs = args_model.inputs + user_inputs = payload.inputs if user_inputs is None: raise ValueError("missing inputs") @@ -1279,13 +1286,14 @@ class PublishedWorkflowApi(Resource): """ # fetch published workflow by app_model workflow_service = WorkflowService() - workflow = workflow_service.get_published_workflow(app_model=app_model, session=db.session()) + session = db.session() + workflow = workflow_service.get_published_workflow(app_model=app_model, session=session) # return workflow, if not found, return None if workflow is None: return None - return dump_response(WorkflowResponse, workflow) + return dump_response(WorkflowResponse, WorkflowResponseSource(workflow, session=session)) @console_ns.expect(console_ns.models[PublishWorkflowPayload.__name__]) @console_ns.response(200, "Workflow published successfully", console_ns.models[WorkflowPublishResponse.__name__]) @@ -1296,13 +1304,12 @@ class PublishedWorkflowApi(Resource): @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @with_current_user @edit_permission_required - def post(self, current_user: Account, app_model: App): + @model_validate(PublishWorkflowPayload) + def post(self, args: PublishWorkflowPayload, current_user: Account, app_model: App): """ Publish workflow """ - args = PublishWorkflowPayload.model_validate(console_ns.payload or {}) - workflow_service = WorkflowService() with sessionmaker(db.engine).begin() as session: workflow = workflow_service.publish_workflow( @@ -1371,11 +1378,11 @@ class DefaultBlockConfigApi(Resource): @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - def get(self, app_model: App, block_type: str): + @model_validate(DefaultBlockConfigQuery) + def get(self, args: DefaultBlockConfigQuery, app_model: App, block_type: str): """ Get default block config """ - args = DefaultBlockConfigQuery.model_validate(request.args.to_dict(flat=True)) filters = None if args.q: @@ -1410,14 +1417,14 @@ class ConvertToWorkflowApi(Resource): @with_current_tenant_id @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) - def post(self, current_tenant_id: str, current_user: Account, app_model: App): + @model_validate(ConvertToWorkflowPayload) + def post(self, payload: ConvertToWorkflowPayload, current_tenant_id: str, current_user: Account, app_model: App): """ Convert basic mode of chatbot app to workflow mode Convert expert mode of chatbot app to workflow mode Convert Completion App to Workflow App """ - payload = console_ns.payload or {} - args = ConvertToWorkflowPayload.model_validate(payload).model_dump(exclude_none=True) + args = payload.model_dump(exclude_none=True) # convert to workflow mode workflow_service = WorkflowService() @@ -1452,9 +1459,8 @@ class WorkflowFeaturesApi(Resource): @with_current_user @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) - def post(self, current_user: Account, app_model: App): - - args = WorkflowFeaturesPayload.model_validate(console_ns.payload or {}) + @model_validate(WorkflowFeaturesPayload) + def post(self, args: WorkflowFeaturesPayload, current_user: Account, app_model: App): features = args.features.model_dump(mode="json", exclude_unset=True) workflow_service = WorkflowService() @@ -1483,12 +1489,12 @@ class PublishedAllWorkflowApi(Resource): @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @with_current_user @edit_permission_required - def get(self, current_user: Account, app_model: App): + @model_validate(WorkflowListQuery) + def get(self, args: WorkflowListQuery, current_user: Account, app_model: App): """ Get published workflows """ - args = WorkflowListQuery.model_validate(request.args.to_dict(flat=True)) page = args.page limit = args.limit user_id = args.user_id @@ -1510,7 +1516,7 @@ class PublishedAllWorkflowApi(Resource): ) return WorkflowPaginationResponse.model_validate( { - "items": [_WorkflowResponseSource(workflow, session=session) for workflow in workflows], + "items": [WorkflowResponseSource(workflow, session=session) for workflow in workflows], "page": page, "limit": limit, "has_more": has_more, @@ -1573,11 +1579,11 @@ class WorkflowByIdApi(Resource): @with_current_user @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) - def patch(self, current_user: Account, app_model: App, workflow_id: str): + @model_validate(WorkflowUpdatePayload) + def patch(self, args: WorkflowUpdatePayload, current_user: Account, app_model: App, workflow_id: str): """ Update workflow attributes """ - args = WorkflowUpdatePayload.model_validate(console_ns.payload or {}) # Prepare update data update_data = {} @@ -1604,7 +1610,7 @@ class WorkflowByIdApi(Resource): if not workflow: raise NotFound("Workflow not found") - response = dump_response(WorkflowResponse, _WorkflowResponseSource(workflow, session=session)) + response = dump_response(WorkflowResponse, WorkflowResponseSource(workflow, session=session)) return response @@ -1710,11 +1716,11 @@ class DraftWorkflowTriggerRunApi(Resource): @edit_permission_required @with_session @get_app_model(mode=[AppMode.WORKFLOW]) - def post(self, session: Session, current_user: Account, app_model: App): + @model_validate(DraftWorkflowTriggerRunPayload) + def post(self, args: DraftWorkflowTriggerRunPayload, session: Session, current_user: Account, app_model: App): """ Poll for trigger events and execute full workflow when event arrives """ - args = DraftWorkflowTriggerRunPayload.model_validate(console_ns.payload or {}) node_id = args.node_id workflow_service = WorkflowService() draft_workflow = workflow_service.get_draft_workflow(app_model, session=session) @@ -1862,12 +1868,12 @@ class DraftWorkflowTriggerRunAllApi(Resource): @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @with_session @get_app_model(mode=[AppMode.WORKFLOW]) - def post(self, session: Session, current_user: Account, app_model: App): + @model_validate(DraftWorkflowTriggerRunAllPayload) + def post(self, args: DraftWorkflowTriggerRunAllPayload, session: Session, current_user: Account, app_model: App): """ Full workflow debug when the start node is a trigger """ - args = DraftWorkflowTriggerRunAllPayload.model_validate(console_ns.payload or {}) node_ids = args.node_ids workflow_service = WorkflowService() draft_workflow = workflow_service.get_draft_workflow(app_model, session=session) @@ -1929,9 +1935,8 @@ class WorkflowOnlineUsersApi(Resource): @account_initialization_required @with_current_user @with_current_tenant_id - def post(self, current_tenant_id: str, current_user: Account): - args = WorkflowOnlineUsersPayload.model_validate(console_ns.payload or {}) - + @model_validate(WorkflowOnlineUsersPayload) + def post(self, args: WorkflowOnlineUsersPayload, current_tenant_id: str, current_user: Account): app_ids = args.app_ids if len(app_ids) > MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS: raise BadRequest(f"Maximum {MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS} app_ids are allowed per request.") diff --git a/api/controllers/console/auth/email_register.py b/api/controllers/console/auth/email_register.py index e81acbed99d..e0bfddccfa0 100644 --- a/api/controllers/console/auth/email_register.py +++ b/api/controllers/console/auth/email_register.py @@ -2,8 +2,6 @@ from flask import request from flask_restx import Resource from pydantic import BaseModel, Field, field_validator -from configs import dify_config -from constants.languages import get_valid_language, languages from controllers.common.fields import SimpleResultDataResponse, VerificationTokenResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns @@ -11,31 +9,35 @@ from controllers.console.auth.error import ( EmailAlreadyInUseError, EmailCodeError, EmailRegisterLimitError, + EmailRegisterRateLimitExceededError, InvalidEmailError, InvalidTokenError, NormalizedEmailAlreadyInUseError, PasswordMismatchError, ) -from enums import DeploymentEdition -from extensions.ext_database import db +from controllers.console.flask_admission import console_email_registration_admission +from controllers.console.wraps import model_validate +from extensions.ext_application_services import application_services from fields.base import ResponseModel -from libs.helper import EmailStr, extract_remote_ip +from libs.helper import EmailStr, dump_response, extract_remote_ip from libs.helper import timezone as validate_timezone_string from libs.password import valid_password -from models import Account -from services.account_service import AccountService -from services.billing_service import BillingService -from services.errors.account import ( +from services.account_errors import ( + AccountEmailAlreadyInUseError, + AccountEmailDomainSuspendedError, + AccountEmailFrozenError, AccountNormalizedEmailAlreadyInUseError, - AccountRegisterError, - SeatsLimitExceededError, -) -from services.errors.account import ( - EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError, + EmailRegistrationPasswordMismatchError, + EmailRegistrationSeatsLimitError, + EmailRegistrationSendIPLimitedError, + EmailRegistrationSendRateLimitError, + EmailRegistrationVerificationLimitError, + InvalidEmailRegistrationAddressError, + InvalidEmailRegistrationCodeError, + InvalidEmailRegistrationTokenError, ) from ..error import AccountInFreezeError, EmailDomainSuspendedError, EmailSendIpLimitError, SeatsLimitExceeded -from ..wraps import email_password_login_enabled, email_register_enabled, model_validate, setup_required class EmailRegisterSendPayload(BaseModel): @@ -91,146 +93,91 @@ register_response_schema_models( @console_ns.route("/email-register/send-email") class EmailRegisterSendEmailApi(Resource): - @setup_required - @email_password_login_enabled - @email_register_enabled @console_ns.expect(console_ns.models[EmailRegisterSendPayload.__name__]) @console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__]) + @console_email_registration_admission @model_validate(EmailRegisterSendPayload) - def post(self, req_data: EmailRegisterSendPayload): - normalized_email = req_data.email.lower() - - ip_address = extract_remote_ip(request) - if AccountService.is_email_send_ip_limit(ip_address): - raise EmailSendIpLimitError() - language = "en-US" - if req_data.language is not None and req_data.language in languages: - language = req_data.language - - if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: - freeze_type = BillingService.get_email_freeze_type(normalized_email) - if freeze_type: - if freeze_type == "email_domain_suspended": - raise EmailDomainSuspendedError() - raise AccountInFreezeError() - - account = AccountService.get_account_by_email_with_case_fallback(req_data.email, session=db.session()) - token = AccountService.send_email_register_email(email=normalized_email, account=account, language=language) - return {"result": "success", "data": token} + def post(self, args: EmailRegisterSendPayload): + try: + token = application_services().accounts.email_registration.send_code( + remote_ip=extract_remote_ip(request), + requested_email=args.email, + requested_language=args.language, + ) + except EmailRegistrationSendIPLimitedError: + raise EmailSendIpLimitError() from None + except EmailRegistrationSendRateLimitError as error: + raise EmailRegisterRateLimitExceededError(error.retry_after_minutes) from None + except AccountEmailDomainSuspendedError: + raise EmailDomainSuspendedError() from None + except AccountEmailFrozenError: + raise AccountInFreezeError() from None + return dump_response(SimpleResultDataResponse, {"result": "success", "data": token}) @console_ns.route("/email-register/validity") class EmailRegisterCheckApi(Resource): - @setup_required - @email_password_login_enabled - @email_register_enabled @console_ns.expect(console_ns.models[EmailRegisterValidityPayload.__name__]) @console_ns.response(200, "Success", console_ns.models[VerificationTokenResponse.__name__]) + @console_email_registration_admission @model_validate(EmailRegisterValidityPayload) - def post(self, req_data: EmailRegisterValidityPayload): - - user_email = req_data.email.lower() - - is_email_register_error_rate_limit = AccountService.is_email_register_error_rate_limit(user_email) - if is_email_register_error_rate_limit: - raise EmailRegisterLimitError() - - token_data = AccountService.get_email_register_data(req_data.token) - if token_data is None: - raise InvalidTokenError() - - token_email = token_data.get("email") - normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email - - if user_email != normalized_token_email: - raise InvalidEmailError() - - if req_data.code != token_data.get("code"): - AccountService.add_email_register_error_rate_limit(user_email) - raise EmailCodeError() - - # Verified, revoke the first token - AccountService.revoke_email_register_token(req_data.token) - - # Refresh token data by generating a new token - _, new_token = AccountService.generate_email_register_token( - user_email, code=req_data.code, additional_data={"phase": "register"} + def post(self, args: EmailRegisterValidityPayload): + try: + verification = application_services().accounts.email_registration.verify_code( + email=args.email, + code=args.code, + token=args.token, + ) + except EmailRegistrationVerificationLimitError: + raise EmailRegisterLimitError() from None + except InvalidEmailRegistrationTokenError: + raise InvalidTokenError() from None + except InvalidEmailRegistrationAddressError: + raise InvalidEmailError() from None + except InvalidEmailRegistrationCodeError: + raise EmailCodeError() from None + return dump_response( + VerificationTokenResponse, + { + "is_valid": True, + "email": verification.email, + "token": verification.token, + }, ) - AccountService.reset_email_register_error_rate_limit(user_email) - return {"is_valid": True, "email": normalized_token_email, "token": new_token} - @console_ns.route("/email-register") class EmailRegisterResetApi(Resource): - @setup_required - @email_password_login_enabled - @email_register_enabled @console_ns.expect(console_ns.models[EmailRegisterResetPayload.__name__]) @console_ns.response(200, "Success", console_ns.models[EmailRegisterResetResponse.__name__]) + @console_email_registration_admission @model_validate(EmailRegisterResetPayload) - def post(self, req_data: EmailRegisterResetPayload): - - # Validate passwords match - if req_data.new_password != req_data.password_confirm: - raise PasswordMismatchError() - - # Validate token and get register data - register_data = AccountService.get_email_register_data(req_data.token) - if not register_data: - raise InvalidTokenError() - # Must use token in reset phase - if register_data.get("phase", "") != "register": - raise InvalidTokenError() - - # Revoke token to prevent reuse - AccountService.revoke_email_register_token(req_data.token) - - email = register_data.get("email", "") - normalized_email = email.lower() - - account = AccountService.get_account_by_email_with_case_fallback(email, session=db.session()) - - 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=ip_address) - AccountService.reset_login_error_rate_limit(normalized_email) - - return {"result": "success", "data": token_pair.model_dump()} - - def _create_new_account( - self, - email: str, - password: str, - timezone: str | None = None, - language: str | None = None, - ip_address: str | None = None, - ) -> Account: + def post(self, args: EmailRegisterResetPayload): try: - return AccountService.create_account_and_tenant( - email=email, - name=email, - password=password, - interface_language=get_valid_language(language), - timezone=timezone, - ip_address=ip_address, - check_normalized_email=True, - session=db.session(), + token_pair = application_services().accounts.email_registration.register( + remote_ip=extract_remote_ip(request), + token=args.token, + new_password=args.new_password, + password_confirm=args.password_confirm, + language=args.language, + timezone=args.timezone, ) - except SeatsLimitExceededError: - raise SeatsLimitExceeded() - except EmailDomainSuspendedRegistrationError as exc: - raise EmailDomainSuspendedError() from exc - except AccountNormalizedEmailAlreadyInUseError as exc: - raise NormalizedEmailAlreadyInUseError() from exc - except AccountRegisterError as exc: - raise AccountInFreezeError() from exc + except EmailRegistrationPasswordMismatchError: + raise PasswordMismatchError() from None + except InvalidEmailRegistrationTokenError: + raise InvalidTokenError() from None + except AccountNormalizedEmailAlreadyInUseError: + raise NormalizedEmailAlreadyInUseError() from None + except AccountEmailAlreadyInUseError: + raise EmailAlreadyInUseError() from None + except EmailRegistrationSeatsLimitError: + raise SeatsLimitExceeded() from None + except AccountEmailDomainSuspendedError: + raise EmailDomainSuspendedError() from None + except AccountEmailFrozenError: + raise AccountInFreezeError() from None + + return dump_response( + EmailRegisterResetResponse, + {"result": "success", "data": token_pair}, + ) diff --git a/api/controllers/console/auth/error.py b/api/controllers/console/auth/error.py index daf7b344bee..af34860d5ec 100644 --- a/api/controllers/console/auth/error.py +++ b/api/controllers/console/auth/error.py @@ -55,7 +55,7 @@ class PasswordResetRateLimitExceededError(BaseHTTPException): code = 429 def __init__(self, minutes: int = 1): - description = self.description.format(minutes=int(minutes)) if self.description else None + description = self.description.format(minutes=minutes) if self.description else None super().__init__(description=description) @@ -65,7 +65,7 @@ class EmailRegisterRateLimitExceededError(BaseHTTPException): code = 429 def __init__(self, minutes: int = 1): - description = self.description.format(minutes=int(minutes)) if self.description else None + description = self.description.format(minutes=minutes) if self.description else None super().__init__(description=description) @@ -75,7 +75,7 @@ class EmailChangeRateLimitExceededError(BaseHTTPException): code = 429 def __init__(self, minutes: int = 1): - description = self.description.format(minutes=int(minutes)) if self.description else None + description = self.description.format(minutes=minutes) if self.description else None super().__init__(description=description) @@ -85,7 +85,7 @@ class OwnerTransferRateLimitExceededError(BaseHTTPException): code = 429 def __init__(self, minutes: int = 1): - description = self.description.format(minutes=int(minutes)) if self.description else None + description = self.description.format(minutes=minutes) if self.description else None super().__init__(description=description) @@ -137,7 +137,7 @@ class EmailCodeLoginRateLimitExceededError(BaseHTTPException): code = 429 def __init__(self, minutes: int = 5): - description = self.description.format(minutes=int(minutes)) if self.description else None + description = self.description.format(minutes=minutes) if self.description else None super().__init__(description=description) @@ -147,7 +147,7 @@ class EmailCodeAccountDeletionRateLimitExceededError(BaseHTTPException): code = 429 def __init__(self, minutes: int = 5): - description = self.description.format(minutes=int(minutes)) if self.description else None + description = self.description.format(minutes=minutes) if self.description else None super().__init__(description=description) diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py index 7c5e0cff893..432e83cc099 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py @@ -26,6 +26,7 @@ from controllers.console.app.workflow import ( DefaultBlockConfigsResponse, WorkflowPaginationResponse, WorkflowResponse, + WorkflowResponseSource, ) from controllers.console.app.wraps import with_session from controllers.console.datasets.wraps import get_rag_pipeline, load_rag_pipeline @@ -202,14 +203,15 @@ class DraftRagPipelineApi(Resource): Get draft rag pipeline's workflow """ # fetch draft workflow by app_model - rag_pipeline_service = RagPipelineService(db.session()) + session = db.session() + rag_pipeline_service = RagPipelineService(session) workflow = rag_pipeline_service.get_draft_workflow(pipeline=pipeline) if not workflow: raise DraftWorkflowNotExist() # return workflow, if not found, return 404 - return dump_response(WorkflowResponse, workflow) + return dump_response(WorkflowResponse, WorkflowResponseSource(workflow, session=session)) @setup_required @login_required @@ -548,14 +550,15 @@ class PublishedRagPipelineApi(Resource): if not pipeline.is_published: return None # fetch published workflow by pipeline - rag_pipeline_service = RagPipelineService(db.session()) + session = db.session() + rag_pipeline_service = RagPipelineService(session) workflow = rag_pipeline_service.get_published_workflow(pipeline=pipeline) # return workflow, if not found, return None if workflow is None: return None - return dump_response(WorkflowResponse, workflow) + return dump_response(WorkflowResponse, WorkflowResponseSource(workflow, session=session)) @console_ns.response(200, "Success", console_ns.models[RagPipelineWorkflowPublishResponse.__name__]) @setup_required @@ -684,7 +687,7 @@ class PublishedAllRagPipelineApi(Resource): return WorkflowPaginationResponse.model_validate( { - "items": workflows, + "items": [WorkflowResponseSource(workflow, session=session) for workflow in workflows], "page": page, "limit": limit, "has_more": has_more, @@ -763,7 +766,7 @@ class RagPipelineByIdApi(Resource): if not workflow: raise NotFound("Workflow not found") - return dump_response(WorkflowResponse, workflow) + return dump_response(WorkflowResponse, WorkflowResponseSource(workflow, session=session)) @console_ns.response(204, "Workflow deleted successfully") @setup_required diff --git a/api/controllers/console/flask_admission.py b/api/controllers/console/flask_admission.py index 5eafc9c4741..eb300128aed 100644 --- a/api/controllers/console/flask_admission.py +++ b/api/controllers/console/flask_admission.py @@ -22,6 +22,22 @@ from libs.login import current_account_with_tenant, login_required from machinery.context import RequestContext from machinery.errors import AdmissionConfigurationError from models.account import TenantAccountRole +from services.feature_service import FeatureService + + +def console_email_registration_admission[T, **P, R]( + view: Callable[Concatenate[T, P], R], +) -> Callable[Concatenate[T, P], R | Response]: + """Apply the complete admission policy for anonymous email registration.""" + + @wraps(view) + def check_registration_features(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R: + features = FeatureService.get_system_features() + if not features.enable_email_password_login or not features.is_allow_register: + abort(403) + return view(self, *args, **kwargs) + + return setup_required(check_registration_features) def console_account_admission[T, **P, R]( diff --git a/api/controllers/console/notification.py b/api/controllers/console/notification.py index 3e58f598bf7..080080bb361 100644 --- a/api/controllers/console/notification.py +++ b/api/controllers/console/notification.py @@ -1,56 +1,16 @@ -from collections.abc import Mapping -from typing import TypedDict - from flask_restx import Resource from pydantic import BaseModel, Field from controllers.common.fields import SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns -from controllers.console.wraps import ( - account_initialization_required, - model_validate, - only_edition_cloud, - setup_required, - with_current_user, -) +from controllers.console.flask_admission import console_account_admission +from controllers.console.wraps import model_validate +from enums import DeploymentEdition +from extensions.ext_application_services import application_services from fields.base import ResponseModel -from libs.login import login_required -from models import Account -from services.billing_service import BillingService - -# Notification content is stored under three lang tags. -_FALLBACK_LANG = "en-US" - - -class NotificationLangContent(TypedDict, total=False): - lang: str - title: str - subtitle: str - body: str - titlePicUrl: str - - -class NotificationItemDict(TypedDict): - notification_id: str | None - frequency: str | None - lang: str - title: str - subtitle: str - body: str - title_pic_url: str - - -class NotificationResponseDict(TypedDict): - should_show: bool - notifications: list[NotificationItemDict] - - -def _pick_lang_content(contents: Mapping[str, NotificationLangContent], lang: str) -> NotificationLangContent: - """Return the single LangContent for *lang*, falling back to English.""" - return ( - contents.get(lang) or contents.get(_FALLBACK_LANG) or next(iter(contents.values()), NotificationLangContent()) - ) +from libs.helper import dump_response +from machinery.context import RequestContext class DismissNotificationPayload(BaseModel): @@ -92,39 +52,10 @@ class NotificationApi(Resource): }, ) @console_ns.response(200, "Success", console_ns.models[NotificationResponse.__name__]) - @setup_required - @login_required - @with_current_user - @account_initialization_required - @only_edition_cloud - def get(self, current_user: Account): - result = BillingService.get_account_notification(str(current_user.id)) - - # Proto JSON uses camelCase field names (Kratos default marshaling). - response: NotificationResponseDict - if not result.get("shouldShow"): - response = {"should_show": False, "notifications": []} - return response, 200 - - lang = current_user.interface_language or _FALLBACK_LANG - - notifications: list[NotificationItemDict] = [] - for notification in result.get("notifications") or []: - contents: Mapping[str, NotificationLangContent] = notification.get("contents") or {} - lang_content = _pick_lang_content(contents, lang) - item: NotificationItemDict = { - "notification_id": notification.get("notificationId"), - "frequency": notification.get("frequency"), - "lang": lang_content.get("lang", lang), - "title": lang_content.get("title", ""), - "subtitle": lang_content.get("subtitle", ""), - "body": lang_content.get("body", ""), - "title_pic_url": lang_content.get("titlePicUrl", ""), - } - notifications.append(item) - - response = {"should_show": bool(notifications), "notifications": notifications} - return response, 200 + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + def get(self, request_context: RequestContext): + result = application_services().notifications.get_active(request_context) + return dump_response(NotificationResponse, result), 200 @console_ns.route("/notification/dismiss") @@ -134,17 +65,10 @@ class NotificationDismissApi(Resource): description="Mark a notification as dismissed for the current user.", responses={200: "Success", 401: "Unauthorized"}, ) - @setup_required - @login_required - @with_current_user - @account_initialization_required - @only_edition_cloud + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) @console_ns.expect(console_ns.models[DismissNotificationPayload.__name__]) @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @model_validate(DismissNotificationPayload) - def post(self, payload: DismissNotificationPayload, current_user: Account): - BillingService.dismiss_notification( - notification_id=payload.notification_id, - account_id=str(current_user.id), - ) - return {"result": "success"}, 200 + def post(self, payload: DismissNotificationPayload, request_context: RequestContext): + application_services().notifications.dismiss(request_context, payload.notification_id) + return dump_response(SimpleResultResponse, {"result": "success"}), 200 diff --git a/api/controllers/console/onboarding.py b/api/controllers/console/onboarding.py index f26e2d539e4..cbd77752e7b 100644 --- a/api/controllers/console/onboarding.py +++ b/api/controllers/console/onboarding.py @@ -7,36 +7,20 @@ action-based so callers do not replace server-side arrays with stale snapshots. """ from datetime import datetime -from typing import Literal, cast from flask_restx import Resource from pydantic import BaseModel, ConfigDict, Field, model_validator from controllers.common.schema import register_response_schema_models, register_schema_models -from extensions.ext_database import db +from controllers.console.flask_admission import console_account_admission +from controllers.console.wraps import model_validate +from extensions.ext_application_services import application_services from fields.base import ResponseModel from libs.helper import dump_response -from libs.login import login_required -from models import Account -from services.step_by_step_tour_service import StepByStepTourPatch, StepByStepTourService +from machinery.context import RequestContext +from services.entities.onboarding_entities import StepByStepTourAction, StepByStepTourPatch, StepByStepTourTaskId from . import console_ns -from .wraps import ( - account_initialization_required, - model_validate, - setup_required, - with_current_tenant_id, - with_current_user, -) - -StepByStepTourAction = Literal[ - "skip", - "complete_task", - "uncomplete_task", - "enable_current_workspace", - "disable_current_workspace", -] -StepByStepTourTaskId = Literal["home", "studio", "knowledge", "integration"] class StepByStepTourStatePatchPayload(BaseModel): @@ -74,39 +58,22 @@ class StepByStepTourStateApi(Resource): @console_ns.doc("get_step_by_step_tour_state") @console_ns.doc(description="Get account-level Step-by-step Tour state") @console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @with_current_user - @with_current_tenant_id - def get(self, current_tenant_id: str, current_user: Account): + @console_account_admission() + def get(self, request_context: RequestContext): return dump_response( StepByStepTourStateResponse, - StepByStepTourService.get_state( - account=current_user, - current_tenant_id=current_tenant_id, - session=db.session, - ), + application_services().step_by_step_tour.get_state(request_context), ) @console_ns.doc("patch_step_by_step_tour_state") @console_ns.doc(description="Update account-level Step-by-step Tour state") @console_ns.expect(console_ns.models[StepByStepTourStatePatchPayload.__name__]) @console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @with_current_user - @with_current_tenant_id + @console_account_admission() @model_validate(StepByStepTourStatePatchPayload) - def patch(self, req_data: StepByStepTourStatePatchPayload, current_tenant_id: str, current_user: Account): - patch = cast(StepByStepTourPatch, req_data.model_dump(exclude_unset=True, exclude_none=True)) + def patch(self, req_data: StepByStepTourStatePatchPayload, request_context: RequestContext): + patch = StepByStepTourPatch(action=req_data.action, task_id=req_data.task_id) return dump_response( StepByStepTourStateResponse, - StepByStepTourService.patch_state( - account=current_user, - current_tenant_id=current_tenant_id, - patch=patch, - session=db.session, - ), + application_services().step_by_step_tour.patch_state(request_context, patch), ) diff --git a/api/controllers/console/snippets/snippet_workflow.py b/api/controllers/console/snippets/snippet_workflow.py index 309a9d9ea1a..c293dc60e77 100644 --- a/api/controllers/console/snippets/snippet_workflow.py +++ b/api/controllers/console/snippets/snippet_workflow.py @@ -19,6 +19,7 @@ from controllers.console.app.workflow import ( WorkflowPaginationResponse, WorkflowPublishResponse, WorkflowResponse, + WorkflowResponseSource, WorkflowRestoreResponse, ) from controllers.console.snippets.payloads import ( @@ -179,9 +180,12 @@ class SnippetDraftWorkflowApi(Resource): raise DraftWorkflowNotExist() workflow.conversation_variables = [] - response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json") + session = db.session() + response = SnippetWorkflowResponse.model_validate( + WorkflowResponseSource(workflow, session=session), from_attributes=True + ).model_dump(mode="json") response["graph"] = WorkflowAgentPublishService.project_draft_bindings_to_graph( - session=db.session(), + session=session, draft_workflow=workflow, ) response["input_fields"] = snippet.input_fields_list @@ -274,7 +278,9 @@ class SnippetPublishedWorkflowApi(Resource): if not workflow: return None - response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json") + response = SnippetWorkflowResponse.model_validate( + WorkflowResponseSource(workflow, session=db.session()), from_attributes=True + ).model_dump(mode="json") response["input_fields"] = snippet.input_fields_list return response @@ -365,15 +371,15 @@ class SnippetPublishedAllWorkflowApi(Resource): limit=req_data.limit, ) - response = SnippetWorkflowPaginationResponse.model_validate( - { - "items": workflows, - "page": req_data.page, - "limit": req_data.limit, - "has_more": has_more, - }, - from_attributes=True, - ).model_dump(mode="json") + response = SnippetWorkflowPaginationResponse.model_validate( + { + "items": [WorkflowResponseSource(workflow, session=session) for workflow in workflows], + "page": req_data.page, + "limit": req_data.limit, + "has_more": has_more, + }, + from_attributes=True, + ).model_dump(mode="json") for item in response["items"]: item["input_fields"] = snippet.input_fields_list return response @@ -464,9 +470,11 @@ class SnippetWorkflowByIdApi(Resource): if not workflow: raise NotFound("Workflow not found") - response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json") - response["input_fields"] = snippet.input_fields_list - return response + response = SnippetWorkflowResponse.model_validate( + WorkflowResponseSource(workflow, session=session), from_attributes=True + ).model_dump(mode="json") + response["input_fields"] = snippet.input_fields_list + return response @console_ns.doc("delete_snippet_workflow_by_id") @console_ns.doc(description="Delete a published snippet workflow version") diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index 927b3b82899..ce3383ff13c 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -292,10 +292,8 @@ class AccountInitApi(Resource): @console_ns.expect(console_ns.models[AccountInitPayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__]) @console_account_admission(require_initialized=False) - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = AccountInitPayload.model_validate(payload) - + @model_validate(AccountInitPayload) + def post(self, args: AccountInitPayload, request_context: RequestContext): try: application_services().accounts.initialization.initialize( request_context, @@ -344,9 +342,8 @@ class AccountNameApi(Resource): @console_ns.expect(console_ns.models[AccountNamePayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__]) @console_account_admission() - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = AccountNamePayload.model_validate(payload) + @model_validate(AccountNamePayload) + def post(self, args: AccountNamePayload, request_context: RequestContext): return _update_account_profile(request_context, AccountProfileChanges(name=args.name)) @@ -371,9 +368,8 @@ class AccountAvatarApi(Resource): @console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.") @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__]) @console_account_admission() - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = AccountAvatarPayload.model_validate(payload) + @model_validate(AccountAvatarPayload) + def post(self, args: AccountAvatarPayload, request_context: RequestContext): return _update_account_profile(request_context, AccountProfileChanges(avatar=args.avatar)) @@ -387,9 +383,8 @@ class AccountInterfaceLanguageApi(Resource): @console_ns.expect(console_ns.models[AccountInterfaceLanguagePayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__]) @console_account_admission() - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = AccountInterfaceLanguagePayload.model_validate(payload) + @model_validate(AccountInterfaceLanguagePayload) + def post(self, args: AccountInterfaceLanguagePayload, request_context: RequestContext): return _update_account_profile( request_context, AccountProfileChanges(interface_language=args.interface_language), @@ -406,9 +401,8 @@ class AccountInterfaceThemeApi(Resource): @console_ns.expect(console_ns.models[AccountInterfaceThemePayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__]) @console_account_admission() - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = AccountInterfaceThemePayload.model_validate(payload) + @model_validate(AccountInterfaceThemePayload) + def post(self, args: AccountInterfaceThemePayload, request_context: RequestContext): return _update_account_profile( request_context, AccountProfileChanges(interface_theme=args.interface_theme), @@ -425,9 +419,8 @@ class AccountTimezoneApi(Resource): @console_ns.expect(console_ns.models[AccountTimezonePayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__]) @console_account_admission() - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = AccountTimezonePayload.model_validate(payload) + @model_validate(AccountTimezonePayload) + def post(self, args: AccountTimezonePayload, request_context: RequestContext): return _update_account_profile(request_context, AccountProfileChanges(timezone=args.timezone)) @@ -436,10 +429,8 @@ class AccountPasswordApi(Resource): @console_ns.expect(console_ns.models[AccountPasswordPayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__]) @console_account_admission() - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = AccountPasswordPayload.model_validate(payload) - + @model_validate(AccountPasswordPayload) + def post(self, args: AccountPasswordPayload, request_context: RequestContext): try: assert args.password is not None account = application_services().accounts.password.change( @@ -498,10 +489,8 @@ class AccountDeleteApi(Resource): @console_ns.expect(console_ns.models[AccountDeletePayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__]) @console_account_admission() - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = AccountDeletePayload.model_validate(payload) - + @model_validate(AccountDeletePayload) + def post(self, args: AccountDeletePayload, request_context: RequestContext): try: application_services().accounts.deletion.request_deletion( request_context, @@ -519,10 +508,8 @@ class AccountDeleteUpdateFeedbackApi(Resource): @console_ns.expect(console_ns.models[AccountDeletionFeedbackPayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__]) @setup_required - def post(self): - payload = console_ns.payload or {} - args = AccountDeletionFeedbackPayload.model_validate(payload) - + @model_validate(AccountDeletionFeedbackPayload) + def post(self, args: AccountDeletionFeedbackPayload): application_services().accounts.deletion_feedback.submit(email=args.email, feedback=args.feedback) return SimpleResultResponse(result="success").model_dump(mode="json") @@ -547,9 +534,8 @@ class EducationApi(Resource): @console_ns.expect(console_ns.models[EducationActivatePayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationActivateResponse.__name__]) @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = EducationActivatePayload.model_validate(payload) + @model_validate(EducationActivatePayload) + def post(self, args: EducationActivatePayload, request_context: RequestContext): try: activation = application_services().accounts.education.activate( request_context, @@ -574,10 +560,8 @@ class EducationAutoCompleteApi(Resource): @console_ns.doc(params=query_params_from_model(EducationAutocompleteQuery)) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationAutocompleteResponse.__name__]) @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) - def get(self, request_context: RequestContext): - payload = request.args.to_dict(flat=True) - args = EducationAutocompleteQuery.model_validate(payload) - + @model_validate(EducationAutocompleteQuery) + def get(self, args: EducationAutocompleteQuery, request_context: RequestContext): return dump_response( EducationAutocompleteResponse, application_services().accounts.education.autocomplete( @@ -594,10 +578,8 @@ class ChangeEmailSendEmailApi(Resource): @console_ns.expect(console_ns.models[ChangeEmailSendPayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__]) @console_account_admission(require_change_email_enabled=True) - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = ChangeEmailSendPayload.model_validate(payload) - + @model_validate(ChangeEmailSendPayload) + def post(self, args: ChangeEmailSendPayload, request_context: RequestContext): ip_address = extract_remote_ip(request) language = "zh-Hans" if args.language == "zh-Hans" else "en-US" try: @@ -627,10 +609,8 @@ class ChangeEmailCheckApi(Resource): @console_ns.expect(console_ns.models[ChangeEmailValidityPayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[VerificationTokenResponse.__name__]) @console_account_admission(require_change_email_enabled=True) - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = ChangeEmailValidityPayload.model_validate(payload) - + @model_validate(ChangeEmailValidityPayload) + def post(self, args: ChangeEmailValidityPayload, request_context: RequestContext): try: verification = application_services().accounts.change_email.verify_code( request_context, @@ -656,9 +636,8 @@ class ChangeEmailResetApi(Resource): @console_ns.expect(console_ns.models[ChangeEmailResetPayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__]) @console_account_admission(require_change_email_enabled=True) - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = ChangeEmailResetPayload.model_validate(payload) + @model_validate(ChangeEmailResetPayload) + def post(self, args: ChangeEmailResetPayload, request_context: RequestContext): try: updated_account = application_services().accounts.change_email.reset( request_context, @@ -684,9 +663,8 @@ class CheckEmailUnique(Resource): @console_ns.expect(console_ns.models[CheckEmailUniquePayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__]) @setup_required - def post(self): - payload = console_ns.payload or {} - args = CheckEmailUniquePayload.model_validate(payload) + @model_validate(CheckEmailUniquePayload) + def post(self, args: CheckEmailUniquePayload): try: application_services().accounts.change_email.ensure_available(args.email) except account_errors.AccountEmailDomainSuspendedError: diff --git a/api/controllers/console/workspace/snippets.py b/api/controllers/console/workspace/snippets.py index f5e2f85a0b7..ba1b3d816b3 100644 --- a/api/controllers/console/workspace/snippets.py +++ b/api/controllers/console/workspace/snippets.py @@ -221,7 +221,7 @@ class CustomizedSnippetDetailApi(Resource): """Update customized snippet.""" snippet_service = _snippet_service() snippet = snippet_service.get_snippet_by_id( - snippet_id=str(snippet_id), + snippet_id=snippet_id, tenant_id=current_tenant_id, ) @@ -265,7 +265,7 @@ class CustomizedSnippetDetailApi(Resource): """Delete customized snippet.""" snippet_service = _snippet_service() snippet = snippet_service.get_snippet_by_id( - snippet_id=str(snippet_id), + snippet_id=snippet_id, tenant_id=current_tenant_id, ) @@ -304,7 +304,7 @@ class CustomizedSnippetExportApi(Resource): """Export snippet as DSL.""" snippet_service = _snippet_service() snippet = snippet_service.get_snippet_by_id( - snippet_id=str(snippet_id), + snippet_id=snippet_id, tenant_id=current_tenant_id, ) @@ -428,7 +428,7 @@ class CustomizedSnippetCheckDependenciesApi(Resource): """Check dependencies for a snippet.""" snippet_service = _snippet_service() snippet = snippet_service.get_snippet_by_id( - snippet_id=str(snippet_id), + snippet_id=snippet_id, tenant_id=current_tenant_id, ) @@ -458,7 +458,7 @@ class CustomizedSnippetUseCountIncrementApi(Resource): """Increment snippet use count when it is inserted into a workflow.""" snippet_service = _snippet_service() snippet = snippet_service.get_snippet_by_id( - snippet_id=str(snippet_id), + snippet_id=snippet_id, tenant_id=current_tenant_id, ) diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index 7850f4d206a..da1d5584af6 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -352,19 +352,6 @@ def email_password_login_enabled[**P, R](view: Callable[P, R]) -> Callable[P, R] return decorated -def email_register_enabled[**P, R](view: Callable[P, R]) -> Callable[P, R]: - @wraps(view) - def decorated(*args: P.args, **kwargs: P.kwargs): - features = FeatureService.get_system_features() - if features.is_allow_register: - return view(*args, **kwargs) - - # otherwise, return 403 - abort(403) - - return decorated - - def enable_change_email[**P, R](view: Callable[P, R]) -> Callable[P, R]: @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): @@ -652,6 +639,23 @@ def with_current_user_id[T, **P, R]( return decorated +def validate_request[M: BaseModel](model: type[M]) -> M: + """Parse and validate the current request without exposing submitted values.""" + + if request.method == "GET": + raw = request.args.to_dict(flat=True) + elif request.method == "DELETE": + raw = request.args.to_dict(flat=True) or (request.get_json(silent=True) or {}) + else: + raw = request.get_json(silent=True) or {} + + try: + return model.model_validate(raw) + except ValidationError as exc: + errors = exc.errors(include_url=False, include_input=False, include_context=False) + raise UnprocessableEntity(json.dumps(errors)) from None + + def model_validate[T, M: BaseModel, **P, R]( model: type[M], ) -> Callable[ @@ -671,19 +675,7 @@ def model_validate[T, M: BaseModel, **P, R]( ) -> Callable[Concatenate[T, P], R]: @wraps(view) def wrapper(self: T, *args: P.args, **kwargs: P.kwargs) -> R: - if request.method == "GET": - raw = request.args.to_dict(flat=True) - elif request.method == "DELETE": - raw = request.args.to_dict(flat=True) or (request.get_json(silent=True) or {}) - else: - raw = request.get_json(silent=True) or {} - - try: - validated = model.model_validate(raw) - except ValidationError as exc: - raise UnprocessableEntity(exc.json()) - - return view(self, validated, *args, **kwargs) + return view(self, validate_request(model), *args, **kwargs) return wrapper diff --git a/api/controllers/openapi/_errors.py b/api/controllers/openapi/_errors.py index 141ad62683e..ebed2454ce9 100644 --- a/api/controllers/openapi/_errors.py +++ b/api/controllers/openapi/_errors.py @@ -74,6 +74,7 @@ class OpenApiErrorCode(StrEnum): KNOWLEDGE_FS_REQUEST_TOO_LARGE = "knowledge_fs_request_too_large" KNOWLEDGE_FS_RESOURCE_NOT_FOUND = "knowledge_fs_resource_not_found" KNOWLEDGE_FS_UNAVAILABLE = "knowledge_fs_unavailable" + TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE = "trigger_workflow_service_mode_unavailable" class ErrorDetail(BaseModel): diff --git a/api/controllers/openapi/app_run.py b/api/controllers/openapi/app_run.py index 772513ad417..631a750f0ee 100644 --- a/api/controllers/openapi/app_run.py +++ b/api/controllers/openapi/app_run.py @@ -35,6 +35,7 @@ from controllers.service_api.app.error import ( ProviderModelCurrentlyNotSupportError, ProviderNotInitializeError, ProviderQuotaExceededError, + TriggerWorkflowServiceModeUnavailableError, ) from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from core.app.apps.base_app_queue_manager import AppQueueManager @@ -57,6 +58,9 @@ from services.errors.app import ( WorkflowIdFormatError, WorkflowNotFoundError, ) +from services.errors.app import ( + TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError, +) from services.errors.llm import InvokeRateLimitError logger = logging.getLogger(__name__) @@ -70,6 +74,8 @@ def _translate_service_errors() -> Generator[None, None, None]: raise NotFound(str(ex)) except (IsDraftWorkflowError, WorkflowIdFormatError) as ex: raise BadRequest(str(ex)) + except TriggerWorkflowServiceModeUnavailableServiceError: + raise TriggerWorkflowServiceModeUnavailableError() except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") except services.errors.conversation.ConversationCompletedError: diff --git a/api/controllers/service_api/app/annotation.py b/api/controllers/service_api/app/annotation.py index 3d45b46f066..832190e08b2 100644 --- a/api/controllers/service_api/app/annotation.py +++ b/api/controllers/service_api/app/annotation.py @@ -9,7 +9,7 @@ from sqlalchemy.orm import Session from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.common.session import with_session -from controllers.console.wraps import edit_permission_required +from controllers.console.wraps import edit_permission_required, model_validate from controllers.service_api import service_api_ns from controllers.service_api.wraps import validate_app_token from extensions.ext_redis import redis_client @@ -106,9 +106,9 @@ class AnnotationReplyActionApi(Resource): service_api_ns.models[AnnotationJobStatusResponse.__name__], ) @validate_app_token - def post(self, app_model: App, action: Literal["enable", "disable"]): + @model_validate(AnnotationReplyActionPayload) + def post(self, payload: AnnotationReplyActionPayload, app_model: App, action: Literal["enable", "disable"]): """Enable or disable annotation reply feature.""" - payload = AnnotationReplyActionPayload.model_validate(service_api_ns.payload or {}) match action: case "enable": enable_args: EnableAnnotationArgs = { @@ -250,9 +250,9 @@ class AnnotationListApi(Resource): ) @validate_app_token @with_session - def post(self, session: Session, app_model: App): + @model_validate(AnnotationCreatePayload) + def post(self, payload: AnnotationCreatePayload, session: Session, app_model: App): """Create a new annotation.""" - payload = AnnotationCreatePayload.model_validate(service_api_ns.payload or {}) insert_args: InsertAnnotationArgs = {"question": payload.question, "answer": payload.answer} annotation = AppAnnotationService.insert_app_annotation_directly(insert_args, app_model.id, session) return dump_response(Annotation, annotation), HTTPStatus.CREATED @@ -290,9 +290,9 @@ class AnnotationUpdateDeleteApi(Resource): @validate_app_token @with_session @edit_permission_required - def put(self, session: Session, app_model: App, annotation_id: UUID): + @model_validate(AnnotationCreatePayload) + def put(self, payload: AnnotationCreatePayload, session: Session, app_model: App, annotation_id: UUID): """Update an existing annotation.""" - payload = AnnotationCreatePayload.model_validate(service_api_ns.payload or {}) update_args: UpdateAnnotationArgs = {"question": payload.question, "answer": payload.answer} app_ref = AppRefService.create_app_ref(app_model) annotation_ref = AppRefService.create_annotation_ref(app_ref, str(annotation_id)) diff --git a/api/controllers/service_api/app/audio.py b/api/controllers/service_api/app/audio.py index 3441fafe034..2db10dcf42b 100644 --- a/api/controllers/service_api/app/audio.py +++ b/api/controllers/service_api/app/audio.py @@ -8,6 +8,7 @@ import services from controllers.common.controller_schemas import TextToAudioPayload from controllers.common.fields import AudioBinaryResponse, AudioTranscriptResponse from controllers.common.schema import register_response_schema_models, register_schema_model +from controllers.console.wraps import model_validate from controllers.service_api import service_api_ns from controllers.service_api.app.error import ( AppUnavailableError, @@ -181,14 +182,13 @@ class TextApi(Resource): # TTS returns provider audio bytes, so the success response is intentionally schema-less. @service_api_ns.response(200, "Text successfully converted to audio") @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON)) - def post(self, app_model: App, end_user: EndUser): + @model_validate(TextToAudioPayload) + def post(self, payload: TextToAudioPayload, app_model: App, end_user: EndUser): """Convert text to audio using text-to-speech. Converts the provided text to audio using the specified voice. """ try: - payload = TextToAudioPayload.model_validate(service_api_ns.payload or {}) - message_id = payload.message_id text = payload.text voice = payload.voice diff --git a/api/controllers/service_api/app/conversation.py b/api/controllers/service_api/app/conversation.py index ac066cdf11d..163a50e959b 100644 --- a/api/controllers/service_api/app/conversation.py +++ b/api/controllers/service_api/app/conversation.py @@ -11,6 +11,7 @@ from werkzeug.exceptions import BadRequest, NotFound import services from controllers.common.controller_schemas import ConversationRenamePayload from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models +from controllers.console.wraps import model_validate from controllers.service_api import service_api_ns from controllers.service_api.app.error import NotChatAppError from controllers.service_api.schema import expect_user_json, expect_with_user @@ -293,7 +294,8 @@ class ConversationRenameApi(Resource): service_api_ns.models[SimpleConversation.__name__], ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON)) - def post(self, app_model: App, end_user: EndUser, conversation_id: UUID): + @model_validate(ConversationRenamePayload) + def post(self, payload: ConversationRenamePayload, app_model: App, end_user: EndUser, conversation_id: UUID): """Rename a conversation or auto-generate a name.""" app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}: @@ -301,8 +303,6 @@ class ConversationRenameApi(Resource): conversation_id_str = str(conversation_id) - payload = ConversationRenamePayload.model_validate(service_api_ns.payload or {}) - try: session = db.session() conversation = ConversationService.rename( @@ -408,7 +408,15 @@ class ConversationVariableDetailApi(Resource): service_api_ns.models[ConversationVariableResponse.__name__], ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON)) - def put(self, app_model: App, end_user: EndUser, conversation_id: UUID, variable_id: UUID): + @model_validate(ConversationVariableUpdatePayload) + def put( + self, + payload: ConversationVariableUpdatePayload, + app_model: App, + end_user: EndUser, + conversation_id: UUID, + variable_id: UUID, + ): """Update a conversation variable's value. Allows updating the value of a specific conversation variable. @@ -421,8 +429,6 @@ class ConversationVariableDetailApi(Resource): conversation_id_str = str(conversation_id) variable_id_str = str(variable_id) - payload = ConversationVariableUpdatePayload.model_validate(service_api_ns.payload or {}) - try: variable = ConversationService.update_conversation_variable( app_model, conversation_id_str, variable_id_str, end_user, payload.value, session=db.session() diff --git a/api/controllers/service_api/app/error.py b/api/controllers/service_api/app/error.py index e6f97e98249..60959746d49 100644 --- a/api/controllers/service_api/app/error.py +++ b/api/controllers/service_api/app/error.py @@ -1,4 +1,8 @@ from libs.exception import BaseHTTPException +from services.errors.app import ( + TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE, + TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE, +) class AppUnavailableError(BaseHTTPException): @@ -37,6 +41,12 @@ class WorkflowVersionExecutionNotAllowedError(BaseHTTPException): code = 403 +class TriggerWorkflowServiceModeUnavailableError(BaseHTTPException): + error_code = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE + description = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE + code = 403 + + class ConversationCompletedError(BaseHTTPException): error_code = "conversation_completed" description = "The conversation has ended. Please start a new conversation." diff --git a/api/controllers/service_api/app/human_input_form.py b/api/controllers/service_api/app/human_input_form.py index 2951818e3ba..e1533870755 100644 --- a/api/controllers/service_api/app/human_input_form.py +++ b/api/controllers/service_api/app/human_input_form.py @@ -17,6 +17,7 @@ from werkzeug.exceptions import BadRequest, NotFound from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.console.wraps import model_validate from controllers.service_api import service_api_ns from controllers.service_api.schema import expect_with_user from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token @@ -163,9 +164,8 @@ class WorkflowHumanInputFormApi(Resource): service_api_ns.models[HumanInputFormSubmitResponse.__name__], ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True)) - def post(self, app_model: App, end_user: EndUser, form_token: str): - payload = HumanInputFormSubmitPayload.model_validate(service_api_ns.payload or {}) - + @model_validate(HumanInputFormSubmitPayload) + def post(self, payload: HumanInputFormSubmitPayload, app_model: App, end_user: EndUser, form_token: str): service = HumanInputService(db.engine) form = service.get_form_by_token(form_token) if form is None: diff --git a/api/controllers/service_api/app/message.py b/api/controllers/service_api/app/message.py index 59f7cc0d676..d3443371313 100644 --- a/api/controllers/service_api/app/message.py +++ b/api/controllers/service_api/app/message.py @@ -11,6 +11,7 @@ import services from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery from controllers.common.fields import SimpleResultStringListResponse from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models +from controllers.console.wraps import model_validate from controllers.service_api import service_api_ns from controllers.service_api.app.error import NotChatAppError from controllers.service_api.schema import expect_with_user @@ -160,15 +161,14 @@ class MessageFeedbackApi(Resource): } ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True)) - def post(self, app_model: App, end_user: EndUser, message_id: UUID): + @model_validate(MessageFeedbackPayload) + def post(self, payload: MessageFeedbackPayload, app_model: App, end_user: EndUser, message_id: UUID): """Submit feedback for a message. Allows users to rate messages as like/dislike and provide optional feedback content. """ message_id_str = str(message_id) - payload = MessageFeedbackPayload.model_validate(service_api_ns.payload or {}) - try: MessageService.create_feedback( app_model=app_model, diff --git a/api/controllers/service_api/app/workflow.py b/api/controllers/service_api/app/workflow.py index 24f9fb7b62c..33a2f3a4b64 100644 --- a/api/controllers/service_api/app/workflow.py +++ b/api/controllers/service_api/app/workflow.py @@ -28,6 +28,7 @@ from controllers.service_api.app.error import ( ProviderModelCurrentlyNotSupportError, ProviderNotInitializeError, ProviderQuotaExceededError, + TriggerWorkflowServiceModeUnavailableError, WorkflowVersionExecutionNotAllowedError, ) from controllers.service_api.schema import ( @@ -61,7 +62,14 @@ from models.model import App, AppMode, EndUser from repositories.factory import DifyAPIRepositoryFactory from services.app_generate_service import AppGenerateService from services.billing_service import BillingService -from services.errors.app import IsDraftWorkflowError, WorkflowIdFormatError, WorkflowNotFoundError +from services.errors.app import ( + IsDraftWorkflowError, + WorkflowIdFormatError, + WorkflowNotFoundError, +) +from services.errors.app import ( + TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError, +) from services.errors.llm import InvokeRateLimitError from services.workflow_app_service import WorkflowAppService @@ -300,6 +308,11 @@ class WorkflowRunApi(Resource): "- `completion_request_error` : Workflow execution request failed.\n" "- `invalid_param` : Invalid parameter value." ), + 403: ( + "- `forbidden` : Token scope, app, or workspace access denied.\n" + "- `trigger_workflow_service_mode_unavailable` : Trigger-entry workflows cannot be invoked through " + "Web App, Service API, OpenAPI, or MCP." + ), 429: ( "- `too_many_requests` : Too many concurrent requests for this app.\n" "- `rate_limit_error` : The upstream model provider rate limit was exceeded." @@ -360,6 +373,8 @@ class WorkflowRunApi(Resource): # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) + except TriggerWorkflowServiceModeUnavailableServiceError: + raise TriggerWorkflowServiceModeUnavailableError() except ProviderTokenNotInitError as ex: raise ProviderNotInitializeError(ex.description) except QuotaExceededError: @@ -406,8 +421,11 @@ class WorkflowRunByIdApi(Resource): "- `invalid_param` : Required parameter missing or invalid." ), 403: ( - "`workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the " - "current plan. Upgrade to a paid plan." + "- `forbidden` : Token scope, app, or workspace access denied.\n" + "- `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the " + "current plan. Upgrade to a paid plan.\n" + "- `trigger_workflow_service_mode_unavailable` : The selected workflow version uses a trigger entry " + "and cannot be invoked through Web App, Service API, OpenAPI, or MCP." ), 404: "`not_found` : Workflow not found.", 429: ( @@ -487,6 +505,8 @@ class WorkflowRunByIdApi(Resource): # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) + except TriggerWorkflowServiceModeUnavailableServiceError: + raise TriggerWorkflowServiceModeUnavailableError() except WorkflowNotFoundError as ex: raise NotFound(str(ex)) except IsDraftWorkflowError as ex: diff --git a/api/controllers/service_api/dataset/dataset.py b/api/controllers/service_api/dataset/dataset.py index 51667e1fbde..da17036689f 100644 --- a/api/controllers/service_api/dataset/dataset.py +++ b/api/controllers/service_api/dataset/dataset.py @@ -25,7 +25,7 @@ from controllers.common.schema import ( register_schema_models, ) from controllers.common.session import with_session -from controllers.console.wraps import edit_permission_required +from controllers.console.wraps import edit_permission_required, model_validate from controllers.service_api import service_api_ns from controllers.service_api.dataset.error import DatasetInUseError, DatasetNameDuplicateError, InvalidActionError from controllers.service_api.wraps import ( @@ -669,14 +669,13 @@ class DatasetApi(DatasetApiResource): ) @cloud_edition_billing_rate_limit_check("knowledge", "dataset") @with_session - def patch(self, session: Session, _, dataset_id: UUID): + @model_validate(DatasetUpdatePayload) + def patch(self, payload: DatasetUpdatePayload, session: Session, _, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str, session) if dataset is None: raise NotFound("Dataset not found.") - payload_dict = service_api_ns.payload or {} - payload = DatasetUpdatePayload.model_validate(payload_dict) update_data = payload.model_dump(exclude_unset=True) if payload.permission is not None: update_data["permission"] = str(payload.permission) @@ -944,13 +943,13 @@ class DatasetTagsApi(DatasetApiResource): service_api_ns.models[KnowledgeTagResponse.__name__], ) @with_session - def post(self, session: Session, _): + @model_validate(TagCreatePayload) + def post(self, payload: TagCreatePayload, session: Session, _): """Add a knowledge type tag.""" assert isinstance(current_user, Account) if not (current_user.has_edit_permission or current_user.is_dataset_editor): raise Forbidden() - payload = TagCreatePayload.model_validate(service_api_ns.payload or {}) tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=TagType.KNOWLEDGE), session) response = KnowledgeTagResponse(id=tag.id, name=tag.name, type=tag.type, binding_count="0") @@ -982,12 +981,12 @@ class DatasetTagsApi(DatasetApiResource): service_api_ns.models[KnowledgeTagResponse.__name__], ) @with_session - def patch(self, session: Session, _): + @model_validate(TagUpdatePayload) + def patch(self, payload: TagUpdatePayload, session: Session, _): assert isinstance(current_user, Account) if not (current_user.has_edit_permission or current_user.is_dataset_editor): raise Forbidden() - payload = TagUpdatePayload.model_validate(service_api_ns.payload or {}) tag_id = payload.tag_id tag = TagService.update_tags( UpdateTagServicePayload(name=payload.name), tag_id, session, tag_type=TagType.KNOWLEDGE @@ -1019,9 +1018,9 @@ class DatasetTagsApi(DatasetApiResource): ) @edit_permission_required @with_session - def delete(self, session: Session, _): + @model_validate(TagDeletePayload) + def delete(self, payload: TagDeletePayload, session: Session, _): """Delete a knowledge type tag.""" - payload = TagDeletePayload.model_validate(service_api_ns.payload or {}) TagService.delete_tag(payload.tag_id, session, tag_type=TagType.KNOWLEDGE) return "", 204 @@ -1049,13 +1048,13 @@ class DatasetTagBindingApi(DatasetApiResource): } ) @with_session - def post(self, session: Session, _): + @model_validate(TagBindingPayload) + def post(self, payload: TagBindingPayload, session: Session, _): # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator assert isinstance(current_user, Account) if not (current_user.has_edit_permission or current_user.is_dataset_editor): raise Forbidden() - payload = TagBindingPayload.model_validate(service_api_ns.payload or {}) TagService.save_tag_binding( TagBindingCreatePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=TagType.KNOWLEDGE), session, @@ -1086,13 +1085,13 @@ class DatasetTagUnbindingApi(DatasetApiResource): } ) @with_session - def post(self, session: Session, _): + @model_validate(TagUnbindingPayload) + def post(self, payload: TagUnbindingPayload, session: Session, _): # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator assert isinstance(current_user, Account) if not (current_user.has_edit_permission or current_user.is_dataset_editor): raise Forbidden() - payload = TagUnbindingPayload.model_validate(service_api_ns.payload or {}) TagService.delete_tag_binding( TagBindingDeletePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=TagType.KNOWLEDGE), session, diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py index 216e3143339..5049c2a3782 100644 --- a/api/controllers/service_api/dataset/document.py +++ b/api/controllers/service_api/dataset/document.py @@ -45,6 +45,7 @@ from controllers.common.schema import ( register_schema_models, ) from controllers.common.session import with_session +from controllers.console.wraps import model_validate from controllers.service_api import service_api_ns from controllers.service_api.app.error import ProviderNotInitializeError from controllers.service_api.dataset.error import ( @@ -1069,9 +1070,8 @@ class DocumentBatchDownloadZipApi(DatasetApiResource): @service_api_ns.response(200, "ZIP archive generated successfully") @cloud_edition_billing_rate_limit_check("knowledge", "dataset") @with_session(write=False) - def post(self, session: Session, tenant_id, dataset_id: UUID): - payload = DocumentBatchDownloadZipPayload.model_validate(service_api_ns.payload or {}) - + @model_validate(DocumentBatchDownloadZipPayload) + def post(self, payload: DocumentBatchDownloadZipPayload, session: Session, tenant_id, dataset_id: UUID): upload_files, download_name = DocumentService.prepare_document_batch_download_zip( dataset_id=str(dataset_id), document_ids=[str(document_id) for document_id in payload.document_ids], diff --git a/api/controllers/service_api/dataset/metadata.py b/api/controllers/service_api/dataset/metadata.py index 6a7ed9fefe4..693ba8377b9 100644 --- a/api/controllers/service_api/dataset/metadata.py +++ b/api/controllers/service_api/dataset/metadata.py @@ -8,6 +8,7 @@ from werkzeug.exceptions import NotFound from controllers.common.controller_schemas import MetadataUpdatePayload from controllers.common.schema import register_response_schema_models, register_schema_model, register_schema_models from controllers.common.session import with_session +from controllers.console.wraps import model_validate from controllers.service_api import service_api_ns from controllers.service_api.wraps import DatasetApiResource, cloud_edition_billing_rate_limit_check from fields.dataset_fields import ( @@ -81,9 +82,9 @@ class DatasetMetadataCreateServiceApi(DatasetApiResource): ) @cloud_edition_billing_rate_limit_check("knowledge", "dataset") @with_session - def post(self, session: Session, tenant_id, dataset_id: UUID): + @model_validate(MetadataArgs) + def post(self, metadata_args: MetadataArgs, session: Session, tenant_id, dataset_id: UUID): """Create metadata for a dataset.""" - metadata_args = MetadataArgs.model_validate(service_api_ns.payload or {}) dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str, session) @@ -156,9 +157,9 @@ class DatasetMetadataServiceApi(DatasetApiResource): ) @cloud_edition_billing_rate_limit_check("knowledge", "dataset") @with_session - def patch(self, session: Session, tenant_id, dataset_id: UUID, metadata_id: UUID): + @model_validate(MetadataUpdatePayload) + def patch(self, payload: MetadataUpdatePayload, session: Session, tenant_id, dataset_id: UUID, metadata_id: UUID): """Update metadata name.""" - payload = MetadataUpdatePayload.model_validate(service_api_ns.payload or {}) dataset_id_str = str(dataset_id) metadata_id_str = str(metadata_id) @@ -315,15 +316,14 @@ class DocumentMetadataEditServiceApi(DatasetApiResource): ) @cloud_edition_billing_rate_limit_check("knowledge", "dataset") @with_session - def post(self, session: Session, tenant_id, dataset_id: UUID): + @model_validate(MetadataOperationData) + def post(self, metadata_args: MetadataOperationData, session: Session, tenant_id, dataset_id: UUID): """Update metadata for multiple documents.""" dataset = DatasetService.get_dataset_for_tenant(str(dataset_id), str(tenant_id), session=session) if dataset is None: raise NotFound("Dataset not found.") DatasetService.check_dataset_permission(dataset, current_user, session) - metadata_args = MetadataOperationData.model_validate(service_api_ns.payload or {}) - try: MetadataService.update_documents_metadata( dataset, metadata_args, cast(Account, current_user), session=session diff --git a/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py index 35a083a3714..245466367b7 100644 --- a/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py @@ -24,6 +24,7 @@ from controllers.common.schema import ( register_schema_model, ) from controllers.console.app.wraps import with_session +from controllers.console.wraps import model_validate from controllers.service_api import service_api_ns from controllers.service_api.dataset.error import PipelineRunError from controllers.service_api.schema import event_stream_response, json_or_event_stream_response, multipart_file_params @@ -215,7 +216,8 @@ class DatasourceNodeRunApi(DatasetApiResource): } ) @service_api_ns.expect(service_api_ns.models[DatasourceNodeRunPayload.__name__]) - def post(self, tenant_id: str, dataset_id: UUID, node_id: str): + @model_validate(DatasourceNodeRunPayload) + def post(self, payload: DatasourceNodeRunPayload, tenant_id: str, dataset_id: UUID, node_id: str): """Resource for getting datasource plugins.""" dataset_id_str = str(dataset_id) # Verify dataset ownership @@ -224,7 +226,6 @@ class DatasourceNodeRunApi(DatasetApiResource): if not dataset: raise NotFound("Dataset not found.") - payload = DatasourceNodeRunPayload.model_validate(service_api_ns.payload or {}) assert isinstance(current_user, Account) rag_pipeline_service: RagPipelineService = RagPipelineService(db.session()) pipeline: Pipeline = rag_pipeline_service.get_pipeline(tenant_id=tenant_id, dataset_id=dataset_id_str) diff --git a/api/controllers/web/audio.py b/api/controllers/web/audio.py index a7b4fa38916..9a5dc0e6f4b 100644 --- a/api/controllers/web/audio.py +++ b/api/controllers/web/audio.py @@ -6,6 +6,7 @@ from werkzeug.exceptions import InternalServerError import services from controllers.common.controller_schemas import TextToAudioPayload as TextToAudioPayloadBase +from controllers.console.wraps import model_validate from controllers.web import web_ns from controllers.web.error import ( AppUnavailableError, @@ -131,11 +132,10 @@ class TextApi(WebApiResource): ) # response-contract:ignore provider audio bytes; TODO: model binary audio response if shape is standardized. @web_ns.response(200, "Success") - def post(self, app_model: App, end_user: EndUser): + @model_validate(TextToAudioPayload) + def post(self, payload: TextToAudioPayload, app_model: App, end_user: EndUser): """Convert text to audio""" try: - payload = TextToAudioPayload.model_validate(web_ns.payload or {}) - message_id = payload.message_id text = payload.text voice = payload.voice diff --git a/api/controllers/web/conversation.py b/api/controllers/web/conversation.py index 75aae01a576..abdf91aca56 100644 --- a/api/controllers/web/conversation.py +++ b/api/controllers/web/conversation.py @@ -8,6 +8,7 @@ from werkzeug.exceptions import NotFound from controllers.common.controller_schemas import ConversationRenamePayload from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models +from controllers.console.wraps import model_validate from controllers.web import web_ns from controllers.web.error import NotChatAppError from controllers.web.wraps import WebApiResource @@ -153,15 +154,14 @@ class ConversationRenameApi(WebApiResource): ) @web_ns.response(200, "Conversation renamed successfully", web_ns.models[SimpleConversation.__name__]) @web_ns.expect(web_ns.models[ConversationRenamePayload.__name__]) - def post(self, app_model: App, end_user: EndUser, c_id: UUID): + @model_validate(ConversationRenamePayload) + def post(self, payload: ConversationRenamePayload, app_model: App, end_user: EndUser, c_id: UUID): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}: raise NotChatAppError() conversation_id = str(c_id) - payload = ConversationRenamePayload.model_validate(web_ns.payload or {}) - try: session = db.session() conversation = ConversationService.rename( diff --git a/api/controllers/web/error.py b/api/controllers/web/error.py index b0ab2f0334c..16253f06eee 100644 --- a/api/controllers/web/error.py +++ b/api/controllers/web/error.py @@ -1,4 +1,8 @@ from libs.exception import BaseHTTPException +from services.errors.app import ( + TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE, + TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE, +) class AppUnavailableError(BaseHTTPException): @@ -31,6 +35,12 @@ class NotWorkflowAppError(BaseHTTPException): code = 400 +class TriggerWorkflowServiceModeUnavailableError(BaseHTTPException): + error_code = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE + description = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE + code = 403 + + class ConversationCompletedError(BaseHTTPException): error_code = "conversation_completed" description = "The conversation has ended. Please start a new conversation." diff --git a/api/controllers/web/remote_files.py b/api/controllers/web/remote_files.py index b12661dc5cf..ab099077b79 100644 --- a/api/controllers/web/remote_files.py +++ b/api/controllers/web/remote_files.py @@ -9,6 +9,7 @@ from controllers.common.errors import ( RemoteFileUploadError, UnsupportedFileTypeError, ) +from controllers.console.wraps import model_validate from core.file import remote_fetcher from extensions.ext_database import db from fields.file_fields import FileWithSignedUrl, RemoteFileInfo @@ -86,7 +87,8 @@ class RemoteFileUploadApi(WebApiResource): ) @web_ns.response(201, "Remote file uploaded", web_ns.models[FileWithSignedUrl.__name__]) @web_ns.expect(web_ns.models[RemoteFileUploadPayload.__name__]) - def post(self, app_model: App, end_user: EndUser): + @model_validate(RemoteFileUploadPayload) + def post(self, payload: RemoteFileUploadPayload, app_model: App, end_user: EndUser): """Upload a file from a remote URL. Downloads a file from the provided remote URL and uploads it @@ -108,7 +110,6 @@ class RemoteFileUploadApi(WebApiResource): FileTooLargeError: File exceeds size limit UnsupportedFileTypeError: File type not supported """ - payload = RemoteFileUploadPayload.model_validate(web_ns.payload or {}) url = str(payload.url) try: diff --git a/api/controllers/web/workflow.py b/api/controllers/web/workflow.py index 1e6d6e24d92..9dc728e2efd 100644 --- a/api/controllers/web/workflow.py +++ b/api/controllers/web/workflow.py @@ -14,6 +14,7 @@ from controllers.web.error import ( ProviderModelCurrentlyNotSupportError, ProviderNotInitializeError, ProviderQuotaExceededError, + TriggerWorkflowServiceModeUnavailableError, ) from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from controllers.web.wraps import WebApiResource @@ -30,6 +31,9 @@ from graphon.model_runtime.errors.invoke import InvokeError from libs import helper from models.model import App, AppMode, EndUser from services.app_generate_service import AppGenerateService +from services.errors.app import ( + TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError, +) from services.errors.llm import InvokeRateLimitError logger = logging.getLogger(__name__) @@ -78,6 +82,8 @@ class WorkflowRunApi(WebApiResource): # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) + except TriggerWorkflowServiceModeUnavailableServiceError: + raise TriggerWorkflowServiceModeUnavailableError() except ProviderTokenNotInitError as ex: raise ProviderNotInitializeError(ex.description) except QuotaExceededError: diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index b2457a44a04..5381cd4b1c3 100644 --- a/api/core/app/apps/agent_app/app_generator.py +++ b/api/core/app/apps/agent_app/app_generator.py @@ -31,7 +31,11 @@ from core.agent.publish_visibility import agent_has_workflow_callable_active_sna from core.app.app_config.easy_ui_based_app.model_config.converter import ModelConfigConverter from core.app.apps.agent_app.app_config_manager import AgentAppConfigManager from core.app.apps.agent_app.app_runner import AgentAppRunner -from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError +from core.app.apps.agent_app.errors import ( + AgentAppGeneratorError, + AgentAppNotPublishedError, + AgentSessionSnapshotIncompatibleError, +) from core.app.apps.agent_app.generate_response_converter import AgentAppGenerateResponseConverter from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder from core.app.apps.agent_app.session_store import AgentAppWorkspaceStore @@ -531,6 +535,15 @@ class AgentAppGenerator(MessageBasedAppGenerator): ) except GenerateTaskStoppedError: pass + except AgentSessionSnapshotIncompatibleError as error: + logger.info( + "Agent App session snapshot no longer matches the current composition", + extra={ + "agent_id": application_generate_entity.agent_id, + "conversation_id": conversation_id, + }, + ) + queue_manager.publish_error(error, PublishFrom.APPLICATION_MANAGER) except Exception as e: logger.exception("Unknown Error in Agent App generate worker") queue_manager.publish_error(e, PublishFrom.APPLICATION_MANAGER) diff --git a/api/core/app/apps/agent_app/errors.py b/api/core/app/apps/agent_app/errors.py index 51b4e77116a..bdcd38abfdf 100644 --- a/api/core/app/apps/agent_app/errors.py +++ b/api/core/app/apps/agent_app/errors.py @@ -1,6 +1,24 @@ +from core.app.apps.exc import AppGenerateError + +AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE = "agent_session_configuration_changed" +AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE = ( + "The Agent configuration changed after this conversation started. Start a new conversation to continue." +) + + class AgentAppGeneratorError(ValueError): """Raised when an Agent App turn cannot be set up.""" class AgentAppNotPublishedError(AgentAppGeneratorError): """Raised when a public Agent App runtime is requested before publish.""" + + +class AgentSessionSnapshotIncompatibleError(AppGenerateError): + """Raised when a retained session snapshot no longer matches the current composition.""" + + error_code = AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE + status_code = 409 + + def __init__(self) -> None: + super().__init__(AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE) diff --git a/api/core/app/apps/agent_app/runtime_request_builder.py b/api/core/app/apps/agent_app/runtime_request_builder.py index cc8ad8cd253..31b2cda6c40 100644 --- a/api/core/app/apps/agent_app/runtime_request_builder.py +++ b/api/core/app/apps/agent_app/runtime_request_builder.py @@ -50,6 +50,8 @@ from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig from models.provider_ids import ModelProviderID from services.agent.prompt_mentions import expand_prompt_mentions +from .errors import AgentSessionSnapshotIncompatibleError + class AgentAppRuntimeRequestBuildError(ValueError): """Raised when Agent App state cannot be mapped to a valid run request.""" @@ -192,6 +194,7 @@ class AgentAppRuntimeRequestBuilder: metadata=metadata, ) ) + self._validate_session_snapshot_layers(request) redacted = cast(dict[str, Any], redact_for_agent_backend_log(request)) return AgentAppRuntimeRequest( request=request, @@ -200,6 +203,24 @@ class AgentAppRuntimeRequestBuilder: binding_id=context.binding_id, ) + @staticmethod + def _validate_session_snapshot_layers(request: CreateRunRequest) -> None: + """Reject stale snapshots before they reach the Agent backend. + + Draft rows are updated in place, so their IDs cannot prove that a + retained snapshot still belongs to the current composition. Agenton + requires the ordered layer names to match exactly; enforce the same + invariant at the API boundary and return a product-level error. + """ + + snapshot = request.session_snapshot + if snapshot is None: + return + snapshot_layer_names = tuple(layer.name for layer in snapshot.layers) + composition_layer_names = tuple(layer.name for layer in request.composition.layers) + if snapshot_layer_names != composition_layer_names: + raise AgentSessionSnapshotIncompatibleError() + def _build_tool_layers( self, *, diff --git a/api/core/app/apps/base_app_generate_response_converter.py b/api/core/app/apps/base_app_generate_response_converter.py index aef54cc049e..0576bd48318 100644 --- a/api/core/app/apps/base_app_generate_response_converter.py +++ b/api/core/app/apps/base_app_generate_response_converter.py @@ -7,6 +7,7 @@ from dify_agent.protocol import RunFailureType from pydantic import JsonValue from clients.agent_backend.errors import AgentBackendError, AgentBackendRunFailedError +from core.app.apps.exc import AppGenerateError from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.task_entities import AppBlockingResponse, AppStreamResponse from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError @@ -125,6 +126,13 @@ class AppGenerateResponseConverter[TBlockingResponse: AppBlockingResponse](ABC): "message": str(e), } + if isinstance(e, AppGenerateError): + return { + "code": e.error_code, + "status": e.status_code, + "message": str(e), + } + error_responses: dict[type[Exception], dict[str, JsonValue]] = { ValueError: {"code": "invalid_param", "status": 400}, ProviderTokenNotInitError: {"code": "provider_not_initialize", "status": 400}, diff --git a/api/core/app/apps/exc.py b/api/core/app/apps/exc.py index 4187118b9bc..e5cb5d31b9a 100644 --- a/api/core/app/apps/exc.py +++ b/api/core/app/apps/exc.py @@ -1,2 +1,9 @@ +class AppGenerateError(ValueError): + """Base class for application-generation errors with a stable response contract.""" + + error_code: str + status_code: int + + class GenerateTaskStoppedError(Exception): pass diff --git a/api/core/llm_generator/llm_generator.py b/api/core/llm_generator/llm_generator.py index c6842b2b86c..03fc06f0d3f 100644 --- a/api/core/llm_generator/llm_generator.py +++ b/api/core/llm_generator/llm_generator.py @@ -841,9 +841,8 @@ class LLMGenerator: model_config: ModelConfig, ideal_output: str | None, workflow_service: WorkflowServiceInterface, + session: Session, ): - session = db.session() - app: App | None = session.scalar(select(App).where(App.id == flow_id, App.tenant_id == tenant_id).limit(1)) if not app: raise ValueError("App not found.") diff --git a/api/core/mcp/server/streamable_http.py b/api/core/mcp/server/streamable_http.py index 7fd03788c7e..3b96c5da43e 100644 --- a/api/core/mcp/server/streamable_http.py +++ b/api/core/mcp/server/streamable_http.py @@ -12,6 +12,7 @@ from core.mcp import types as mcp_types from graphon.variables.input_entities import VariableEntity, VariableEntityType from models.model import App, AppMCPServer, AppMode, EndUser from services.app_generate_service import AppGenerateService +from services.errors.app import TriggerWorkflowServiceModeUnavailableError logger = logging.getLogger(__name__) @@ -93,11 +94,16 @@ def handle_mcp_request( result=result_data.model_dump(by_alias=True, mode="json", exclude_none=True), ) - def create_error_response(code: int, message: str) -> mcp_types.JSONRPCError: + def create_error_response( + code: int, + message: str, + *, + data: Mapping[str, Any] | None = None, + ) -> mcp_types.JSONRPCError: """Create error response with error code and message""" from core.mcp.types import ErrorData - error_data = ErrorData(code=code, message=message) + error_data = ErrorData(code=code, message=message, data=data) return mcp_types.JSONRPCError( jsonrpc="2.0", id=request_id, @@ -131,6 +137,12 @@ def handle_mcp_request( case _: return create_error_response(mcp_types.METHOD_NOT_FOUND, f"Method not found: {request_type.__name__}") + except TriggerWorkflowServiceModeUnavailableError as e: + return create_error_response( + mcp_types.INVALID_REQUEST, + str(e), + data={"code": e.error_code}, + ) except ValueError as e: logger.exception("Invalid params") return create_error_response(mcp_types.INVALID_PARAMS, str(e)) diff --git a/api/core/workflow/generator/runner.py b/api/core/workflow/generator/runner.py index a0fa6e3f500..42095fbf85c 100644 --- a/api/core/workflow/generator/runner.py +++ b/api/core/workflow/generator/runner.py @@ -1193,7 +1193,7 @@ class WorkflowGenerator: if node.get("node_type") == BuiltinNodeTypes.TOOL and node.get("id") } for node in graph.get("nodes") or []: - planned = planned_by_id.get(str(node.get("id") or "")) + planned = planned_by_id.get(node.get("id") or "") if planned is None: continue data = node.get("data") diff --git a/api/dev/generate_swagger_markdown_docs.py b/api/dev/generate_swagger_markdown_docs.py index 991a487c107..a9451c52778 100644 --- a/api/dev/generate_swagger_markdown_docs.py +++ b/api/dev/generate_swagger_markdown_docs.py @@ -76,6 +76,10 @@ def _schema_markdown_type(schema: object) -> str: item_type = _schema_markdown_type(schema.get("items")) return f"[ {item_type or 'object'} ]" if isinstance(schema_type, str): + enum_values = schema.get("enum") + if isinstance(enum_values, list) and enum_values: + rendered_values = ", ".join(json.dumps(value, ensure_ascii=False) for value in enum_values) + return f"{schema_type},
**Available values:** {rendered_values}" return schema_type return "" diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index e29b92c8617..747c658a561 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -31,6 +31,7 @@ from repositories.factory import DifyAPIRepositoryFactory from repositories.installation_state_repository import InstallationStateRepository from repositories.oauth_server_repository import RedisOAuthServerTokenRepository, SQLAlchemyOAuthServerRepository from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository +from repositories.step_by_step_tour_repository import SQLAlchemyStepByStepTourStateRepository from repositories.tag_repository import TagRepository from repositories.trial_app_query_repository import TrialAppQueryRepository from repositories.trial_app_usage_repository import TrialAppUsageRepository @@ -70,6 +71,16 @@ from services.account_deletion_adapters import ( from services.account_deletion_feedback_service import AccountDeletionFeedbackService from services.account_deletion_service import AccountDeletionService from services.account_education_service import AccountEducationService +from services.account_email_registration_adapters import ( + AccountServiceRegistrationGateway, + BillingAccountRegistrationPolicyGateway, + CeleryEmailRegistrationNotificationGateway, + RateLimiterEmailRegistrationSendLimiter, + RedisEmailRegistrationSecurityGateway, + SecureEmailRegistrationCodeGenerator, + TokenManagerEmailRegistrationTokenGateway, +) +from services.account_email_registration_service import AccountEmailRegistrationService from services.account_initialization_service import AccountInitializationService from services.account_integration_service import AccountIntegrationService from services.account_password_hasher import LegacyAccountPasswordHasher @@ -94,6 +105,8 @@ from services.feature_service import FeatureService from services.feature_service_gateway import FeatureServiceGateway from services.file_service import FileService from services.init_validation_service import InitValidationService +from services.notification_gateway import BillingNotificationGateway +from services.notification_service import NotificationService from services.notion_data_source_gateway import NotionDataSourceGateway from services.oauth_server_service import OAUTH_ACCESS_TOKEN_EXPIRES_IN, OAuthServerService from services.partner_tenant_binding_service import PartnerTenantBindingService @@ -112,6 +125,7 @@ from services.retention.workflow_run.archive_log_service import WorkflowRunArchi from services.schema_definition_service import SchemaDefinitionService from services.setup_adapters import RedisSetupLock, RegisterServiceAccountProvisioner from services.setup_service import SetupService +from services.step_by_step_tour_service import StepByStepTourService from services.tag_application_service import TagApplicationService from services.trial_app_usage import TrialAppUsageRecorder from services.web_app_runtime_query_service import WebAppRuntimeQueryService @@ -150,6 +164,7 @@ def _is_user_allowed_to_access_webapp(user_id: str, app_id: str) -> bool: class AccountServices: avatar: AccountAvatarService change_email: AccountChangeEmailService + email_registration: AccountEmailRegistrationService deletion: AccountDeletionService deletion_feedback: AccountDeletionFeedbackService education: AccountEducationService @@ -177,6 +192,8 @@ class ApplicationServices: feature_queries: FeatureQueryService oauth_server: OAuthServerService init_validation: InitValidationService + notifications: NotificationService + step_by_step_tour: StepByStepTourService partner_tenant_bindings: PartnerTenantBindingService recommended_app_queries: RecommendedAppQueryService trial_app_usage: TrialAppUsageRecorder @@ -278,6 +295,29 @@ def build_application_services( billing_enabled=deployment_edition == DeploymentEdition.CLOUD, ), ), + email_registration=AccountEmailRegistrationService( + accounts=accounts, + tokens=TokenManagerEmailRegistrationTokenGateway(), + codes=SecureEmailRegistrationCodeGenerator(), + notifications=CeleryEmailRegistrationNotificationGateway(), + send_limits=RateLimiterEmailRegistrationSendLimiter( + rate_limiter=RateLimiter( + prefix="email_register_rate_limit", + max_attempts=1, + time_window=60, + redis_client=redis, + ) + ), + security=RedisEmailRegistrationSecurityGateway( + redis=redis, + verification_failure_limit=5, + verification_lockout_duration=dify_config.EMAIL_REGISTER_LOCKOUT_DURATION, + ), + account_policy=BillingAccountRegistrationPolicyGateway( + enabled=deployment_edition == DeploymentEdition.CLOUD, + ), + registration=AccountServiceRegistrationGateway(session_factory=database_client), + ), deletion=AccountDeletionService( accounts=accounts, memberships=workspace_query_repository, @@ -400,6 +440,16 @@ def build_application_services( validation_required=(deployment_edition != DeploymentEdition.CLOUD and bool(initialization_password)), expected_password=initialization_password, ), + notifications=NotificationService( + accounts=accounts, + notifications=BillingNotificationGateway(), + ), + step_by_step_tour=StepByStepTourService( + accounts=accounts, + states=SQLAlchemyStepByStepTourStateRepository(session_factory=database_client), + enabled=dify_config.ENABLE_STEP_BY_STEP_TOUR, + rollout_started_at=dify_config.STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT, + ), partner_tenant_bindings=PartnerTenantBindingService( sync_bindings=BillingService.sync_partner_tenants_bindings, ), diff --git a/api/models/workflow.py b/api/models/workflow.py index dd021307fd9..95159da2bd8 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -297,18 +297,16 @@ class Workflow(Base): # bug workflow.updated_at = workflow.created_at return workflow - @property - def created_by_account(self) -> Account | None: - return self.get_created_by_account(session=db.session()) + def created_by_account(self, session: orm.Session) -> Account | None: + return self.get_created_by_account(session=session) - def get_created_by_account(self, *, session: orm.Session) -> Account | None: + def get_created_by_account(self, session: orm.Session) -> Account | None: return session.get(Account, self.created_by) - @property - def updated_by_account(self) -> Account | None: - return self.get_updated_by_account(session=db.session()) + def updated_by_account(self, session: orm.Session) -> Account | None: + return self.get_updated_by_account(session=session) - def get_updated_by_account(self, *, session: orm.Session) -> Account | None: + def get_updated_by_account(self, session: orm.Session) -> Account | None: return session.get(Account, self.updated_by) if self.updated_by else None @property @@ -564,18 +562,17 @@ class Workflow(Base): # bug return helper.generate_text_hash(json.dumps(entity, sort_keys=True)) - @property @deprecated( - "This property is not accurate for determining if a workflow is published as a tool." + "This method is not accurate for determining if a workflow is published as a tool." "It only checks if there's a WorkflowToolProvider for the app, " "not if this specific workflow version is the one being used by the tool." ) - def tool_published(self) -> bool: - return self.get_tool_published(session=db.session()) + def tool_published(self, session: orm.Session) -> bool: + return self.get_tool_published(session=session) - def get_tool_published(self, *, session: orm.Session) -> bool: + def get_tool_published(self, session: orm.Session) -> bool: """ - DEPRECATED: This property is not accurate for determining if a workflow is published as a tool. + DEPRECATED: This method is not accurate for determining if a workflow is published as a tool. It only checks if there's a WorkflowToolProvider for the app, not if this specific workflow version is the one being used by the tool. @@ -1393,17 +1390,15 @@ class WorkflowAppLog(TypeBase): return None - @property - def created_by_account(self): + def created_by_account(self, session: orm.Session) -> Account | None: created_by_role = CreatorUserRole(self.created_by_role) - return db.session.get(Account, self.created_by) if created_by_role == CreatorUserRole.ACCOUNT else None + return session.get(Account, self.created_by) if created_by_role == CreatorUserRole.ACCOUNT else None - @property - def created_by_end_user(self): + def created_by_end_user(self, session: orm.Session): from .model import EndUser created_by_role = CreatorUserRole(self.created_by_role) - return db.session.get(EndUser, self.created_by) if created_by_role == CreatorUserRole.END_USER else None + return session.get(EndUser, self.created_by) if created_by_role == CreatorUserRole.END_USER else None def to_dict(self) -> WorkflowAppLogDict: result: WorkflowAppLogDict = { diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 96075f0a437..26b228840f1 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -346,6 +346,7 @@ Check if activation token is valid | mode | query | App mode filter | No | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow",
**Default:** all | | name | query | Filter by app name | No | string | | page | query | Page number (1-99999) | No | integer,
**Default:** 1 | +| publication_status | query | Filter by published or draft Agent configuration status | No | string,
**Available values:** "drafts", "published" | | sort_by | query | Sort apps by last modified, recently created, or earliest created | No | string,
**Available values:** "earliest_created", "last_modified", "recently_created",
**Default:** last_modified | | tag_ids | query | Filter by tag IDs | No | [ string ] | @@ -15600,6 +15601,20 @@ default (the config form sends the full desired feature state on save). | suggested_questions_after_answer | [AgentSuggestedQuestionsAfterAnswerFeatureConfig](#agentsuggestedquestionsafteranswerfeatureconfig) | Follow-up suggestions config, e.g. {'enabled': true} | No | | text_to_speech | [AgentTextToSpeechFeatureConfig](#agenttexttospeechfeatureconfig) | Text-to-speech config | No | +#### AgentAppListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| creator_ids | [ string ] | Filter by creator account IDs | No | +| is_created_by_me | boolean | Filter by creator | No | +| limit | integer,
**Default:** 20 | Page size (1-100) | No | +| mode | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow",
**Default:** all | App mode filter
*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"all"`, `"channel"`, `"chat"`, `"completion"`, `"workflow"` | No | +| name | string | Filter by app name | No | +| page | integer,
**Default:** 1 | Page number (1-99999) | No | +| publication_status | string,
**Available values:** "drafts", "published" | Filter by published or draft Agent configuration status | No | +| sort_by | string,
**Available values:** "earliest_created", "last_modified", "recently_created",
**Default:** last_modified | Sort apps by last modified, recently created, or earliest created
*Enum:* `"earliest_created"`, `"last_modified"`, `"recently_created"` | No | +| tag_ids | [ string ] | Filter by tag IDs | No | + #### AgentAppPagination | Name | Type | Description | Required | @@ -15608,6 +15623,7 @@ default (the config form sends the full desired feature state on save). | has_more | boolean | | Yes | | limit | integer | | Yes | | page | integer | | Yes | +| publication_counts | [AgentPublicationCountsResponse](#agentpublicationcountsresponse) | | Yes | | total | integer | | Yes | #### AgentAppPartial @@ -16684,6 +16700,13 @@ section may be empty, which is how callers express "no knowledge layer". | ---- | ---- | ----------- | -------- | | AgentProviderResponse | object | | | +#### AgentPublicationCountsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| drafts | integer | Draft Agent Apps in the current list scope, excluding the publication status filter | Yes | +| published | integer | Published Agent Apps in the current list scope, excluding the publication status filter | Yes | + #### AgentPublishPayload | Name | Type | Description | Required | @@ -17831,7 +17854,7 @@ AppMCPServer Status Enum | copyright | string | | No | | custom_disclaimer | string | | No | | customize_domain | string | | No | -| customize_token_strategy | string | | No | +| customize_token_strategy | string,
**Available values:** "allow", "must", "not_allow" | | No | | default_language | string | | No | | description | string | | No | | icon | string | | No | @@ -18289,7 +18312,7 @@ TEAM: Team collaboration paid plan | files | [ object ] | | No | | inputs | object | | Yes | | query | string | | No | -| response_mode | string | | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | | No | | retriever_from | string,
**Default:** explore_app | | No | #### CompletionMessagePayload @@ -18310,7 +18333,7 @@ TEAM: Team collaboration paid plan | files | [ object ] | | No | | inputs | object | | Yes | | query | string | | No | -| response_mode | string | | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | | No | | retriever_from | string,
**Default:** explore_app | | No | #### ComplianceDownloadQuery @@ -20352,9 +20375,9 @@ Flask blueprint initialization. | ---- | ---- | ----------- | -------- | | end_date | string | End date (YYYY-MM-DD) | No | | format | string,
**Available values:** "csv", "json",
**Default:** csv | Export format
*Enum:* `"csv"`, `"json"` | No | -| from_source | string | Filter by feedback source | No | +| from_source | string,
**Available values:** "admin", "user" | Filter by feedback source | No | | has_comment | boolean | Only include feedback with comments | No | -| rating | string | Filter by rating | No | +| rating | string,
**Available values:** "dislike", "like" | Filter by rating | No | | start_date | string | Start date (YYYY-MM-DD) | No | #### FeedbackStat @@ -20752,7 +20775,7 @@ Icon information model. | ---- | ---- | ----------- | -------- | | icon | string | | No | | icon_background | string | | No | -| icon_type | string | | No | +| icon_type | string,
**Available values:** "emoji", "image" | | No | | icon_url | string | | No | #### IconType @@ -23864,7 +23887,7 @@ Enum class for large language model mode. | ---- | ---- | ----------- | -------- | | content | string | Optional text feedback providing additional detail. | No | | message_id | string | Message ID | Yes | -| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | +| rating | string,
**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | #### MessageFile @@ -23925,7 +23948,7 @@ Metadata Filtering Condition. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | conditions | [ [Condition](#condition) ] | List of metadata conditions to evaluate. | No | -| logical_operator | string | How to combine multiple conditions. | No | +| logical_operator | string,
**Available values:** "and", "or" | How to combine multiple conditions. | No | #### MetadataOperationData @@ -24058,7 +24081,7 @@ Enum class for model property key. | is_exhausted | boolean | | Yes | | is_unlimited | boolean | | Yes | | next_credit_reset_date | integer | | Yes | -| pool_type | string | | Yes | +| pool_type | string,
**Available values:** "paid", "trial" | | Yes | | quota_limit | integer | Credit limit for the effective pool; -1 means unlimited. | Yes | | quota_used | integer | | Yes | | remaining_credits | integer | Remaining credits; -1 means unlimited. | Yes | @@ -26053,7 +26076,7 @@ Model class for provider quota configuration. | ---- | ---- | ----------- | -------- | | metadata_filtering_conditions | [MetadataFilteringCondition](#metadatafilteringcondition) | Restrict retrieval to chunks whose document metadata matches the given conditions. Conditions are evaluated server-side against document metadata fields. | No | | reranking_enable | boolean | Whether reranking is enabled. | Yes | -| reranking_mode | string | Reranking mode. Required when `reranking_enable` is `true`. | No | +| reranking_mode | string,
**Available values:** "reranking_model", "weighted_score" | Reranking mode. Required when `reranking_enable` is `true`. | No | | reranking_model | [RerankingModel](#rerankingmodel) | Reranking model configuration. | No | | score_threshold | number | Minimum similarity score for results. Only effective when score threshold filtering is enabled. | No | | score_threshold_enabled | boolean | Whether score threshold filtering is enabled. | Yes | @@ -26107,7 +26130,7 @@ Model class for provider quota configuration. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| parent_mode | string | Parent-child segmentation mode. | No | +| parent_mode | string,
**Available values:** "full-doc", "paragraph" | Parent-child segmentation mode. | No | | pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | Pre-processing rules to apply before segmentation. | No | | segmentation | [Segmentation](#segmentation) | Parent chunk segmentation settings. | No | | subchunk_segmentation | [Segmentation](#segmentation) | Child chunk segmentation settings. | No | @@ -27096,7 +27119,7 @@ Query parameters for listing snippet published workflows. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | action | string,
**Available values:** "complete_task", "disable_current_workspace", "enable_current_workspace", "skip", "uncomplete_task" | State update action
*Enum:* `"complete_task"`, `"disable_current_workspace"`, `"enable_current_workspace"`, `"skip"`, `"uncomplete_task"` | Yes | -| task_id | string | Task ID for task actions | No | +| task_id | string,
**Available values:** "home", "integration", "knowledge", "studio" | Task ID for task actions | No | #### StepByStepTourStateResponse @@ -27563,7 +27586,7 @@ Tool label | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| visibility | string | Visibility for the OAuth credential. Defaults to 'only_me'. | No | +| visibility | string,
**Available values:** "all_team_members", "only_me" | Visibility for the OAuth credential. Defaults to 'only_me'. | No | #### ToolOAuthCustomClientPayload @@ -27695,7 +27718,7 @@ removes TOOLS_SELECTOR from PluginParameterType | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| type | string | | No | +| type | string,
**Available values:** "api", "builtin", "mcp", "model", "workflow" | | No | #### ToolProviderListResponse @@ -28313,7 +28336,7 @@ in form definition, or a variable while the workflow is running. | ---- | ---- | ----------- | -------- | | keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | Keyword search weight settings. | No | | vector_setting | [WeightVectorSetting](#weightvectorsetting) | Semantic search weight settings. | No | -| weight_type | string | Strategy for balancing semantic and keyword search weights. | No | +| weight_type | string,
**Available values:** "customized", "keyword_first", "semantic_first" | Strategy for balancing semantic and keyword search weights. | No | #### WeightVectorSetting @@ -28819,7 +28842,7 @@ can reuse its existing handler. | description | string | | No | | event | string | | No | | icon | string | | No | -| mode | string | *Enum:* `"advanced-chat"`, `"workflow"` | Yes | +| mode | string,
**Available values:** "advanced-chat", "workflow" | *Enum:* `"advanced-chat"`, `"workflow"` | Yes | | nodes | [ [WorkflowPlanNodeResponse](#workflowplannoderesponse) ] | | Yes | | start_inputs | [ [WorkflowPlanStartInputResponse](#workflowplanstartinputresponse) ] | | No | | title | string | | No | @@ -28834,7 +28857,7 @@ can reuse its existing handler. | graph | [WorkflowGraph](#workflowgraph) | | Yes | | icon | string | | No | | message | string | | No | -| mode | string | | No | +| mode | string,
**Available values:** "advanced-chat", "workflow" | | No | #### WorkflowGenerateResultEventResponse @@ -28847,7 +28870,7 @@ can reuse its existing handler. | graph | [WorkflowGraph](#workflowgraph) | | Yes | | icon | string | | No | | message | string | | No | -| mode | string | | No | +| mode | string,
**Available values:** "advanced-chat", "workflow" | | No | #### WorkflowGenerateStreamEventResponse @@ -29147,9 +29170,9 @@ Lifecycle state for an asynchronous archive download request. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| status | string | Workflow run status filter | No | +| status | string,
**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | Workflow run status filter | No | | time_range | string | Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), 30m (30 minutes), 30s (30 seconds). Filters by created_at field. | No | -| triggered_from | string | Filter by trigger source: debugging or app-run. Default: debugging | No | +| triggered_from | string,
**Available values:** "app-run", "debugging" | Filter by trigger source: debugging or app-run. Default: debugging | No | #### WorkflowRunCountResponse @@ -29221,8 +29244,8 @@ Lifecycle state for an asynchronous archive download request. | ---- | ---- | ----------- | -------- | | last_id | string | Last run ID for pagination | No | | limit | integer,
**Default:** 20 | Number of items per page (1-100) | No | -| status | string | Workflow run status filter | No | -| triggered_from | string | Filter by trigger source: debugging or app-run. Default: debugging | No | +| status | string,
**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | Workflow run status filter | No | +| triggered_from | string,
**Available values:** "app-run", "debugging" | Filter by trigger source: debugging or app-run. Default: debugging | No | #### WorkflowRunNodeExecutionListResponse @@ -29520,7 +29543,7 @@ Workflow tool configuration | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| language | string | Localized policy label language | No | +| language | string,
**Available values:** "en", "ja", "zh" | Localized policy label language | No | #### _AccessPolicyList @@ -29579,7 +29602,7 @@ Workflow tool configuration | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| language | string | Localized policy label language | No | +| language | string,
**Available values:** "en", "ja", "zh" | Localized policy label language | No | | limit | integer | | No | | page | integer | | No | | reverse | boolean | | No | diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index 4b541c3051f..8aebeec8811 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -2905,7 +2905,7 @@ Execute a workflow. Cannot be executed without a published workflow. | 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `WorkflowBlockingResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkWorkflowEvent` objects. | **application/json**: [WorkflowBlockingResponse](#workflowblockingresponse)
**text/event-stream**: string
| | 400 | - `not_workflow_app` : App mode does not match the API route. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Workflow execution request failed. - `invalid_param` : Invalid parameter value. | | | 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 403 | - `forbidden` : Token scope, app, or workspace access denied. - `trigger_workflow_service_mode_unavailable` : Trigger-entry workflows cannot be invoked through Web App, Service API, OpenAPI, or MCP. | | | 404 | Workflow not found | | | 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | | | 500 | `internal_server_error` : Internal server error. | | @@ -2981,7 +2981,7 @@ Execute a specific workflow version identified by its ID. Useful for running a p | 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `WorkflowBlockingResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkWorkflowEvent` objects. | **application/json**: [WorkflowBlockingResponse](#workflowblockingresponse)
**text/event-stream**: string
| | 400 | - `not_workflow_app` : App mode does not match the API route. - `bad_request` : Workflow is a draft or has an invalid ID format. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Workflow execution request failed. - `invalid_param` : Required parameter missing or invalid. | | | 401 | Unauthorized - invalid API token | | -| 403 | `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the current plan. Upgrade to a paid plan. | | +| 403 | - `forbidden` : Token scope, app, or workspace access denied. - `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the current plan. Upgrade to a paid plan. - `trigger_workflow_service_mode_unavailable` : The selected workflow version uses a trigger entry and cannot be invoked through Web App, Service API, OpenAPI, or MCP. | | | 404 | `not_found` : Workflow not found. | | | 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | | | 500 | `internal_server_error` : Internal server error. | | @@ -3281,7 +3281,7 @@ Public pause reason emitted by a blocking Chatflow execution. | files | [ object
object
object
object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | | inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes | | query | string | User input or question content. | Yes | -| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No | | workflow_id | string | Published workflow version ID to execute for advanced chat. If omitted, the app's current published workflow is used. | No | #### ChatRequestPayloadWithUser @@ -3293,7 +3293,7 @@ Public pause reason emitted by a blocking Chatflow execution. | files | [ object
object
object
object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | | inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes | | query | string | User input or question content. | Yes | -| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No | | user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes | | workflow_id | string | Published workflow version ID to execute for advanced chat. If omitted, the app's current published workflow is used. | No | @@ -3366,7 +3366,7 @@ Public pause reason emitted by a blocking Chatflow execution. | files | [ object
object
object
object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | | inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes | | query | string | User input or prompt content. | No | -| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No | #### CompletionRequestPayloadWithUser @@ -3375,7 +3375,7 @@ Public pause reason emitted by a blocking Chatflow execution. | files | [ object
object
object
object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | | inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes | | query | string | User input or prompt content. | No | -| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No | | user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes | #### Condition @@ -3491,7 +3491,7 @@ Enum class for custom configuration status. | embedding_model_provider | string | Embedding model provider. Use the `provider` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No | | external_knowledge_api_id | string | ID of the external knowledge API. | No | | external_knowledge_id | string | ID of the external knowledge base. | No | -| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No | +| indexing_technique | string,
**Available values:** "economy", "high_quality" | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No | | name | string | Name of the knowledge base. | Yes | | permission | [PermissionEnum](#permissionenum) | Controls who can access this knowledge base. `only_me` restricts access to the creator, `all_team_members` grants workspace-wide access, and `partial_members` grants access to specified members. | No | | provider | string,
**Available values:** "external", "vendor",
**Default:** vendor | Knowledge base provider: `vendor` for internal knowledge bases, `external` for external ones.
*Enum:* `"external"`, `"vendor"` | No | @@ -3733,7 +3733,7 @@ Enum class for custom configuration status. | external_knowledge_api_id | string | ID of the external knowledge API. | No | | external_knowledge_id | string | ID of the external knowledge base. | No | | external_retrieval_model | object | Retrieval settings for external knowledge bases. | No | -| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No | +| indexing_technique | string,
**Available values:** "economy", "high_quality" | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No | | name | string | Name of the knowledge base. | No | | partial_member_list | [ object ] | List of team members with access when `permission` is `partial_members`. | No | | permission | [PermissionEnum](#permissionenum) | Controls who can access this knowledge base. `only_me` restricts access to the creator, `all_team_members` grants workspace-wide access, and `partial_members` grants access to specified members. | No | @@ -3861,7 +3861,7 @@ Request payload for bulk downloading documents as a zip archive. | keyword | string | Search keyword to filter by document name. | No | | limit | integer,
**Default:** 20 | Number of items per page. Server caps at `100`. | No | | page | integer,
**Default:** 1 | Page number to retrieve. | No | -| status | string | Filter by display status. | No | +| status | string,
**Available values:** "archived", "available", "disabled", "error", "indexing", "paused", "queuing" | Filter by display status. | No | #### DocumentListResponse @@ -3959,7 +3959,7 @@ Request payload for bulk downloading documents as a zip archive. | doc_language | string,
**Default:** English | Language of the document for processing optimization. | No | | embedding_model | string | Embedding model name. Use the `model` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No | | embedding_model_provider | string | Embedding model provider. Use the `provider` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No | -| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. Required when adding the first document to a knowledge base; subsequent documents inherit the knowledge base's indexing technique if omitted. | No | +| indexing_technique | string,
**Available values:** "economy", "high_quality" | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. Required when adding the first document to a knowledge base; subsequent documents inherit the knowledge base's indexing technique if omitted. | No | | name | string | Document name. | Yes | | original_document_id | string | Original document ID for replacement. | No | | process_rule | [ProcessRule](#processrule) | Processing rules for chunking. | No | @@ -5249,14 +5249,14 @@ Model class for i18n object. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | content | string | Optional text feedback providing additional detail. | No | -| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | +| rating | string,
**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | #### MessageFeedbackPayloadWithUser | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | content | string | Optional text feedback providing additional detail. | No | -| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | +| rating | string,
**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | | user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes | #### MessageFile @@ -5336,7 +5336,7 @@ Metadata Filtering Condition. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | conditions | [ [Condition](#condition) ] | List of metadata conditions to evaluate. | No | -| logical_operator | string | How to combine multiple conditions. | No | +| logical_operator | string,
**Available values:** "and", "or" | How to combine multiple conditions. | No | #### MetadataOperationData @@ -5570,7 +5570,7 @@ Model class for provider with models response. | ---- | ---- | ----------- | -------- | | metadata_filtering_conditions | [MetadataFilteringCondition](#metadatafilteringcondition) | Restrict retrieval to chunks whose document metadata matches the given conditions. Conditions are evaluated server-side against document metadata fields. | No | | reranking_enable | boolean | Whether reranking is enabled. | Yes | -| reranking_mode | string | Reranking mode. Required when `reranking_enable` is `true`. | No | +| reranking_mode | string,
**Available values:** "reranking_model", "weighted_score" | Reranking mode. Required when `reranking_enable` is `true`. | No | | reranking_model | [RerankingModel](#rerankingmodel) | Reranking model configuration. | No | | score_threshold | number | Minimum similarity score for results. Only effective when score threshold filtering is enabled. | No | | score_threshold_enabled | boolean | Whether score threshold filtering is enabled. | Yes | @@ -5604,7 +5604,7 @@ Model class for provider with models response. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| parent_mode | string | Parent-child segmentation mode. | No | +| parent_mode | string,
**Available values:** "full-doc", "paragraph" | Parent-child segmentation mode. | No | | pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | Pre-processing rules to apply before segmentation. | No | | segmentation | [Segmentation](#segmentation) | Parent chunk segmentation settings. | No | | subchunk_segmentation | [Segmentation](#segmentation) | Child chunk segmentation settings. | No | @@ -5935,7 +5935,7 @@ in form definition, or a variable while the workflow is running. | ---- | ---- | ----------- | -------- | | keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | Keyword search weight settings. | No | | vector_setting | [WeightVectorSetting](#weightvectorsetting) | Semantic search weight settings. | No | -| weight_type | string | Strategy for balancing semantic and keyword search weights. | No | +| weight_type | string,
**Available values:** "customized", "keyword_first", "semantic_first" | Strategy for balancing semantic and keyword search weights. | No | #### WeightVectorSetting @@ -6018,7 +6018,7 @@ Blocking workflow response for a finished or paused execution. | keyword | string | Keyword to search in logs. | No | | limit | integer,
**Default:** 20 | Number of items per page. | No | | page | integer,
**Default:** 1 | Page number for pagination. | No | -| status | string | Filter by execution status. | No | +| status | string,
**Available values:** "failed", "stopped", "succeeded" | Filter by execution status. | No | #### WorkflowPauseReasonResponse @@ -6087,7 +6087,7 @@ Public pause reason emitted by a blocking Workflow execution. | ---- | ---- | ----------- | -------- | | files | [ object
object
object
object ] | File list for workflow system file inputs. Available when file upload is enabled for the workflow. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | | inputs | object | Key-value pairs for workflow input variables. Values for file-type variables should be arrays of file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover the variable names and types expected by your app. | Yes | -| response_mode | string | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No | #### WorkflowRunPayloadWithUser @@ -6095,7 +6095,7 @@ Public pause reason emitted by a blocking Workflow execution. | ---- | ---- | ----------- | -------- | | files | [ object
object
object
object ] | File list for workflow system file inputs. Available when file upload is enabled for the workflow. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | | inputs | object | Key-value pairs for workflow input variables. Values for file-type variables should be arrays of file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover the variable names and types expected by your app. | Yes | -| response_mode | string | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No | | user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes | #### WorkflowRunResponse diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index 137d8e523e6..9c1d8c026a5 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -1019,7 +1019,7 @@ Button styles for user actions. | inputs | object | Input variables for the chat | Yes | | parent_message_id | string | Parent message ID | No | | query | string | User query/message | Yes | -| response_mode | string | Response mode: blocking or streaming | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode: blocking or streaming | No | | retriever_from | string,
**Default:** web_app | Source of retriever | No | #### CompletionMessagePayload @@ -1029,7 +1029,7 @@ Button styles for user actions. | files | [ object ] | Files to be processed | No | | inputs | object | Input variables for the completion | Yes | | query | string | Query text for completion | No | -| response_mode | string | Response mode: blocking or streaming | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode: blocking or streaming | No | | retriever_from | string,
**Default:** web_app | Source of retriever | No | #### ConversationInfiniteScrollPagination @@ -1322,7 +1322,7 @@ Parsed multipart form fields for HITL uploads. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | content | string | Optional text feedback providing additional detail. | No | -| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | +| rating | string,
**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | #### MessageFile diff --git a/api/repositories/account_repository.py b/api/repositories/account_repository.py index 1e7fb55e0d3..54568c7321c 100644 --- a/api/repositories/account_repository.py +++ b/api/repositories/account_repository.py @@ -31,6 +31,14 @@ class SQLAlchemyAccountRepository(AccountRepository): account = session.get(Account, account_id) return self._to_snapshot(account) if account is not None else None + @override + def find_by_email(self, email: str) -> AccountSnapshot | None: + with self._session_factory() as session: + account = session.scalar(select(Account).where(Account.email == email).limit(1)) + if account is None and email != email.lower(): + account = session.scalar(select(Account).where(Account.email == email.lower()).limit(1)) + return self._to_snapshot(account) if account is not None else None + @override def get_credentials(self, account_id: str) -> AccountCredentials | None: with self._session_factory() as session: diff --git a/api/repositories/step_by_step_tour_repository.py b/api/repositories/step_by_step_tour_repository.py new file mode 100644 index 00000000000..7dc7a6d6bf2 --- /dev/null +++ b/api/repositories/step_by_step_tour_repository.py @@ -0,0 +1,189 @@ +"""SQLAlchemy repository for account Step-by-step Tour state.""" + +import logging +from collections.abc import Callable +from typing import Protocol, override, runtime_checkable + +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError, OperationalError +from sqlalchemy.orm import Session, sessionmaker + +from models.onboarding import AccountStepByStepTourState +from services.entities.onboarding_entities import StepByStepTourState +from services.step_by_step_tour_service import StepByStepTourStateRepository + +logger = logging.getLogger(__name__) + +_MYSQL_RETRYABLE_LOCK_ERRNOS = frozenset({1205, 1213}) +_MAX_LOCK_ATTEMPTS = 3 + + +@runtime_checkable +class _ErrorWithErrno(Protocol): + @property + def errno(self) -> object: ... + + +class SQLAlchemyStepByStepTourStateRepository(StepByStepTourStateRepository): + def __init__(self, session_factory: sessionmaker[Session]) -> None: + self._session_factory = session_factory + + @override + def get(self, account_id: str) -> StepByStepTourState | None: + with self._session_factory() as session: + model = self._get_model(account_id, session=session) + return self._to_state(model) if model is not None else None + + @override + def initialize(self, account_id: str, first_workspace_id: str) -> StepByStepTourState: + """Create state with its first workspace, or atomically claim a legacy empty state.""" + return self._run_with_lock_retry( + lambda: self._initialize_once(account_id, first_workspace_id), + ) + + def _initialize_once(self, account_id: str, first_workspace_id: str) -> StepByStepTourState: + with self._session_factory() as session: + model = self._get_model(account_id, session=session) + if model is None: + model = AccountStepByStepTourState( + account_id=account_id, + first_workspace_id=first_workspace_id, + ) + session.add(model) + try: + session.commit() + except IntegrityError: + # A concurrent request inserted the account-owned row first. + session.rollback() + model = self._get_model(account_id, session=session) + if model is None: + raise + else: + session.refresh(model) + return self._to_state(model) + + if model.first_workspace_id is None: + stmt = ( + update(AccountStepByStepTourState) + .where( + AccountStepByStepTourState.account_id == account_id, + AccountStepByStepTourState.first_workspace_id.is_(None), + ) + .values(first_workspace_id=first_workspace_id) + .execution_options(synchronize_session=False) + ) + session.execute(stmt) + session.commit() + # A competing conditional update may have won while this request waited. + session.refresh(model) + + return self._to_state(model) + + @override + def mutate( + self, + account_id: str, + mutation: Callable[[StepByStepTourState], StepByStepTourState], + ) -> StepByStepTourState: + """Lock, create if needed, mutate, and persist account state in one transaction.""" + return self._run_with_lock_retry( + lambda: self._mutate_once(account_id, mutation), + ) + + def _mutate_once( + self, + account_id: str, + mutation: Callable[[StepByStepTourState], StepByStepTourState], + ) -> StepByStepTourState: + with self._session_factory() as session: + # Probe without a locking read so a missing MySQL unique key does not + # acquire a gap/next-key lock before the insert. + model = self._get_model(account_id, session=session) + if model is None: + model = AccountStepByStepTourState(account_id=account_id) + session.add(model) + try: + session.flush() + except IntegrityError: + # A concurrent mutation created the row. Start a new transaction, + # lock its committed state, and replay the pure mutation on it. + session.rollback() + model = self._get_model(account_id, session=session, lock_for_update=True) + if model is None: + raise + else: + model = self._get_model(account_id, session=session, lock_for_update=True) + if model is None: + raise RuntimeError("Step-by-step Tour state disappeared while acquiring its lock") + + state = mutation(self._to_state(model)) + if state.account_id != account_id: + raise ValueError("Step-by-step Tour mutation cannot change account ownership") + # first_workspace_id is write-once and owned exclusively by initialize(). + model.skipped = state.skipped + model.completed_task_ids = list(state.completed_task_ids) + model.manually_enabled_workspace_ids = list(state.manually_enabled_workspace_ids) + model.manually_disabled_workspace_ids = list(state.manually_disabled_workspace_ids) + session.commit() + session.refresh(model) + return self._to_state(model) + + @staticmethod + def _run_with_lock_retry[T](operation: Callable[[], T]) -> T: + for attempt in range(1, _MAX_LOCK_ATTEMPTS): + try: + return operation() + except OperationalError as exc: + if not _is_retryable_mysql_lock_error(exc): + raise + logger.warning( + "Retrying Step-by-step Tour transaction after MySQL lock failure (attempt %s/%s)", + attempt, + _MAX_LOCK_ATTEMPTS, + ) + return operation() + + @staticmethod + def _get_model( + account_id: str, + *, + session: Session, + lock_for_update: bool = False, + ) -> AccountStepByStepTourState | None: + stmt = select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == account_id).limit(1) + if lock_for_update: + stmt = stmt.with_for_update().execution_options(populate_existing=True) + return session.execute(stmt).scalar_one_or_none() + + @staticmethod + def _to_state(model: AccountStepByStepTourState) -> StepByStepTourState: + return StepByStepTourState( + account_id=model.account_id, + first_workspace_id=model.first_workspace_id, + skipped=model.skipped, + completed_task_ids=tuple(model.completed_task_ids), + manually_enabled_workspace_ids=tuple(model.manually_enabled_workspace_ids), + manually_disabled_workspace_ids=tuple(model.manually_disabled_workspace_ids), + updated_at=model.updated_at, + ) + + +def _is_retryable_mysql_lock_error(exc: OperationalError) -> bool: + orig = exc.orig + if isinstance(orig, _ErrorWithErrno) and _is_retryable_mysql_lock_error_code(orig.errno): + return True + if not isinstance(orig, BaseException) or not orig.args: + return False + return _is_retryable_mysql_lock_error_code(orig.args[0]) + + +def _is_retryable_mysql_lock_error_code(candidate: object) -> bool: + if isinstance(candidate, bool): + return False + if isinstance(candidate, int): + code = candidate + elif isinstance(candidate, str) and candidate.isdecimal(): + code = int(candidate) + else: + return False + return code in _MYSQL_RETRYABLE_LOCK_ERRNOS diff --git a/api/services/account_email_registration_adapters.py b/api/services/account_email_registration_adapters.py new file mode 100644 index 00000000000..5bd25b33b6e --- /dev/null +++ b/api/services/account_email_registration_adapters.py @@ -0,0 +1,230 @@ +"""Infrastructure adapters for account email registration.""" + +import logging +import secrets +from typing import override + +from redis import RedisError +from sqlalchemy.orm import Session, sessionmaker + +from extensions.ext_redis import RedisClientWrapper +from libs.helper import RateLimiter, TokenManager +from models.account import Account +from services.account_email_registration_service import ( + AccountRegistrationGateway, + AccountRegistrationPolicyGateway, + EmailRegistrationCodeGenerator, + EmailRegistrationNotificationGateway, + EmailRegistrationSecurityGateway, + EmailRegistrationSendLimiter, + EmailRegistrationTokenGateway, +) +from services.account_errors import ( + AccountEmailDomainSuspendedError, + AccountEmailFrozenError, + AccountNormalizedEmailAlreadyInUseError, + EmailRegistrationSeatsLimitError, +) +from services.account_service import AccountService +from services.billing_service import BillingService +from services.entities.account_entities import ( + AccountEmailRegistrationPhase, + AccountEmailRegistrationToken, + AccountSessionTokens, +) +from services.errors.account import ( + AccountNormalizedEmailAlreadyInUseError as AccountNormalizedEmailAlreadyInUseServiceError, +) +from services.errors.account import AccountRegisterError, EmailDomainSuspendedError, SeatsLimitExceededError +from tasks.mail_register_task import send_email_register_mail_task, send_email_register_mail_task_when_account_exist + +logger = logging.getLogger(__name__) + + +class TokenManagerEmailRegistrationTokenGateway(EmailRegistrationTokenGateway): + @override + def get(self, token: str) -> AccountEmailRegistrationToken | None: + payload = TokenManager.get_token_data(token, "email_register") + if payload is None: + return None + email = payload.get("email") + code = payload.get("code") + phase_value = payload.get("phase") + if not isinstance(email, str) or not isinstance(code, str): + return None + if phase_value is None: + phase = None + else: + try: + phase = AccountEmailRegistrationPhase(phase_value) + except (TypeError, ValueError): + return None + return AccountEmailRegistrationToken(email=email, code=code, phase=phase) + + @override + def issue(self, token_data: AccountEmailRegistrationToken) -> str: + additional_data = {"code": token_data.code} + if token_data.phase is not None: + additional_data["phase"] = token_data.phase.value + return TokenManager.generate_token( + email=token_data.email, + token_type="email_register", + additional_data=additional_data, + ) + + @override + def revoke(self, token: str) -> None: + TokenManager.revoke_token(token, "email_register") + + +class SecureEmailRegistrationCodeGenerator(EmailRegistrationCodeGenerator): + @override + def generate(self) -> str: + return "".join(str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)) + + +class CeleryEmailRegistrationNotificationGateway(EmailRegistrationNotificationGateway): + @override + def send_code(self, *, email: str, code: str, language: str) -> None: + send_email_register_mail_task.delay(language=language, to=email, code=code) + + @override + def send_account_exists(self, *, email: str, account_name: str, language: str) -> None: + send_email_register_mail_task_when_account_exist.delay( + language=language, + to=email, + account_name=account_name, + ) + + +class RateLimiterEmailRegistrationSendLimiter(EmailRegistrationSendLimiter): + def __init__(self, *, rate_limiter: RateLimiter) -> None: + self._rate_limiter = rate_limiter + + @override + def is_limited(self, email: str) -> bool: + return self._rate_limiter.is_rate_limited(email) + + @override + def record(self, email: str) -> None: + self._rate_limiter.increment_rate_limit(email) + + @property + @override + def retry_after_minutes(self) -> int: + return int(self._rate_limiter.time_window / 60) + + +class RedisEmailRegistrationSecurityGateway(EmailRegistrationSecurityGateway): + def __init__( + self, + *, + redis: RedisClientWrapper, + verification_failure_limit: int, + verification_lockout_duration: int, + ) -> None: + self._redis = redis + self._verification_failure_limit = verification_failure_limit + self._verification_lockout_duration = verification_lockout_duration + + @override + def is_ip_limited(self, ip_address: str) -> bool: + return AccountService.is_email_send_ip_limit(ip_address) is True + + @override + def is_verification_limited(self, email: str) -> bool: + try: + count = self._redis.get(self._verification_key(email)) + return count is not None and int(count) > self._verification_failure_limit + except RedisError: + logger.warning("Failed to read email-registration verification limit", exc_info=True) + return False + + @override + def record_verification_failure(self, email: str) -> None: + try: + key = self._verification_key(email) + count = int(self._redis.get(key) or 0) + 1 + self._redis.setex(key, self._verification_lockout_duration, count) + except RedisError: + logger.warning("Failed to record email-registration verification failure", exc_info=True) + return None + + @override + def reset_verification_failures(self, email: str) -> None: + try: + self._redis.delete(self._verification_key(email)) + except RedisError: + logger.warning("Failed to reset email-registration verification failures", exc_info=True) + return None + + @override + def reset_login_failures(self, email: str) -> None: + AccountService.reset_login_error_rate_limit(email) + + @staticmethod + def _verification_key(email: str) -> str: + return f"email_register_error_rate_limit:{email}" + + +class BillingAccountRegistrationPolicyGateway(AccountRegistrationPolicyGateway): + def __init__(self, *, enabled: bool) -> None: + self._enabled = enabled + + @override + def get_freeze_type(self, email: str) -> str | None: + if not self._enabled: + return None + return BillingService.get_email_freeze_type(email) + + +class AccountServiceRegistrationGateway(AccountRegistrationGateway): + """Compatibility adapter around account provisioning and login internals.""" + + def __init__(self, *, session_factory: sessionmaker[Session]) -> None: + self._session_factory = session_factory + + @override + def create( + self, + *, + email: str, + password: str, + interface_language: str, + timezone: str | None, + ip_address: str, + ) -> str: + with self._session_factory() as session: + try: + account = AccountService.create_account_and_tenant( + email=email, + name=email, + password=password, + interface_language=interface_language, + timezone=timezone, + ip_address=ip_address, + check_normalized_email=True, + session=session, + ) + except SeatsLimitExceededError as exc: + raise EmailRegistrationSeatsLimitError from exc + except EmailDomainSuspendedError as exc: + raise AccountEmailDomainSuspendedError from exc + except AccountNormalizedEmailAlreadyInUseServiceError as exc: + raise AccountNormalizedEmailAlreadyInUseError from exc + except AccountRegisterError as exc: + raise AccountEmailFrozenError from exc + return account.id + + @override + def login(self, account_id: str, *, ip_address: str) -> AccountSessionTokens: + with self._session_factory() as session: + account = session.get(Account, account_id) + if account is None: + raise RuntimeError("newly registered account no longer exists") + token_pair = AccountService.login(account=account, session=session, ip_address=ip_address) + return AccountSessionTokens( + access_token=token_pair.access_token, + refresh_token=token_pair.refresh_token, + csrf_token=token_pair.csrf_token, + ) diff --git a/api/services/account_email_registration_service.py b/api/services/account_email_registration_service.py new file mode 100644 index 00000000000..2379f220254 --- /dev/null +++ b/api/services/account_email_registration_service.py @@ -0,0 +1,207 @@ +"""Application service for the account email-registration use case.""" + +from typing import Protocol + +from constants.languages import get_valid_language, languages +from services.account_errors import ( + AccountEmailAlreadyInUseError, + AccountEmailDomainSuspendedError, + AccountEmailFrozenError, + EmailRegistrationPasswordMismatchError, + EmailRegistrationSendIPLimitedError, + EmailRegistrationSendRateLimitError, + EmailRegistrationVerificationLimitError, + InvalidEmailRegistrationAddressError, + InvalidEmailRegistrationCodeError, + InvalidEmailRegistrationTokenError, +) +from services.account_ports import AccountRepository +from services.entities.account_entities import ( + AccountEmailRegistrationPhase, + AccountEmailRegistrationToken, + AccountEmailRegistrationVerification, + AccountSessionTokens, +) + + +class EmailRegistrationTokenGateway(Protocol): + def get(self, token: str) -> AccountEmailRegistrationToken | None: ... + + def issue(self, token_data: AccountEmailRegistrationToken) -> str: ... + + def revoke(self, token: str) -> None: ... + + +class EmailRegistrationCodeGenerator(Protocol): + def generate(self) -> str: ... + + +class EmailRegistrationNotificationGateway(Protocol): + def send_code(self, *, email: str, code: str, language: str) -> None: ... + + def send_account_exists(self, *, email: str, account_name: str, language: str) -> None: ... + + +class EmailRegistrationSendLimiter(Protocol): + def is_limited(self, email: str) -> bool: ... + + def record(self, email: str) -> None: ... + + @property + def retry_after_minutes(self) -> int: ... + + +class EmailRegistrationSecurityGateway(Protocol): + def is_ip_limited(self, ip_address: str) -> bool: ... + + def is_verification_limited(self, email: str) -> bool: ... + + def record_verification_failure(self, email: str) -> None: ... + + def reset_verification_failures(self, email: str) -> None: ... + + def reset_login_failures(self, email: str) -> None: ... + + +class AccountRegistrationPolicyGateway(Protocol): + def get_freeze_type(self, email: str) -> str | None: ... + + +class AccountRegistrationGateway(Protocol): + def create( + self, + *, + email: str, + password: str, + interface_language: str, + timezone: str | None, + ip_address: str, + ) -> str: ... + + def login(self, account_id: str, *, ip_address: str) -> AccountSessionTokens: ... + + +class AccountEmailRegistrationService: + def __init__( + self, + *, + accounts: AccountRepository, + tokens: EmailRegistrationTokenGateway, + codes: EmailRegistrationCodeGenerator, + notifications: EmailRegistrationNotificationGateway, + send_limits: EmailRegistrationSendLimiter, + security: EmailRegistrationSecurityGateway, + account_policy: AccountRegistrationPolicyGateway, + registration: AccountRegistrationGateway, + ) -> None: + self._accounts = accounts + self._tokens = tokens + self._codes = codes + self._notifications = notifications + self._send_limits = send_limits + self._security = security + self._account_policy = account_policy + self._registration = registration + + def send_code( + self, + *, + remote_ip: str, + requested_email: str, + requested_language: str | None, + ) -> str: + if self._security.is_ip_limited(remote_ip): + raise EmailRegistrationSendIPLimitedError + + normalized_email = requested_email.lower() + self._ensure_email_allowed(normalized_email) + account = self._accounts.find_by_email(requested_email) + delivery_email = account.email if account is not None else normalized_email + if self._send_limits.is_limited(delivery_email): + raise EmailRegistrationSendRateLimitError(self._send_limits.retry_after_minutes) + + language = requested_language if requested_language is not None and requested_language in languages else "en-US" + code = self._codes.generate() + token = self._tokens.issue(AccountEmailRegistrationToken(email=delivery_email, code=code)) + if account is None: + self._notifications.send_code(email=delivery_email, code=code, language=language) + else: + self._notifications.send_account_exists( + email=delivery_email, + account_name=account.name, + language=language, + ) + self._send_limits.record(delivery_email) + return token + + def verify_code( + self, + *, + email: str, + code: str, + token: str, + ) -> AccountEmailRegistrationVerification: + normalized_email = email.lower() + if self._security.is_verification_limited(normalized_email): + raise EmailRegistrationVerificationLimitError + + token_data = self._tokens.get(token) + if token_data is None: + raise InvalidEmailRegistrationTokenError + normalized_token_email = token_data.email.lower() + if normalized_email != normalized_token_email: + raise InvalidEmailRegistrationAddressError + if code != token_data.code: + self._security.record_verification_failure(normalized_email) + raise InvalidEmailRegistrationCodeError + + self._tokens.revoke(token) + verified_token = self._tokens.issue( + AccountEmailRegistrationToken( + email=normalized_email, + code=code, + phase=AccountEmailRegistrationPhase.REGISTER, + ) + ) + self._security.reset_verification_failures(normalized_email) + return AccountEmailRegistrationVerification(email=normalized_token_email, token=verified_token) + + def register( + self, + *, + remote_ip: str, + token: str, + new_password: str, + password_confirm: str, + language: str | None, + timezone: str | None, + ) -> AccountSessionTokens: + if new_password != password_confirm: + raise EmailRegistrationPasswordMismatchError + + token_data = self._tokens.get(token) + if token_data is None or token_data.phase != AccountEmailRegistrationPhase.REGISTER: + raise InvalidEmailRegistrationTokenError + self._tokens.revoke(token) + + normalized_email = token_data.email.lower() + if self._accounts.find_by_email(token_data.email) is not None: + raise AccountEmailAlreadyInUseError + + account_id = self._registration.create( + email=normalized_email, + password=password_confirm, + interface_language=get_valid_language(language), + timezone=timezone, + ip_address=remote_ip, + ) + tokens = self._registration.login(account_id, ip_address=remote_ip) + self._security.reset_login_failures(normalized_email) + return tokens + + def _ensure_email_allowed(self, email: str) -> None: + freeze_type = self._account_policy.get_freeze_type(email) + if freeze_type == "email_domain_suspended": + raise AccountEmailDomainSuspendedError + if freeze_type: + raise AccountEmailFrozenError diff --git a/api/services/account_errors.py b/api/services/account_errors.py index c902c7d331b..115e0e6f511 100644 --- a/api/services/account_errors.py +++ b/api/services/account_errors.py @@ -85,6 +85,46 @@ class AccountEmailAlreadyInUseError(AccountApplicationError): """The target email already belongs to an account.""" +class AccountNormalizedEmailAlreadyInUseError(AccountEmailAlreadyInUseError): + """A normalized equivalent of the target email already belongs to an account.""" + + +class EmailRegistrationSendIPLimitedError(AccountApplicationError): + """The caller IP exceeded the registration-email send policy.""" + + +class EmailRegistrationSendRateLimitError(AccountApplicationError): + """Too many registration messages were requested for the address.""" + + def __init__(self, retry_after_minutes: int) -> None: + super().__init__(retry_after_minutes) + self.retry_after_minutes = retry_after_minutes + + +class EmailRegistrationVerificationLimitError(AccountApplicationError): + """Too many invalid registration-code attempts were made.""" + + +class InvalidEmailRegistrationTokenError(AccountApplicationError): + """The registration token is absent, malformed, or in the wrong phase.""" + + +class InvalidEmailRegistrationAddressError(AccountApplicationError): + """The request address does not match the registration token.""" + + +class InvalidEmailRegistrationCodeError(AccountApplicationError): + """The verification code does not match the registration token.""" + + +class EmailRegistrationPasswordMismatchError(AccountApplicationError): + """The registration password confirmation does not match.""" + + +class EmailRegistrationSeatsLimitError(AccountApplicationError): + """The deployment has no licensed seat available for another account.""" + + class EducationDiscountPausedError(AccountApplicationError): """Education discount activation is temporarily paused.""" diff --git a/api/services/account_ports.py b/api/services/account_ports.py index 39afd92bd1f..78792664127 100644 --- a/api/services/account_ports.py +++ b/api/services/account_ports.py @@ -19,6 +19,8 @@ from services.entities.account_entities import ( class AccountRepository(Protocol): def get(self, account_id: str) -> AccountSnapshot | None: ... + def find_by_email(self, email: str) -> AccountSnapshot | None: ... + def get_credentials(self, account_id: str) -> AccountCredentials | None: ... def update_profile(self, account_id: str, changes: AccountProfileChanges) -> AccountSnapshot | None: ... diff --git a/api/services/account_service.py b/api/services/account_service.py index b12b80c2438..4f7178fd737 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -93,7 +93,6 @@ from tasks.mail_owner_transfer_task import ( send_old_owner_transfer_notify_email_task, send_owner_transfer_confirm_task, ) -from tasks.mail_register_task import send_email_register_mail_task, send_email_register_mail_task_when_account_exist from tasks.mail_reset_password_task import ( send_reset_password_mail_task, send_reset_password_mail_task_when_account_not_exist, @@ -157,7 +156,6 @@ class AccountService: CHANGE_EMAIL_PHASE_NEW = ChangeEmailPhase.NEW_EMAIL reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1) - email_register_rate_limiter = RateLimiter(prefix="email_register_rate_limit", max_attempts=1, time_window=60 * 1) email_code_login_rate_limiter = RateLimiter( prefix="email_code_login_rate_limit", max_attempts=3, time_window=300 * 1 ) @@ -168,7 +166,16 @@ class AccountService: FORGOT_PASSWORD_MAX_ERROR_LIMITS = 5 CHANGE_EMAIL_MAX_ERROR_LIMITS = 5 OWNER_TRANSFER_MAX_ERROR_LIMITS = 5 - EMAIL_REGISTER_MAX_ERROR_LIMITS = 5 + + @staticmethod + def _resolve_role_id_by_tag(tenant_id: str, account_id: str, tag: str) -> str: + options = ListOption(page_number=1, results_per_page=100) + roles = RBACService.Roles.list(tenant_id, account_id, options=options).data + for rbac_role in roles: + if rbac_role.is_builtin and rbac_role.category == "global_system_default" and rbac_role.role_tag == tag: + return str(rbac_role.id) + + raise ValueError(f"Builtin RBAC role not found for tag {tag!r} in tenant {tenant_id}") @staticmethod def _resolve_legacy_role_id(tenant_id: str, account_id: str, role: TenantAccountRole) -> str: @@ -177,9 +184,6 @@ class AccountService: Looks up the builtin RBAC role whose tag matches the legacy role name (e.g. ``TenantAccountRole.ADMIN`` → builtin role with tag ``"admin"``). """ - options = ListOption(page_number=1, results_per_page=100) - roles = RBACService.Roles.list(tenant_id, account_id, options=options).data - expected_tag = { TenantAccountRole.OWNER: "owner", TenantAccountRole.ADMIN: "admin", @@ -187,15 +191,7 @@ class AccountService: TenantAccountRole.NORMAL: "normal", TenantAccountRole.DATASET_OPERATOR: "dataset_operator", }[role] - for rbac_role in roles: - if ( - rbac_role.is_builtin - and rbac_role.category == "global_system_default" - and rbac_role.role_tag == expected_tag - ): - return str(rbac_role.id) - - raise ValueError(f"Builtin RBAC role not found for {role.value} in tenant {tenant_id}") + return AccountService._resolve_role_id_by_tag(tenant_id, account_id, expected_tag) @staticmethod def get_workspace_permission_keys(tenant_id: str, account_id: str, *, session: Session) -> set[str]: @@ -680,40 +676,6 @@ class AccountService: cls.reset_password_rate_limiter.increment_rate_limit(account_email) return token - @classmethod - def send_email_register_email( - cls, - account: Account | None = None, - email: str | None = None, - language: str = "en-US", - ): - account_email = account.email if account else email - if account_email is None: - raise ValueError("Email must be provided.") - - if cls.email_register_rate_limiter.is_rate_limited(account_email): - from controllers.console.auth.error import EmailRegisterRateLimitExceededError - - raise EmailRegisterRateLimitExceededError(int(cls.email_register_rate_limiter.time_window / 60)) - - code, token = cls.generate_email_register_token(account_email) - - if account: - send_email_register_mail_task_when_account_exist.delay( - language=language, - to=account_email, - account_name=account.name, - ) - - else: - send_email_register_mail_task.delay( - language=language, - to=account_email, - code=code, - ) - cls.email_register_rate_limiter.increment_rate_limit(account_email) - return token - @classmethod def send_change_email_email( cls, @@ -867,19 +829,6 @@ class AccountService: ) return code, token - @classmethod - def generate_email_register_token( - cls, - email: str, - code: str | None = None, - additional_data: dict[str, Any] = {}, - ): - if not code: - code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)]) - additional_data["code"] = code - token = TokenManager.generate_token(email=email, token_type="email_register", additional_data=additional_data) - return code, token - @classmethod def generate_change_email_token( cls, @@ -917,10 +866,6 @@ class AccountService: def revoke_reset_password_token(cls, token: str): TokenManager.revoke_token(token, "reset_password") - @classmethod - def revoke_email_register_token(cls, token: str): - TokenManager.revoke_token(token, "email_register") - @classmethod def revoke_change_email_token(cls, token: str): TokenManager.revoke_token(token, "change_email") @@ -933,10 +878,6 @@ class AccountService: def get_reset_password_data(cls, token: str) -> dict[str, Any] | None: return TokenManager.get_token_data(token, "reset_password") - @classmethod - def get_email_register_data(cls, token: str) -> dict[str, Any] | None: - return TokenManager.get_token_data(token, "email_register") - @classmethod def get_change_email_data(cls, token: str) -> ChangeEmailTokenData | None: token_data = TokenManager.get_token_data(token, "change_email") @@ -1067,16 +1008,6 @@ class AccountService: count = int(count) + 1 redis_client.setex(key, dify_config.FORGOT_PASSWORD_LOCKOUT_DURATION, count) - @staticmethod - @redis_fallback(default_return=None) - def add_email_register_error_rate_limit(email: str) -> None: - key = f"email_register_error_rate_limit:{email}" - count = redis_client.get(key) - if count is None: - count = 0 - count = int(count) + 1 - redis_client.setex(key, dify_config.EMAIL_REGISTER_LOCKOUT_DURATION, count) - @staticmethod @redis_fallback(default_return=False) def is_forgot_password_error_rate_limit(email: str) -> bool: @@ -1096,24 +1027,6 @@ class AccountService: key = f"forgot_password_error_rate_limit:{email}" redis_client.delete(key) - @staticmethod - @redis_fallback(default_return=False) - def is_email_register_error_rate_limit(email: str) -> bool: - key = f"email_register_error_rate_limit:{email}" - count = redis_client.get(key) - if count is None: - return False - count = int(count) - if count > AccountService.EMAIL_REGISTER_MAX_ERROR_LIMITS: - return True - return False - - @staticmethod - @redis_fallback(default_return=None) - def reset_email_register_error_rate_limit(email: str): - key = f"email_register_error_rate_limit:{email}" - redis_client.delete(key) - @staticmethod @redis_fallback(default_return=None) def add_change_email_error_rate_limit(email: str): @@ -1891,30 +1804,41 @@ class TenantService: affected_account_ids = [str(member.id)] if new_role == "owner": - # Find the current owner and change their role to 'admin' + if dify_config.RBAC_ENABLED: + old_owner_id = AccountService.get_rbac_workspace_owner_account_id( + str(tenant.id), operator.id, session=session + ) + owner_role_id = AccountService._resolve_legacy_role_id( + tenant_id=str(tenant.id), + account_id=operator.id, + role=TenantAccountRole.OWNER, + ) + no_access_role_id = AccountService._resolve_role_id_by_tag( + tenant_id=str(tenant.id), + account_id=operator.id, + tag="no_access", + ) + current_roles = RBACService.MemberRoles.get( + str(tenant.id), operator.id, old_owner_id, session=session + ).roles + remaining_role_ids = [str(r.id) for r in current_roles if str(r.id) != owner_role_id] + RBACService.MemberRoles.replace( + tenant_id=str(tenant.id), + account_id=operator.id, + member_account_id=old_owner_id, + role_ids=remaining_role_ids or [no_access_role_id], + session=session, + ) + current_owner_join = session.scalar( select(TenantAccountJoin) .where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.role == "owner") .limit(1) ) - if not dify_config.RBAC_ENABLED: - if current_owner_join: - current_owner_join.role = TenantAccountRole.ADMIN - elif current_owner_join: - admin_role_id = AccountService._resolve_legacy_role_id( - tenant_id=str(tenant.id), - account_id=operator.id, - role=TenantAccountRole.ADMIN, - ) - RBACService.MemberRoles.replace( - tenant_id=str(tenant.id), - account_id=operator.id, - member_account_id=str(current_owner_join.account_id), - role_ids=[admin_role_id], - session=session, - ) if current_owner_join and str(current_owner_join.account_id) != str(member.id): affected_account_ids.append(str(current_owner_join.account_id)) + if current_owner_join: + current_owner_join.role = TenantAccountRole.NORMAL # Update the role of the target member if dify_config.RBAC_ENABLED: @@ -1930,6 +1854,8 @@ class TenantService: role_ids=[resolved_role_id], session=session, ) + if new_tenant_role == TenantAccountRole.OWNER: + target_member_join.role = new_tenant_role else: target_member_join.role = new_tenant_role from services.knowledge_fs.membership_changes import ( diff --git a/api/services/annotation_service.py b/api/services/annotation_service.py index 087bbd9be2b..ac0de983eb9 100644 --- a/api/services/annotation_service.py +++ b/api/services/annotation_service.py @@ -120,7 +120,7 @@ class AppAnnotationService: raw_message_id = args.get("message_id") if raw_message_id: - message_id = str(raw_message_id) + message_id = raw_message_id message = session.scalar(select(Message).where(Message.id == message_id, Message.app_id == app.id).limit(1)) if not message: @@ -176,19 +176,19 @@ class AppAnnotationService: @classmethod def enable_app_annotation(cls, args: EnableAnnotationArgs, app_id: str) -> AnnotationJobStatusDict: - enable_app_annotation_key = f"enable_app_annotation_{str(app_id)}" + enable_app_annotation_key = f"enable_app_annotation_{app_id}" cache_result = redis_client.get(enable_app_annotation_key) if cache_result is not None: return {"job_id": cache_result, "job_status": "processing"} # async job job_id = str(uuid.uuid4()) - enable_app_annotation_job_key = f"enable_app_annotation_job_{str(job_id)}" + enable_app_annotation_job_key = f"enable_app_annotation_job_{job_id}" # send batch add segments task redis_client.setnx(enable_app_annotation_job_key, "waiting") current_user, current_tenant_id = current_account_with_tenant() enable_annotation_reply_task.delay( - str(job_id), + job_id, app_id, current_user.id, current_tenant_id, @@ -201,17 +201,17 @@ class AppAnnotationService: @classmethod def disable_app_annotation(cls, app_id: str) -> AnnotationJobStatusDict: _, current_tenant_id = current_account_with_tenant() - disable_app_annotation_key = f"disable_app_annotation_{str(app_id)}" + disable_app_annotation_key = f"disable_app_annotation_{app_id}" cache_result = redis_client.get(disable_app_annotation_key) if cache_result is not None: return {"job_id": cache_result, "job_status": "processing"} # async job job_id = str(uuid.uuid4()) - disable_app_annotation_job_key = f"disable_app_annotation_job_{str(job_id)}" + disable_app_annotation_job_key = f"disable_app_annotation_job_{job_id}" # send batch add segments task redis_client.setnx(disable_app_annotation_job_key, "waiting") - disable_annotation_reply_task.delay(str(job_id), app_id, current_tenant_id) + disable_annotation_reply_task.delay(job_id, app_id, current_tenant_id) return {"job_id": job_id, "job_status": "waiting"} @classmethod @@ -539,7 +539,7 @@ class AppAnnotationService: raise ValueError("The number of annotations exceeds the limit of your subscription.") # async job job_id = str(uuid.uuid4()) - indexing_cache_key = f"app_annotation_batch_import_{str(job_id)}" + indexing_cache_key = f"app_annotation_batch_import_{job_id}" # Register job in active tasks list for concurrency tracking current_time = int(naive_utc_now().timestamp() * 1000) @@ -549,7 +549,7 @@ class AppAnnotationService: # Set job status redis_client.setnx(indexing_cache_key, "waiting") - batch_import_annotations_task.delay(str(job_id), result, app_id, current_tenant_id, current_user.id) + batch_import_annotations_task.delay(job_id, result, app_id, current_tenant_id, current_user.id) except ValueError as e: return {"error_msg": str(e)} diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index 4b271c6e94e..a22a7ba5d47 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -21,11 +21,17 @@ from core.app.features.rate_limiting import RateLimit from core.app.features.rate_limiting.rate_limit import rate_limit_context from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig from core.db import session_factory +from core.trigger.constants import is_trigger_node_type from enums import DeploymentEdition, QuotaType from extensions.otel import AppGenerateHandler, trace_span from models.model import Account, App, AppMode, EndUser from models.workflow import Workflow, WorkflowRun -from services.errors.app import QuotaExceededError, WorkflowIdFormatError, WorkflowNotFoundError +from services.errors.app import ( + QuotaExceededError, + TriggerWorkflowServiceModeUnavailableError, + WorkflowIdFormatError, + WorkflowNotFoundError, +) from services.errors.llm import InvokeRateLimitError from services.quota_service import QuotaService, unlimited from services.workflow_service import WorkflowService @@ -34,6 +40,13 @@ from tasks.app_generate.workflow_execute_task import AppExecutionParams, workflo logger = logging.getLogger(__name__) SSE_TASK_START_FALLBACK_MS = 200 +_MANUAL_WORKFLOW_INVOKE_SOURCES = frozenset( + { + InvokeFrom.OPENAPI, + InvokeFrom.SERVICE_API, + InvokeFrom.WEB_APP, + } +) if TYPE_CHECKING: from controllers.console.app.workflow import LoopNodeRunPayload @@ -290,6 +303,7 @@ class AppGenerateService: case AppMode.WORKFLOW: workflow_id = args.get("workflow_id") workflow = cls._get_workflow(app_model, invoke_from, workflow_id, session=session) + cls._ensure_workflow_service_mode_available(workflow=workflow, invoke_from=invoke_from) if streaming: with rate_limit_context(rate_limit, request_id): payload = AppExecutionParams.new( @@ -343,6 +357,16 @@ class AppGenerateService: case _: raise ValueError(f"Invalid app mode {app_model.mode}") + @staticmethod + def _ensure_workflow_service_mode_available(*, workflow: Workflow, invoke_from: InvokeFrom) -> None: + if invoke_from not in _MANUAL_WORKFLOW_INVOKE_SOURCES: + return + + for _, node_data in workflow.walk_nodes(): + node_type = node_data.get("type") + if isinstance(node_type, str) and is_trigger_node_type(node_type): + raise TriggerWorkflowServiceModeUnavailableError() + @staticmethod def _get_max_active_requests(app: App) -> int: """ diff --git a/api/services/app_service.py b/api/services/app_service.py index 1a9f0497774..89b622463b7 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -90,12 +90,19 @@ class AppListBaseParams(BaseModel): class AppListParams(AppListBaseParams): status: str | None = None openapi_visible: bool = False + agent_is_published: bool | None = None class StarredAppListParams(AppListBaseParams): pass +@dataclass(frozen=True) +class AgentAppPublicationCounts: + published: int + drafts: int + + @dataclass(frozen=True) class RecentAppListItem: id: str @@ -188,6 +195,24 @@ class AppResponseView: class AppService: + @staticmethod + def _agent_app_exists_filter(tenant_id: str, *, is_published: bool | None = None) -> sa.Exists: + agent_filters = [ + Agent.tenant_id == tenant_id, + Agent.app_id == App.id, + Agent.scope == AgentScope.ROSTER, + Agent.source.in_(APP_BACKED_AGENT_SOURCES), + Agent.status == AgentStatus.ACTIVE, + ] + if is_published is not None: + has_published_config = sa.and_( + Agent.active_config_snapshot_id.is_not(None), + Agent.active_config_is_published.is_(True), + ) + agent_filters.append(has_published_config if is_published else sa.not_(has_published_config)) + + return sa.exists().where(*agent_filters).correlate(App) + @staticmethod def _build_app_list_filters( user_id: str, tenant_id: str, params: AppListBaseParams, session: Session @@ -206,17 +231,8 @@ class AppService: filters.append(App.mode == AppMode.AGENT_CHAT) elif params.mode == "agent": filters.append(App.mode == AppMode.AGENT) - filters.append( - sa.exists() - .where( - Agent.tenant_id == tenant_id, - Agent.app_id == App.id, - Agent.scope == AgentScope.ROSTER, - Agent.source.in_(APP_BACKED_AGENT_SOURCES), - Agent.status == AgentStatus.ACTIVE, - ) - .correlate(App) - ) + publication_filter = params.agent_is_published if isinstance(params, AppListParams) else None + filters.append(AppService._agent_app_exists_filter(tenant_id, is_published=publication_filter)) elif params.mode == "all": filters.append(App.mode != AppMode.AGENT) @@ -374,6 +390,31 @@ class AppService: return app_models + def get_agent_publication_counts( + self, + user_id: str, + tenant_id: str, + params: AppListParams, + session: Session, + ) -> AgentAppPublicationCounts: + unfiltered_params = params.model_copy(update={"agent_is_published": None}) + filters = self._build_app_list_filters(user_id, tenant_id, unfiltered_params, session) + if not filters: + return AgentAppPublicationCounts(published=0, drafts=0) + + published_filter = self._agent_app_exists_filter(tenant_id, is_published=True) + draft_filter = self._agent_app_exists_filter(tenant_id, is_published=False) + published_count, draft_count = session.execute( + sa.select( + sa.func.coalesce(sa.func.sum(sa.case((published_filter, 1), else_=0)), 0), + sa.func.coalesce(sa.func.sum(sa.case((draft_filter, 1), else_=0)), 0), + ) + .select_from(App) + .where(*filters) + ).one() + + return AgentAppPublicationCounts(published=int(published_count), drafts=int(draft_count)) + def get_recent_apps( self, user_id: str, diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index abe742df27f..c4b62404cf2 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -1668,7 +1668,7 @@ class DocumentService: """Fetch documents for a dataset in a single batch query.""" if not document_ids: return [] - document_id_list: list[str] = [str(document_id) for document_id in document_ids] + document_id_list: list[str] = list(document_ids) # Fetch all requested documents in one query to avoid N+1 lookups. documents: Sequence[Document] = session.scalars( select(Document).where( @@ -1704,7 +1704,7 @@ class DocumentService: if not document_ids: return 0 - document_id_list: list[str] = [str(document_id) for document_id in document_ids] + document_id_list: list[str] = list(document_ids) result = session.execute( update(Document) @@ -1865,7 +1865,7 @@ class DocumentService: """ Batch load upload files keyed by document id for ZIP downloads. """ - document_id_list: list[str] = [str(document_id) for document_id in document_ids] + document_id_list: list[str] = list(document_ids) documents = DocumentService.get_documents_by_ids( DatasetRef(tenant_id=tenant_id, dataset_id=dataset_id), document_id_list, session diff --git a/api/services/enterprise/rbac_service.py b/api/services/enterprise/rbac_service.py index e3e0fe2ed2d..336b97d74f2 100644 --- a/api/services/enterprise/rbac_service.py +++ b/api/services/enterprise/rbac_service.py @@ -405,6 +405,7 @@ _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [ "snippets.management", "tool.manage", "mcp.manage", + "agent.manage", ] _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [ @@ -436,6 +437,7 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [ "snippets.management", "tool.manage", "mcp.manage", + "agent.manage", ] _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [ @@ -454,6 +456,7 @@ _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [ "dataset.external.connect", "snippets.create_and_modify", "tool.manage", + "agent.manage", ] _LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [ @@ -462,6 +465,7 @@ _LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [ "plugin.install", "credential.use", "app_library.access", + "agent.manage", ] _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [ @@ -469,10 +473,12 @@ _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [ "plugin.install", "dataset.create_and_management", "dataset.external.connect", + "agent.manage", ] _LEGACY_APP_OWNER_KEYS: list[str] = [ "app.acl.preview", + "app.acl.access_point_manage", "app.acl.view_layout", "app.acl.test_and_run", "app.acl.edit", @@ -488,6 +494,7 @@ _LEGACY_APP_OWNER_KEYS: list[str] = [ _LEGACY_APP_ADMIN_KEYS: list[str] = [ "app.acl.preview", "app.acl.view_layout", + "app.acl.access_point_manage", "app.acl.test_and_run", "app.acl.edit", "app.acl.import_export_dsl", @@ -502,6 +509,7 @@ _LEGACY_APP_ADMIN_KEYS: list[str] = [ _LEGACY_APP_EDITOR_KEYS: list[str] = [ "app.acl.preview", + "app.acl.access_point_manage", "app.acl.view_layout", "app.acl.test_and_run", "app.acl.edit", @@ -1880,7 +1888,7 @@ class RBACService: ) ) if current_owner_join and current_owner_join.account_id != member_account_id: - current_owner_join.role = TenantAccountRole.ADMIN + current_owner_join.role = TenantAccountRole.NORMAL target_member_join.role = tenant_role session.commit() diff --git a/api/services/entities/account_entities.py b/api/services/entities/account_entities.py index 21cfc2df00d..b53a739eba2 100644 --- a/api/services/entities/account_entities.py +++ b/api/services/entities/account_entities.py @@ -116,6 +116,30 @@ class AccountEmailResetResult: account: AccountSnapshot | None = None +class AccountEmailRegistrationPhase(StrEnum): + REGISTER = "register" + + +@dataclass(frozen=True, slots=True) +class AccountEmailRegistrationToken: + email: str + code: str + phase: AccountEmailRegistrationPhase | None = None + + +@dataclass(frozen=True, slots=True) +class AccountEmailRegistrationVerification: + email: str + token: str + + +@dataclass(frozen=True, slots=True) +class AccountSessionTokens: + access_token: str + refresh_token: str + csrf_token: str + + class AccountChangeEmailPhase(StrEnum): OLD_EMAIL = "old_email" OLD_EMAIL_VERIFIED = "old_email_verified" diff --git a/api/services/entities/notification_entities.py b/api/services/entities/notification_entities.py new file mode 100644 index 00000000000..6686c5edb99 --- /dev/null +++ b/api/services/entities/notification_entities.py @@ -0,0 +1,38 @@ +"""Framework-independent notification contracts.""" + +from collections.abc import Mapping +from typing import NamedTuple + + +class NotificationContent(NamedTuple): + lang: str + title: str + subtitle: str + body: str + title_pic_url: str + + +class AccountNotification(NamedTuple): + notification_id: str | None + frequency: str | None + contents: Mapping[str, NotificationContent] + + +class AccountNotificationBatch(NamedTuple): + should_show: bool + notifications: tuple[AccountNotification, ...] + + +class NotificationItem(NamedTuple): + notification_id: str | None + frequency: str | None + lang: str + title: str + subtitle: str + body: str + title_pic_url: str + + +class NotificationResult(NamedTuple): + should_show: bool + notifications: tuple[NotificationItem, ...] diff --git a/api/services/entities/onboarding_entities.py b/api/services/entities/onboarding_entities.py new file mode 100644 index 00000000000..2550db489e4 --- /dev/null +++ b/api/services/entities/onboarding_entities.py @@ -0,0 +1,42 @@ +"""Framework-independent Step-by-step Tour contracts.""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Literal, TypeAlias + +# Assignment-form aliases preserve Literal enum values in Pydantic-generated OpenAPI schemas. +StepByStepTourAction: TypeAlias = Literal[ # noqa: UP040 + "skip", + "complete_task", + "uncomplete_task", + "enable_current_workspace", + "disable_current_workspace", +] +StepByStepTourTaskId: TypeAlias = Literal["home", "studio", "knowledge", "integration"] # noqa: UP040 + + +@dataclass(frozen=True, slots=True) +class StepByStepTourPatch: + action: StepByStepTourAction + task_id: StepByStepTourTaskId | None = None + + +@dataclass(frozen=True, slots=True) +class StepByStepTourState: + account_id: str + first_workspace_id: str | None = None + skipped: bool = False + completed_task_ids: tuple[str, ...] = () + manually_enabled_workspace_ids: tuple[str, ...] = () + manually_disabled_workspace_ids: tuple[str, ...] = () + updated_at: datetime | None = None + + +@dataclass(frozen=True, slots=True) +class StepByStepTourResult: + first_workspace_id: str | None = None + skipped: bool = False + completed_task_ids: tuple[str, ...] = () + manually_enabled_workspace_ids: tuple[str, ...] = () + manually_disabled_workspace_ids: tuple[str, ...] = () + updated_at: datetime | None = None diff --git a/api/services/errors/app.py b/api/services/errors/app.py index c9e9df97dea..74c29b0857d 100644 --- a/api/services/errors/app.py +++ b/api/services/errors/app.py @@ -18,6 +18,21 @@ class WorkflowIdFormatError(Exception): pass +TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE = "trigger_workflow_service_mode_unavailable" +TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE = ( + "This workflow uses a trigger entry and cannot be invoked through Web App, Service API, OpenAPI, or MCP." +) + + +class TriggerWorkflowServiceModeUnavailableError(Exception): + """Raised when a trigger-entry Workflow is invoked through a manual service surface.""" + + error_code = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE + + def __init__(self) -> None: + super().__init__(TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE) + + class QuotaExceededError(ValueError): """Raised when billing quota is exceeded for a feature.""" diff --git a/api/services/notification_gateway.py b/api/services/notification_gateway.py new file mode 100644 index 00000000000..cb7cc5e74d0 --- /dev/null +++ b/api/services/notification_gateway.py @@ -0,0 +1,48 @@ +"""Billing-backed notification gateway.""" + +from collections.abc import Mapping +from typing import Any, override + +from services.billing_service import BillingService +from services.entities.notification_entities import ( + AccountNotification, + AccountNotificationBatch, + NotificationContent, +) +from services.notification_service import NotificationGateway + + +class BillingNotificationGateway(NotificationGateway): + @override + def get_active(self, account_id: str) -> AccountNotificationBatch: + payload = BillingService.get_account_notification(account_id) + notifications = tuple(self._map_notification(item) for item in payload.get("notifications") or ()) + return AccountNotificationBatch( + should_show=bool(payload.get("shouldShow")), + notifications=notifications, + ) + + @override + def dismiss(self, notification_id: str, account_id: str) -> None: + BillingService.dismiss_notification(notification_id=notification_id, account_id=account_id) + + @classmethod + def _map_notification(cls, payload: Mapping[str, Any]) -> AccountNotification: + raw_contents = payload.get("contents") or {} + contents = {language: cls._map_content(content) for language, content in raw_contents.items() if content} + return AccountNotification( + notification_id=payload.get("notificationId"), + frequency=payload.get("frequency"), + contents=contents, + ) + + @staticmethod + def _map_content(payload: Mapping[str, Any]) -> NotificationContent: + return NotificationContent( + # The application service owns the requested-language fallback. + lang=payload.get("lang") or "", + title=payload.get("title") or "", + subtitle=payload.get("subtitle") or "", + body=payload.get("body") or "", + title_pic_url=payload.get("titlePicUrl") or "", + ) diff --git a/api/services/notification_service.py b/api/services/notification_service.py new file mode 100644 index 00000000000..13236ef16ed --- /dev/null +++ b/api/services/notification_service.py @@ -0,0 +1,60 @@ +"""Application service for Console account notifications.""" + +from typing import Protocol + +from machinery.context import RequestContext +from services.account_ports import AccountRepository +from services.entities.notification_entities import ( + AccountNotification, + AccountNotificationBatch, + NotificationContent, + NotificationItem, + NotificationResult, +) + +_FALLBACK_LANGUAGE = "en-US" + + +class NotificationGateway(Protocol): + def get_active(self, account_id: str) -> AccountNotificationBatch: ... + + def dismiss(self, notification_id: str, account_id: str) -> None: ... + + +class NotificationService: + def __init__(self, *, accounts: AccountRepository, notifications: NotificationGateway) -> None: + self._accounts = accounts + self._notifications = notifications + + def get_active(self, context: RequestContext) -> NotificationResult: + batch = self._notifications.get_active(context.account_id) + if not batch.should_show: + return NotificationResult(should_show=False, notifications=()) + + account = self._accounts.get(context.account_id) + if account is None: + raise RuntimeError("Console account admission resolved an unknown account") + language = account.interface_language or _FALLBACK_LANGUAGE + + notifications = tuple(self._localize(notification, language) for notification in batch.notifications) + return NotificationResult(should_show=bool(notifications), notifications=notifications) + + def dismiss(self, context: RequestContext, notification_id: str) -> None: + self._notifications.dismiss(notification_id, context.account_id) + + @staticmethod + def _localize(notification: AccountNotification, language: str) -> NotificationItem: + content = ( + notification.contents.get(language) + or notification.contents.get(_FALLBACK_LANGUAGE) + or next(iter(notification.contents.values()), NotificationContent(language, "", "", "", "")) + ) + return NotificationItem( + notification_id=notification.notification_id, + frequency=notification.frequency, + lang=content.lang or language, + title=content.title, + subtitle=content.subtitle, + body=content.body, + title_pic_url=content.title_pic_url, + ) diff --git a/api/services/step_by_step_tour_service.py b/api/services/step_by_step_tour_service.py index b01d59c1acc..9597d3d5e77 100644 --- a/api/services/step_by_step_tour_service.py +++ b/api/services/step_by_step_tour_service.py @@ -1,221 +1,161 @@ -"""Account-level Step-by-step Tour persistence.""" +"""Application service for account-level Step-by-step Tour use cases.""" +from collections.abc import Callable +from dataclasses import replace from datetime import datetime -from typing import NotRequired, TypedDict +from typing import Protocol, get_args -from sqlalchemy import select -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session, scoped_session - -from configs import dify_config from libs.datetime_utils import ensure_naive_utc -from models.account import Account -from models.onboarding import AccountStepByStepTourState +from machinery.context import RequestContext +from services.account_ports import AccountRepository +from services.entities.onboarding_entities import ( + StepByStepTourPatch, + StepByStepTourResult, + StepByStepTourState, + StepByStepTourTaskId, +) -STEP_BY_STEP_TOUR_TASK_IDS = frozenset(("home", "studio", "knowledge", "integration")) +_TASK_IDS: frozenset[str] = frozenset(get_args(StepByStepTourTaskId)) -class StepByStepTourStateResponse(TypedDict): - first_workspace_id: str | None - skipped: bool - completed_task_ids: list[str] - manually_enabled_workspace_ids: list[str] - manually_disabled_workspace_ids: list[str] - updated_at: datetime | None +class StepByStepTourStateRepository(Protocol): + def get(self, account_id: str) -> StepByStepTourState | None: ... + def initialize(self, account_id: str, first_workspace_id: str) -> StepByStepTourState: ... -class StepByStepTourPatch(TypedDict): - action: str - task_id: NotRequired[str | None] + def mutate( + self, + account_id: str, + mutation: Callable[[StepByStepTourState], StepByStepTourState], + ) -> StepByStepTourState: ... class StepByStepTourService: - """Coordinate persisted tour state with account eligibility rules.""" - - @classmethod - def get_state( - cls, + def __init__( + self, *, - account: Account, - current_tenant_id: str, - session: Session | scoped_session, - ) -> StepByStepTourStateResponse: - eligible = cls.is_eligible(account) - state = cls._get_state(account.id, session=session) + accounts: AccountRepository, + states: StepByStepTourStateRepository, + enabled: bool, + rollout_started_at: datetime | None, + ) -> None: + self._accounts = accounts + self._states = states + self._enabled = enabled + self._rollout_started_at = rollout_started_at - if eligible: - state = cls._ensure_state(account.id, session=session, state=state) - if state.first_workspace_id is None: - state.first_workspace_id = current_tenant_id - session.commit() - session.refresh(state) + def get_state(self, context: RequestContext) -> StepByStepTourResult: + workspace_id = self._require_workspace(context) + account = self._accounts.get(context.account_id) + if account is None: + raise RuntimeError("Console account admission resolved an unknown account") - return cls._build_response(state=state) + if not self._is_eligible(account.initialized_at or account.created_at): + return self._to_result(self._states.get(context.account_id)) - @classmethod - def patch_state( - cls, - *, - account: Account, - current_tenant_id: str, - patch: StepByStepTourPatch, - session: Session | scoped_session, - ) -> StepByStepTourStateResponse: - state = cls._ensure_state(account.id, session=session, state=None) - cls._apply_action( - state=state, - action=patch["action"], - task_id=patch.get("task_id"), - current_tenant_id=current_tenant_id, + return self._to_result(self._states.initialize(context.account_id, workspace_id)) + + def patch_state(self, context: RequestContext, patch: StepByStepTourPatch) -> StepByStepTourResult: + workspace_id = self._require_workspace(context) + state = self._states.mutate( + context.account_id, + lambda current: self._apply_action(current, patch=patch, workspace_id=workspace_id), ) + return self._to_result(state) - session.commit() - session.refresh(state) - return cls._build_response(state=state) - - @classmethod - def is_eligible(cls, account: Account) -> bool: - if not dify_config.ENABLE_STEP_BY_STEP_TOUR: + def _is_eligible(self, account_started_at: datetime) -> bool: + if not self._enabled or self._rollout_started_at is None: return False - - rollout_started_at = dify_config.STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT - if rollout_started_at is None: - return False - - account_started_at = account.initialized_at or account.created_at - if account_started_at is None: - return False - - return ensure_naive_utc(account_started_at) >= ensure_naive_utc(rollout_started_at) - - @classmethod - def _get_state( - cls, - account_id: str, - *, - session: Session | scoped_session, - ) -> AccountStepByStepTourState | None: - stmt = select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == account_id).limit(1) - return session.execute(stmt).scalar_one_or_none() - - @classmethod - def _ensure_state( - cls, - account_id: str, - *, - session: Session | scoped_session, - state: AccountStepByStepTourState | None, - ) -> AccountStepByStepTourState: - if state is None: - state = cls._get_state(account_id, session=session) - if state is not None: - return state - - state = AccountStepByStepTourState(account_id=account_id) - session.add(state) - try: - session.flush() - except IntegrityError: - # Another tab/device can create the account row between our read and insert. - session.rollback() - state = cls._get_state(account_id, session=session) - if state is None: - raise - return state + return ensure_naive_utc(account_started_at) >= ensure_naive_utc(self._rollout_started_at) @classmethod def _apply_action( cls, + state: StepByStepTourState, *, - state: AccountStepByStepTourState, - action: str, - task_id: str | None, - current_tenant_id: str, - ) -> None: - match action: + patch: StepByStepTourPatch, + workspace_id: str, + ) -> StepByStepTourState: + match patch.action: case "skip": - state.skipped = True - state.manually_enabled_workspace_ids = cls._remove_id( - state.manually_enabled_workspace_ids, - current_tenant_id, + return replace( + state, + skipped=True, + manually_enabled_workspace_ids=cls._remove_id( + state.manually_enabled_workspace_ids, + workspace_id, + ), ) case "complete_task": - if task_id is None: - raise ValueError("task_id is required") - cls._validate_task_id(task_id) - state.completed_task_ids = cls._add_id(state.completed_task_ids, task_id) + task_id = cls._require_task_id(patch.task_id) + return replace(state, completed_task_ids=cls._add_id(state.completed_task_ids, task_id)) case "uncomplete_task": - if task_id is None: - raise ValueError("task_id is required") - cls._validate_task_id(task_id) - state.completed_task_ids = cls._remove_id(state.completed_task_ids, task_id) + task_id = cls._require_task_id(patch.task_id) + return replace(state, completed_task_ids=cls._remove_id(state.completed_task_ids, task_id)) case "enable_current_workspace": - state.skipped = False - state.manually_enabled_workspace_ids = cls._add_id( - state.manually_enabled_workspace_ids, - current_tenant_id, - ) - state.manually_disabled_workspace_ids = cls._remove_id( - state.manually_disabled_workspace_ids, - current_tenant_id, + return replace( + state, + skipped=False, + manually_enabled_workspace_ids=cls._add_id( + state.manually_enabled_workspace_ids, + workspace_id, + ), + manually_disabled_workspace_ids=cls._remove_id( + state.manually_disabled_workspace_ids, + workspace_id, + ), ) case "disable_current_workspace": - state.manually_enabled_workspace_ids = cls._remove_id( - state.manually_enabled_workspace_ids, - current_tenant_id, - ) - state.manually_disabled_workspace_ids = cls._add_id( - state.manually_disabled_workspace_ids, - current_tenant_id, + return replace( + state, + manually_enabled_workspace_ids=cls._remove_id( + state.manually_enabled_workspace_ids, + workspace_id, + ), + manually_disabled_workspace_ids=cls._add_id( + state.manually_disabled_workspace_ids, + workspace_id, + ), ) case _: - raise ValueError(f"Unsupported action: {action}") - - @classmethod - def _build_response( - cls, - *, - state: AccountStepByStepTourState | None, - ) -> StepByStepTourStateResponse: - if state is None: - return { - "first_workspace_id": None, - "skipped": False, - "completed_task_ids": [], - "manually_enabled_workspace_ids": [], - "manually_disabled_workspace_ids": [], - "updated_at": None, - } - - return { - "first_workspace_id": state.first_workspace_id, - "skipped": state.skipped, - "completed_task_ids": cls._normalize_ids(state.completed_task_ids), - "manually_enabled_workspace_ids": cls._normalize_ids(state.manually_enabled_workspace_ids), - "manually_disabled_workspace_ids": cls._normalize_ids(state.manually_disabled_workspace_ids), - "updated_at": state.updated_at, - } + raise ValueError(f"Unsupported action: {patch.action}") @staticmethod - def _validate_task_id(task_id: str) -> None: - if task_id not in STEP_BY_STEP_TOUR_TASK_IDS: + def _require_workspace(context: RequestContext) -> str: + if context.active_workspace_id is None: + raise RuntimeError("Console account admission did not resolve an active workspace") + return context.active_workspace_id + + @staticmethod + def _require_task_id(task_id: str | None) -> str: + if task_id is None: + raise ValueError("task_id is required") + if task_id not in _TASK_IDS: raise ValueError(f"Unsupported task_id: {task_id}") + return task_id @classmethod - def _add_id(cls, values: list[str], value: str) -> list[str]: + def _add_id(cls, values: tuple[str, ...], value: str) -> tuple[str, ...]: normalized = cls._normalize_ids(values) - if value in normalized: - return normalized - return [*normalized, value] + return normalized if value in normalized else (*normalized, value) @classmethod - def _remove_id(cls, values: list[str], value: str) -> list[str]: - return [item for item in cls._normalize_ids(values) if item != value] + def _remove_id(cls, values: tuple[str, ...], value: str) -> tuple[str, ...]: + return tuple(item for item in cls._normalize_ids(values) if item != value) @staticmethod - def _normalize_ids(values: list[str]) -> list[str]: - normalized: list[str] = [] - for value in values: - if value not in normalized: - normalized.append(value) - return normalized + def _normalize_ids(values: tuple[str, ...]) -> tuple[str, ...]: + return tuple(dict.fromkeys(values)) + + @staticmethod + def _to_result(state: StepByStepTourState | None) -> StepByStepTourResult: + if state is None: + return StepByStepTourResult() + return StepByStepTourResult( + first_workspace_id=state.first_workspace_id, + skipped=state.skipped, + completed_task_ids=tuple(dict.fromkeys(state.completed_task_ids)), + manually_enabled_workspace_ids=tuple(dict.fromkeys(state.manually_enabled_workspace_ids)), + manually_disabled_workspace_ids=tuple(dict.fromkeys(state.manually_disabled_workspace_ids)), + updated_at=state.updated_at, + ) diff --git a/api/services/workflow_app_service.py b/api/services/workflow_app_service.py index 56665fe698d..453c130e1aa 100644 --- a/api/services/workflow_app_service.py +++ b/api/services/workflow_app_service.py @@ -23,17 +23,27 @@ class LogView: """Lightweight wrapper for WorkflowAppLog with computed details. - Exposes `details_` for marshalling to `details` in API response + - Resolves the account/end-user accessors through the session it was built with - Proxies all other attributes to the underlying `WorkflowAppLog` """ - def __init__(self, log: WorkflowAppLog, details: LogViewDetails | None): + def __init__(self, log: WorkflowAppLog, details: LogViewDetails | None, session: Session): self.log = log self.details_ = details + self._session = session @property def details(self) -> LogViewDetails | None: return self.details_ + @property + def created_by_account(self) -> Account | None: + return self.log.created_by_account(self._session) + + @property + def created_by_end_user(self) -> EndUser | None: + return self.log.created_by_end_user(self._session) + def __getattr__(self, name): return getattr(self.log, name) @@ -171,11 +181,11 @@ class WorkflowAppService: if detail: rows = session.execute(offset_stmt).all() items = [ - LogView(log, {"trigger_metadata": self.handle_trigger_metadata(app_model.tenant_id, meta_val)}) + LogView(log, {"trigger_metadata": self.handle_trigger_metadata(app_model.tenant_id, meta_val)}, session) for log, meta_val in rows ] else: - items = [LogView(log, None) for log in session.scalars(offset_stmt).all()] + items = [LogView(log, None, session) for log in session.scalars(offset_stmt).all()] return { "page": page, "limit": limit, diff --git a/api/tests/test_containers_integration_tests/controllers/console/workspace/test_members.py b/api/tests/test_containers_integration_tests/controllers/console/workspace/test_members.py index 1c84b70b08e..33bbdcb2f69 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/workspace/test_members.py +++ b/api/tests/test_containers_integration_tests/controllers/console/workspace/test_members.py @@ -300,7 +300,7 @@ class TestOwnerTransferApiWithContainers: ) assert ( factory.get_join(db_session_with_containers, tenant=tenant, account=current_user).role - == TenantAccountRole.ADMIN + == TenantAccountRole.NORMAL ) mock_new_owner_email.assert_called_once() mock_old_owner_email.assert_called_once() diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_conversation.py b/api/tests/test_containers_integration_tests/controllers/web/test_conversation.py index 0ec399ba2b5..b35e01bdbfa 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_conversation.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_conversation.py @@ -102,10 +102,8 @@ class TestConversationRenameApi: ConversationRenameApi().post(_completion_app(), _end_user(), uuid4()) @patch("controllers.web.conversation.ConversationService.rename") - @patch("controllers.web.conversation.web_ns") - def test_rename_success(self, mock_ns: MagicMock, mock_rename: MagicMock, app: Flask) -> None: + def test_rename_success(self, mock_rename: MagicMock, app: Flask) -> None: c_id = uuid4() - mock_ns.payload = {"name": "New Name", "auto_generate": False} conv = SimpleNamespace( id=str(c_id), name="New Name", @@ -126,10 +124,8 @@ class TestConversationRenameApi: "controllers.web.conversation.ConversationService.rename", side_effect=ConversationNotExistsError(), ) - @patch("controllers.web.conversation.web_ns") - def test_rename_not_found(self, mock_ns: MagicMock, mock_rename: MagicMock, app: Flask) -> None: + def test_rename_not_found(self, mock_rename: MagicMock, app: Flask) -> None: c_id = uuid4() - mock_ns.payload = {"name": "X", "auto_generate": False} with app.test_request_context(f"/conversations/{c_id}/name", method="POST", json={"name": "X"}): with pytest.raises(NotFound, match="Conversation Not Exists"): diff --git a/api/tests/test_containers_integration_tests/services/test_account_service.py b/api/tests/test_containers_integration_tests/services/test_account_service.py index 48e76f8abc1..37dbd20e81f 100644 --- a/api/tests/test_containers_integration_tests/services/test_account_service.py +++ b/api/tests/test_containers_integration_tests/services/test_account_service.py @@ -1671,7 +1671,7 @@ class TestTenantService: def test_update_member_role_to_owner(self, db_session_with_containers: Session, mock_external_service_dependencies): """ - Test updating member role to owner (should change current owner to admin). + Test updating member role to owner (should change current owner to normal). """ fake = Faker() tenant_name = fake.company() @@ -1723,7 +1723,7 @@ class TestTenantService: .filter_by(tenant_id=tenant.id, account_id=member_account.id) .first() ) - assert owner_join.role == "admin" + assert owner_join.role == "normal" assert member_join.role == "owner" def test_update_member_role_already_assigned( diff --git a/api/tests/unit_tests/clients/agent_backend/test_factory.py b/api/tests/unit_tests/clients/agent_backend/test_factory.py index 626bf5f1dfd..490c1bc6360 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_factory.py +++ b/api/tests/unit_tests/clients/agent_backend/test_factory.py @@ -9,6 +9,7 @@ from clients.agent_backend.factory import create_agent_backend_client, create_ag from configs import dify_config from services import agent_app_sandbox_service from services.agent import home_snapshot_service, workspace_service +from tests.unit_tests.config_override import apply_config_overrides @pytest.mark.parametrize( @@ -78,9 +79,12 @@ def test_default_agent_backend_clients_forward_authentication( module: ModuleType, extra_kwargs: dict[str, float], ) -> None: - monkeypatch.setattr(dify_config, "AGENT_BACKEND_BASE_URL", "http://agent-backend") - monkeypatch.setattr(dify_config, "AGENT_BACKEND_API_TOKEN", "secret-token") - monkeypatch.setattr(dify_config, "AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS", 123.5) + apply_config_overrides( + monkeypatch, + AGENT_BACKEND_BASE_URL="http://agent-backend", + AGENT_BACKEND_API_TOKEN="secret-token", + AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS=123.5, + ) create_client = MagicMock() monkeypatch.setattr(module, "create_agent_backend_client", create_client) diff --git a/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py b/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py index cff6695e414..9231e274d5c 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py @@ -238,6 +238,42 @@ def test_patch_union_schema_markdown_fills_regular_schema_union_property(tmp_pat assert "| value | string
integer
number
boolean | | No |" in patched +def test_patch_union_schema_markdown_preserves_nullable_enum_values(tmp_path: Path): + module = _load_generate_swagger_markdown_docs_module() + spec_path = tmp_path / "console-openapi.json" + spec_path.write_text( + json.dumps( + { + "components": { + "schemas": { + "StepByStepTourStatePatchPayload": { + "properties": { + "task_id": { + "anyOf": [ + {"enum": ["home", "studio"], "type": "string"}, + {"type": "null"}, + ], + }, + }, + }, + }, + } + } + ), + encoding="utf-8", + ) + markdown = """#### StepByStepTourStatePatchPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| task_id | string | Task ID | No | +""" + + patched = module._patch_union_schema_markdown(markdown, spec_path) + + assert '| task_id | string,
**Available values:** "home", "studio" | Task ID | No |' in patched + + def test_patch_union_schema_markdown_fills_array_item_union_property(tmp_path: Path): module = _load_generate_swagger_markdown_docs_module() spec_path = tmp_path / "console-openapi.json" diff --git a/api/tests/unit_tests/commands/test_generate_swagger_specs.py b/api/tests/unit_tests/commands/test_generate_swagger_specs.py index 9c17cad7f12..d968b81ab71 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_specs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_specs.py @@ -9,6 +9,8 @@ from pathlib import Path from jsonschema import Draft202012Validator +from tests.unit_tests.config_override import apply_config_overrides + def _walk_values(value): yield value @@ -162,7 +164,7 @@ def test_apply_runtime_defaults_forces_swagger_routes_on(monkeypatch): from configs import dify_config monkeypatch.setenv("SWAGGER_UI_ENABLED", "false") - monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", False) + apply_config_overrides(monkeypatch, SWAGGER_UI_ENABLED=False) module.apply_runtime_defaults() diff --git a/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py b/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py index 5e2f8ffaa09..d8c47e2747e 100644 --- a/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py +++ b/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py @@ -21,6 +21,7 @@ from graphon.model_runtime.entities.model_entities import ModelType from models import Tenant from models.provider import Provider, ProviderModel, ProviderType from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider +from tests.unit_tests.config_override import apply_config_overrides def _invoke_reset() -> int: @@ -88,7 +89,7 @@ def _bind_command_to_sqlite(monkeypatch: pytest.MonkeyPatch, session: Session) - def test_reset_aborts_when_not_self_hosted(monkeypatch, capsys): - monkeypatch.setattr(system_commands.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) exit_code = _invoke_reset() captured = capsys.readouterr() @@ -107,7 +108,7 @@ def test_reset_purges_provider_and_tool_tables_for_each_tenant( ) -> None: """The command must purge LLM provider rows AND every tool provider table that stores ciphertext encrypted under the tenant key (#35396).""" - monkeypatch.setattr(system_commands.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}") _bind_command_to_sqlite(monkeypatch, sqlite_session) @@ -147,7 +148,7 @@ def test_reset_purges_provider_and_tool_tables_for_each_tenant( ) def test_reset_iterates_all_tenants(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: """Multi-tenant deployments must purge every tenant, not just the first.""" - monkeypatch.setattr(system_commands.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}") _bind_command_to_sqlite(monkeypatch, sqlite_session) diff --git a/api/tests/unit_tests/config_override.py b/api/tests/unit_tests/config_override.py new file mode 100644 index 00000000000..9a65679fcc7 --- /dev/null +++ b/api/tests/unit_tests/config_override.py @@ -0,0 +1,26 @@ +"""Typed config override support shared by unit-test fixtures and helpers.""" + +from collections.abc import Generator +from contextlib import contextmanager + +import pytest + +from configs import dify_config + + +def apply_config_overrides(monkeypatch: pytest.MonkeyPatch, **values: object) -> None: + """Override known DifyConfig fields for the lifetime of ``monkeypatch``.""" + 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) + + +@contextmanager +def config_overrides_context(**values: object) -> Generator[None]: + """Apply validated config overrides as a context manager or decorator.""" + with pytest.MonkeyPatch.context() as monkeypatch: + apply_config_overrides(monkeypatch, **values) + yield diff --git a/api/tests/unit_tests/conftest.py b/api/tests/unit_tests/conftest.py index ce90f8f380a..9ca5f9774e6 100644 --- a/api/tests/unit_tests/conftest.py +++ b/api/tests/unit_tests/conftest.py @@ -41,6 +41,7 @@ import core.db.session_factory as session_factory_module from extensions import ext_redis from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.base import TypeBase +from tests.unit_tests.config_override import apply_config_overrides def _patch_redis_clients_on_loaded_modules() -> None: @@ -99,17 +100,9 @@ def reset_redis_mock() -> None: @pytest.fixture(autouse=True) -def reset_secret_key() -> Iterator[None]: +def reset_secret_key(monkeypatch: pytest.MonkeyPatch) -> None: """Ensure SECRET_KEY-dependent logic sees an empty config value by default.""" - - from configs import dify_config - - original = dify_config.SECRET_KEY - dify_config.SECRET_KEY = "" - try: - yield - finally: - dify_config.SECRET_KEY = original + apply_config_overrides(monkeypatch, SECRET_KEY="") @pytest.fixture @@ -120,14 +113,9 @@ def config_overrides(monkeypatch: pytest.MonkeyPatch) -> Callable[..., None]: 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) + apply_config_overrides(monkeypatch, **values) return apply diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index 9cd219971db..27a543fd2a2 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from datetime import datetime from inspect import getsource, unwrap from types import SimpleNamespace @@ -55,7 +56,7 @@ from controllers.console.agent.roster import ( from controllers.console.app import completion as completion_controller from controllers.console.app import message as message_controller from controllers.console.app.completion import AgentBuildChatFinalizeApi, AgentChatMessageApi, AgentChatMessageStopApi -from controllers.console.app.error import CompletionRequestError +from controllers.console.app.error import AgentSessionConfigurationChangedError, CompletionRequestError from controllers.console.app.message import ( AgentChatMessageListApi, AgentMessageApi, @@ -74,6 +75,7 @@ from services.entities.agent_entities import ( WorkflowAgentComposerQuery, WorkflowComposerCopyFromRosterPayload, ) +from tests.unit_tests.config_override import apply_config_overrides def _persist_conversation_message( @@ -307,9 +309,15 @@ def account_id() -> str: def test_agent_app_list_and_create_use_agent_route( - app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str, sqlite_session: Session + app: Flask, + monkeypatch: pytest.MonkeyPatch, + account_id: str, + sqlite_session: Session, + config_overrides: Callable[..., None], ) -> None: captured: dict[str, object] = {} + replace_whitelist = MagicMock() + initialize_access = MagicMock() class FakeAppService: def get_app(self, app_obj: object, *, session: object) -> object: @@ -325,6 +333,11 @@ def test_agent_app_list_and_create_use_agent_route( items=[_app_detail_obj(id="app-list", bound_agent_id="agent-list")], ) + def get_agent_publication_counts(self, user_id: str, tenant_id: str, params, session): + del session + captured["counts"] = {"user_id": user_id, "tenant_id": tenant_id, "params": params} + return roster_controller.AgentAppPublicationCounts(published=1, drafts=0) + def create_app(self, tenant_id: str, params, current_user: object, *, session: object) -> object: captured["create"] = {"tenant_id": tenant_id, "params": params, "current_user": current_user} return _app_detail_obj(id="app-created", bound_agent_id="agent-created") @@ -390,7 +403,9 @@ def test_agent_app_list_and_create_use_agent_route( lambda _self, **kwargs: {"agent-list": "debug-conversation-list"}, ) monkeypatch.setattr( - roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 0 + roster_controller.AgentRosterService, + "count_agent_app_debug_conversation_messages", + lambda _self, **kwargs: 0, ) def get_or_create_debug_conversation(_self: object, **kwargs: object) -> str: @@ -407,8 +422,16 @@ def test_agent_app_list_and_create_use_agent_route( "get_system_features", lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), ) + config_overrides(RBAC_ENABLED=True) + monkeypatch.setattr( + roster_controller.enterprise_rbac_service.RBACService.AppAccess, + "replace_whitelist", + replace_whitelist, + ) + monkeypatch.setattr(roster_controller.initialize_created_app_rbac_access_task, "delay", initialize_access) with app.test_request_context( - "/console/api/agent?page=1&limit=10&mode=workflow&sort_by=recently_created&is_created_by_me=true" + "/console/api/agent?page=1&limit=10&mode=workflow&sort_by=recently_created" + "&is_created_by_me=true&publication_status=published" ): listed = unwrap(AgentAppListApi.get)( AgentAppListApi(), sqlite_session, "tenant-1", _account(account_id=account_id) @@ -416,6 +439,7 @@ def test_agent_app_list_and_create_use_agent_route( assert listed["page"] == 1 assert listed["limit"] == 10 assert listed["total"] == 1 + assert listed["publication_counts"] == {"published": 1, "drafts": 0} assert listed["data"][0]["id"] == "agent-list" assert listed["data"][0]["app_id"] == "app-list" assert listed["data"][0]["debug_conversation_id"] == "debug-conversation-list" @@ -438,15 +462,29 @@ def test_agent_app_list_and_create_use_agent_route( assert list_params.mode == "agent" assert list_params.sort_by == "recently_created" assert list_params.is_created_by_me is True + assert list_params.agent_is_published is True assert list_params.status == "normal" + count_call = cast(dict[str, object], captured["counts"]) + count_params = cast(Any, count_call["params"]) + assert count_params.agent_is_published is True with app.test_request_context( "/console/api/agent", - json={"name": "Iris", "description": "Agent app", "role": "Coordinator", "icon_type": "emoji", "icon": "robot"}, + json={ + "name": "Iris", + "description": "Agent app", + "role": "Coordinator", + "icon_type": "emoji", + "icon": "robot", + }, ): created, status = unwrap(AgentAppListApi.post)( AgentAppListApi(), AgentAppCreatePayload( - name="Iris", description="Agent app", role="Coordinator", icon_type="emoji", icon="robot" + name="Iris", + description="Agent app", + role="Coordinator", + icon_type="emoji", + icon="robot", ), sqlite_session, "tenant-1", @@ -469,6 +507,81 @@ def test_agent_app_list_and_create_use_agent_route( "account_id": account_id, "commit": False, } + replace_whitelist.assert_called_once() + assert replace_whitelist.call_args.args[:3] == ("tenant-1", account_id, "app-created") + replace_payload = replace_whitelist.call_args.args[3] + assert replace_payload.automatic_include_workspace_members is True + initialize_access.assert_called_once_with("tenant-1", account_id, app_id="app-created") + + +def test_agent_app_create_skips_rbac_access_initialization_when_rbac_is_disabled( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + account_id: str, + sqlite_session: Session, + config_overrides: Callable[..., None], +) -> None: + replace_whitelist = MagicMock() + initialize_access = MagicMock() + + class FakeAppService: + def get_app(self, app_obj: object, *, session: object) -> object: + return app_obj + + def create_app(self, tenant_id: str, params, current_user: object, *, session: object) -> object: + return _app_detail_obj(id="app-created", bound_agent_id="agent-created") + + monkeypatch.setattr(roster_controller, "AppService", FakeAppService) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_app_backing_agent", + lambda _self, **kwargs: Agent( + id="agent-created", + app_id="app-created", + backing_app_id=None, + role="Created role", + active_config_snapshot_id=None, + ), + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_or_create_build_conversation", + lambda _self, **kwargs: "debug-conversation-created", + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 0 + ) + monkeypatch.setattr( + roster_controller.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + config_overrides(RBAC_ENABLED=False) + monkeypatch.setattr( + roster_controller.enterprise_rbac_service.RBACService.AppAccess, + "replace_whitelist", + replace_whitelist, + ) + monkeypatch.setattr(roster_controller.initialize_created_app_rbac_access_task, "delay", initialize_access) + + with app.test_request_context( + "/console/api/agent", + json={"name": "Iris", "description": "Agent app", "role": "Coordinator", "icon_type": "emoji", "icon": "robot"}, + ): + created, status = unwrap(AgentAppListApi.post)( + AgentAppListApi(), + AgentAppCreatePayload( + name="Iris", description="Agent app", role="Coordinator", icon_type="emoji", icon="robot" + ), + sqlite_session, + "tenant-1", + _account(account_id=account_id), + ) + + assert status == 201 + assert created["id"] == "agent-created" + replace_whitelist.assert_not_called() + initialize_access.assert_not_called() def test_agent_app_create_payload_allows_optional_role() -> None: @@ -853,7 +966,7 @@ def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata(monkeyp monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda _session, **kwargs: app_model) monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda _session, _app: 2) monkeypatch.setattr(roster_controller, "_agent_app_access_ready", lambda _session, _app: True) - monkeypatch.setattr("models.model.dify_config.SERVICE_API_URL", "https://api.example.test/v1") + apply_config_overrides(monkeypatch, SERVICE_API_URL="https://api.example.test/v1") response = unwrap(AgentApiAccessApi.get)(AgentApiAccessApi(), MagicMock(), "tenant-1", agent_id) assert response == { "access_ready": True, @@ -1621,6 +1734,38 @@ def test_agent_chat_stream_preflight_raises_first_error_event() -> None: assert stream.closed is True +def test_agent_chat_stream_preflight_preserves_session_configuration_error() -> None: + class ClosableStream: + def __init__(self) -> None: + self.closed = False + self._chunks = iter( + [ + "event: ping\n\n", + ( + 'data: {"event":"error","message":"Start a new conversation to continue.",' + '"code":"agent_session_configuration_changed","status":409}\n\n' + ), + ] + ) + + def __iter__(self): + return self + + def __next__(self) -> str: + return next(self._chunks) + + def close(self) -> None: + self.closed = True + + stream = ClosableStream() + with pytest.raises(AgentSessionConfigurationChangedError) as exc_info: + completion_controller._raise_agent_stream_error_before_response(stream) + assert exc_info.value.code == 409 + assert exc_info.value.error_code == "agent_session_configuration_changed" + assert "Start a new conversation" in exc_info.value.description + assert stream.closed is True + + def test_agent_chat_stream_preflight_preserves_first_normal_event() -> None: stream = iter( ["event: ping\n\n", 'data: {"event":"message","answer":"hello"}\n\n', 'data: {"event":"message_end"}\n\n'] diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_manage_guard.py b/api/tests/unit_tests/controllers/console/app/test_agent_manage_guard.py index 7b785381b88..4d5dafe7bce 100644 --- a/api/tests/unit_tests/controllers/console/app/test_agent_manage_guard.py +++ b/api/tests/unit_tests/controllers/console/app/test_agent_manage_guard.py @@ -12,6 +12,7 @@ from core.rbac import RBACPermission, RBACResourceScope from models import Account from models.agent import Agent, AgentScope, AgentSource, AgentStatus from models.model import App, AppMode +from tests.unit_tests.config_override import config_overrides_context TENANT_ID = "tenant-1" @@ -60,7 +61,7 @@ def _persist_app( def _patch_guard(account: Account, rbac_enabled: bool): return ( patch("controllers.console.app.wraps.current_account_with_tenant", return_value=(account, TENANT_ID)), - patch("controllers.console.app.wraps.dify_config.RBAC_ENABLED", rbac_enabled), + config_overrides_context(RBAC_ENABLED=rbac_enabled), ) diff --git a/api/tests/unit_tests/controllers/console/app/test_app_apis.py b/api/tests/unit_tests/controllers/console/app/test_app_apis.py index c7f8286fac5..6d574657017 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_apis.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_apis.py @@ -77,6 +77,7 @@ from services.app_site_service import ( AppSiteCommandResult, AppSiteNotFoundError, ) +from tests.unit_tests.config_override import apply_config_overrides APP_ID = "11111111-1111-1111-1111-111111111111" TENANT_ID = "22222222-2222-2222-2222-222222222222" @@ -261,8 +262,11 @@ class TestAppEndpoints: oauth_server.issue_authorization_code.return_value = MagicMock(code="oauth-code-1") services = MagicMock(oauth_server=oauth_server) - monkeypatch.setattr(app_module.dify_config, "CREATORS_PLATFORM_FEATURES_ENABLED", True) - monkeypatch.setattr(app_module.dify_config, "CREATORS_PLATFORM_OAUTH_CLIENT_ID", "client-1") + apply_config_overrides( + monkeypatch, + CREATORS_PLATFORM_FEATURES_ENABLED=True, + CREATORS_PLATFORM_OAUTH_CLIENT_ID="client-1", + ) monkeypatch.setattr(app_module, "application_services", lambda: services) monkeypatch.setattr(app_module.AppDslService, "export_dsl", MagicMock(return_value="app: demo")) diff --git a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py index 5a08cad43bf..16c2ae1007d 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py @@ -21,6 +21,7 @@ from models.model import App, AppMode from services.app_dsl_service import ImportStatus from services.entities.dsl_entities import CheckDependenciesResult from services.entities.feature_entities import SystemFeatureModel, WebAppAuthModel +from tests.unit_tests.config_override import apply_config_overrides def _unwrap(func): @@ -240,7 +241,7 @@ class TestAppImportApi: "current_account_with_tenant", lambda: (_make_account(), "tenant-1"), ) - monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) app_id = _install_persisting_service_result( monkeypatch, method_name="import_app", @@ -276,7 +277,7 @@ class TestAppImportApi: "current_account_with_tenant", lambda: (_make_account(), "tenant-1"), ) - monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) app_id = _install_persisting_service_result( monkeypatch, method_name="import_app", @@ -353,7 +354,7 @@ class TestAppImportConfirmApi: ) ) monkeypatch.setattr(app_import_module.redis_client, "get", redis_get) - monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) app_id = _install_persisting_service_result( monkeypatch, method_name="confirm_import", @@ -397,7 +398,7 @@ class TestAppImportConfirmApi: b'"name":null,"description":null,"icon_type":null,"icon":null,"icon_background":null}' ), ) - monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) app_id = _install_persisting_service_result( monkeypatch, method_name="confirm_import", diff --git a/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py b/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py index 075997b40f9..5e5762f9260 100644 --- a/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py +++ b/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py @@ -21,6 +21,7 @@ from models import Account from models.account import AccountStatus from models.enums import AppMCPServerStatus from models.model import App, AppMCPServer, AppMode, IconType +from tests.unit_tests.config_override import config_overrides_context def _app( @@ -309,7 +310,7 @@ class TestAppMCPServerRefreshController: 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), + config_overrides_context(RBAC_ENABLED=True), patch( "controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-1"), diff --git a/api/tests/unit_tests/controllers/console/app/test_ops_trace_api.py b/api/tests/unit_tests/controllers/console/app/test_ops_trace_api.py index 08b3799de34..1c1ee83e19a 100644 --- a/api/tests/unit_tests/controllers/console/app/test_ops_trace_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_ops_trace_api.py @@ -18,6 +18,7 @@ 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 +from tests.unit_tests.config_override import apply_config_overrides def _make_account(role: TenantAccountRole) -> Account: @@ -55,9 +56,12 @@ def _patch_console_guards( *, rbac_enabled: bool = False, ) -> None: - monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True) - monkeypatch.setattr(login_lib.dify_config, "RBAC_ENABLED", rbac_enabled) - monkeypatch.setattr(console_wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + apply_config_overrides( + monkeypatch, + LOGIN_DISABLED=True, + RBAC_ENABLED=rbac_enabled, + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + ) monkeypatch.setattr(login_lib, "current_user", account) monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow.py b/api/tests/unit_tests/controllers/console/app/test_workflow.py index bf59f8a32ad..5a5bf81336d 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow.py @@ -22,6 +22,7 @@ from core.workflow.llm_environment_variable import LLMEnvironmentVariable from graphon.file import File, FileTransferMethod, FileType from graphon.variables import SecretVariable, StringVariable from graphon.variables.variables import RAGPipelineVariable +from tests.unit_tests.config_override import apply_config_overrides def _make_workflow(**overrides): @@ -77,6 +78,9 @@ def _make_workflow(**overrides): ) for key, value in overrides.items(): setattr(workflow, key, value) + workflow.get_created_by_account = Mock(return_value=workflow.created_by_account) + workflow.get_updated_by_account = Mock(return_value=workflow.updated_by_account) + workflow.get_tool_published = Mock(return_value=workflow.tool_published) return workflow @@ -103,6 +107,7 @@ def test_publish_workflow_returns_success( with app.test_request_context("/apps/app-1/workflows/publish", method="POST", json={}): response = inspect.unwrap(workflow_module.PublishedWorkflowApi.post)( workflow_module.PublishedWorkflowApi(), + workflow_module.PublishWorkflowPayload.model_validate({}), current_user, app_model, ) @@ -547,7 +552,10 @@ def test_get_published_workflows_serializes_items_before_session_closes( method="GET", query_string={"page": 1, "limit": 10, "user_id": "", "named_only": "false"}, ): - response = handler(api, "t1", app_model=SimpleNamespace(id="app", workflow_id="wf-1")) + query = workflow_module.WorkflowListQuery.model_validate( + {"page": "1", "limit": "10", "user_id": "", "named_only": "false"} + ) + response = handler(api, query, "t1", app_model=SimpleNamespace(id="app", workflow_id="wf-1")) assert response["items"][0]["id"] == "w1" assert response["page"] == 1 @@ -612,6 +620,25 @@ def test_draft_workflow_get_serializes_response_model(monkeypatch: pytest.Monkey ] +def test_published_workflow_get_uses_session_aware_response_source(monkeypatch: pytest.MonkeyPatch) -> None: + workflow = _make_workflow() + session = Mock(spec=Session) + monkeypatch.setattr(workflow_module, "db", SimpleNamespace(session=Mock(return_value=session))) + monkeypatch.setattr( + workflow_module, "WorkflowService", lambda: SimpleNamespace(get_published_workflow=lambda **_kwargs: workflow) + ) + + api = workflow_module.PublishedWorkflowApi() + handler = inspect.unwrap(api.get) + + response = handler(api, app_model=SimpleNamespace(id="app")) + + assert response["id"] == "workflow-1" + workflow.get_created_by_account.assert_called_once_with(session=session) + workflow.get_updated_by_account.assert_called_once_with(session=session) + workflow.get_tool_published.assert_called_once_with(session=session) + + def test_pipeline_variable_response_accepts_legacy_file_field_names() -> None: response = workflow_module.PipelineVariableResponse.model_validate( { @@ -806,15 +833,24 @@ def test_advanced_chat_run_conversation_not_exists(app: Flask, monkeypatch: pyte method="POST", json={"inputs": {}}, ): + payload = workflow_module.AdvancedChatWorkflowRunPayload.model_validate({"inputs": {}}) with pytest.raises(NotFound): - handler(api, Mock(), "t1", app_model=SimpleNamespace(id="app")) + handler(api, payload, Mock(), "t1", app_model=SimpleNamespace(id="app")) @pytest.mark.parametrize( - ("resource", "payload"), + ("resource", "payload_model", "payload"), [ - (workflow_module.DraftWorkflowTriggerRunApi, {"node_id": "node-1"}), - (workflow_module.DraftWorkflowTriggerRunAllApi, {"node_ids": ["node-1"]}), + ( + workflow_module.DraftWorkflowTriggerRunApi, + workflow_module.DraftWorkflowTriggerRunPayload, + {"node_id": "node-1"}, + ), + ( + workflow_module.DraftWorkflowTriggerRunAllApi, + workflow_module.DraftWorkflowTriggerRunAllPayload, + {"node_ids": ["node-1"]}, + ), ], ) def test_trigger_run_loads_draft_with_request_session( @@ -822,6 +858,7 @@ def test_trigger_run_loads_draft_with_request_session( monkeypatch: pytest.MonkeyPatch, unbound_session: Session, resource: type, + payload_model: type, payload: dict[str, object], ) -> None: get_draft_workflow = Mock(return_value=None) @@ -836,7 +873,13 @@ def test_trigger_run_loads_draft_with_request_session( with app.test_request_context("/", method="POST", json=payload): with pytest.raises(ValueError, match="Workflow not found"): - handler(resource(), session, SimpleNamespace(id="account-1"), app_model) + handler( + resource(), + payload_model.model_validate(payload), + session, + SimpleNamespace(id="account-1"), + app_model, + ) get_draft_workflow.assert_called_once_with(app_model, session=session) @@ -855,7 +898,7 @@ def test_workflow_online_users_filters_inaccessible_workflow(app: Flask, monkeyp access_filter = SimpleNamespace(is_app_accessible=lambda app_id, _maintainer, _account_id: app_id == app_id_1) resolve_access = Mock(return_value=access_filter) monkeypatch.setattr(workflow_module, "resolve_app_access_filter", resolve_access) - monkeypatch.setattr(workflow_module.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr(workflow_module.file_helpers, "get_signed_file_url", sign_avatar) short_session = Mock() monkeypatch.setattr(workflow_module.session_factory, "create_session", lambda: nullcontext(short_session)) @@ -906,7 +949,8 @@ def test_workflow_online_users_filters_inaccessible_workflow(app: Flask, monkeyp method="POST", json={"app_ids": [app_id_1, app_id_2]}, ): - response = handler(api, "tenant-1", SimpleNamespace(id="account-1")) + args = workflow_module.WorkflowOnlineUsersPayload.model_validate({"app_ids": [app_id_1, app_id_2]}) + response = handler(api, args, "tenant-1", SimpleNamespace(id="account-1")) assert response == { "data": [ @@ -945,7 +989,7 @@ def test_workflow_online_users_batches_redis_reads(app: Flask, monkeypatch: pyte "WorkflowService", lambda: SimpleNamespace(get_tenant_app_maintainers=lambda app_ids, tenant_id, session: dict.fromkeys(app_ids)), ) - monkeypatch.setattr(workflow_module.dify_config, "RBAC_ENABLED", False) + apply_config_overrides(monkeypatch, RBAC_ENABLED=False) monkeypatch.setattr(workflow_module.session_factory, "create_session", lambda: nullcontext(Mock())) first_pipeline = Mock() @@ -963,7 +1007,8 @@ def test_workflow_online_users_batches_redis_reads(app: Flask, monkeypatch: pyte method="POST", json={"app_ids": app_ids}, ): - response = handler(api, "tenant-1", SimpleNamespace(id="account-1")) + args = workflow_module.WorkflowOnlineUsersPayload.model_validate({"app_ids": app_ids}) + response = handler(api, args, "tenant-1", SimpleNamespace(id="account-1")) assert len(response["data"]) == len(app_ids) assert redis_pipeline_factory.call_count == 2 @@ -989,8 +1034,9 @@ def test_workflow_online_users_rejects_excessive_workflow_ids(app: Flask, monkey method="POST", json={"app_ids": excessive_ids}, ): + args = workflow_module.WorkflowOnlineUsersPayload.model_validate({"app_ids": excessive_ids}) with pytest.raises(HTTPException) as exc: - handler(api, "tenant-1", SimpleNamespace(id="account-1")) + handler(api, args, "tenant-1", SimpleNamespace(id="account-1")) assert exc.value.code == 400 assert exc.value.description is not None diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py index 56049936f27..337a4f31881 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py @@ -18,6 +18,7 @@ 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 +from tests.unit_tests.config_override import apply_config_overrides JAN_1_2024_NOON = datetime(2024, 1, 1, 12, 0, 0) JAN_1_2024_NOON_TS = int(JAN_1_2024_NOON.timestamp()) @@ -58,12 +59,11 @@ def _make_app() -> App: def _patch_console_guards(monkeypatch: pytest.MonkeyPatch, account: Account, app_model: App) -> None: - monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True) + apply_config_overrides(monkeypatch, LOGIN_DISABLED=True, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) monkeypatch.setattr(login_lib, "current_user", account) monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) monkeypatch.setattr(login_lib, "check_csrf_token", lambda *_, **__: None) monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) - monkeypatch.setattr(console_wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr(app_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) monkeypatch.setattr(app_wraps, "_load_app_model_from_scoped_session", lambda _app_id: app_model) diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_convert_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_convert_api.py index 73ed8471caf..54163d65448 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_convert_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_convert_api.py @@ -60,6 +60,7 @@ class TestConvertToWorkflowApi: current_user.id = "u1" response = method( api, + workflow_module.ConvertToWorkflowPayload.model_validate({}), current_tenant_id="tenant-1", current_user=current_user, app_model=_app("app-1"), diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py index a9f38ac504e..f223228048d 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py @@ -5,7 +5,7 @@ from unittest.mock import ANY, MagicMock import pytest from flask import Flask -from pydantic import ValidationError +from werkzeug.exceptions import UnprocessableEntity from controllers.console import wraps as console_wraps from controllers.console.app import workflow as workflow_module @@ -15,6 +15,7 @@ from libs import login as login_lib from models import App, Tenant from models.account import Account, AccountStatus, TenantAccountRole from models.model import AppMode, IconType +from tests.unit_tests.config_override import apply_config_overrides def _make_account() -> Account: @@ -47,14 +48,17 @@ def _make_app(mode: AppMode) -> App: 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) + apply_config_overrides( + monkeypatch, + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + LOGIN_DISABLED=True, + INIT_PASSWORD="", + ) monkeypatch.setattr(login_lib, "current_user", account) monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) monkeypatch.setattr(login_lib, "check_csrf_token", lambda *_, **__: None) monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) monkeypatch.setattr(app_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) - monkeypatch.setattr(console_wraps.dify_config, "INIT_PASSWORD", "") # Avoid hitting the database when resolving the app model monkeypatch.setattr(app_wraps, "_load_app_model_from_scoped_session", lambda _app_id: app_model) @@ -241,5 +245,5 @@ def test_human_input_preview_rejects_non_mapping(app: Flask, monkeypatch: pytest method="POST", json={"inputs": ["not-a-dict"]}, ): - with pytest.raises(ValidationError): + with pytest.raises(UnprocessableEntity): workflow_module.AdvancedChatDraftHumanInputFormPreviewApi().post(app_id=app_model.id, node_id="node-1") diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py index 88aed318fb2..a1be8254d63 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py @@ -20,6 +20,7 @@ from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom from models.workflow import WorkflowPause, WorkflowRun, WorkflowType +from tests.unit_tests.config_override import apply_config_overrides @dataclass(frozen=True) @@ -77,7 +78,7 @@ class _PauseEntity: def test_pause_details_returns_backstage_input_url( app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: - monkeypatch.setattr(workflow_run_module.dify_config, "APP_WEB_URL", "https://web.example.com") + apply_config_overrides(monkeypatch, APP_WEB_URL="https://web.example.com") tenant_id = str(uuid4()) run_id = str(uuid4()) @@ -137,7 +138,7 @@ def test_pause_details_returns_backstage_input_url( def test_pause_details_tenant_isolation(app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: - monkeypatch.setattr(workflow_run_module.dify_config, "APP_WEB_URL", "https://web.example.com") + apply_config_overrides(monkeypatch, APP_WEB_URL="https://web.example.com") run_id = str(uuid4()) _persist_run( diff --git a/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py b/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py index 37e67cf6fc5..64e2813f62f 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py +++ b/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py @@ -12,6 +12,7 @@ from controllers.console.auth.error import AuthenticationFailedError from controllers.console.auth.login import LoginApi from enums import DeploymentEdition from models.account import Account +from tests.unit_tests.config_override import config_overrides_context def encode_password(password: str) -> str: @@ -35,7 +36,7 @@ class TestAuthenticationSecurity: @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.AccountService.authenticate") @patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit") - @patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + @config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") def test_login_invalid_email_with_registration_allowed( self, mock_get_invitation, mock_add_rate_limit, mock_authenticate, mock_is_rate_limit, mock_features, mock_db @@ -67,7 +68,7 @@ class TestAuthenticationSecurity: @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.AccountService.authenticate") @patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit") - @patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + @config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") def test_login_wrong_password_returns_error( self, mock_get_invitation, mock_add_rate_limit, mock_authenticate, mock_is_rate_limit, mock_db @@ -99,7 +100,7 @@ class TestAuthenticationSecurity: @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.AccountService.authenticate") @patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit") - @patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + @config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") def test_login_invalid_email_with_registration_disabled( self, mock_get_invitation, mock_add_rate_limit, mock_authenticate, mock_is_rate_limit, mock_features, mock_db diff --git a/api/tests/unit_tests/controllers/console/auth/test_data_source_oauth.py b/api/tests/unit_tests/controllers/console/auth/test_data_source_oauth.py index 1f6cd681a75..72d28c0385b 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_data_source_oauth.py +++ b/api/tests/unit_tests/controllers/console/auth/test_data_source_oauth.py @@ -19,6 +19,7 @@ from services.data_source_oauth_service import ( InvalidDataSourceOAuthProviderError, ) from services.entities.data_source_oauth_entities import DataSourceOAuthCallback +from tests.unit_tests.config_override import config_overrides_context def _request_context() -> RequestContext: @@ -72,10 +73,7 @@ def test_callback_parses_query_and_returns_flask_redirect() -> None: with ( app.test_request_context("/?code=code-1"), - patch( - "controllers.console.auth.data_source_oauth.dify_config.CONSOLE_WEB_URL", - "https://console.example/root?lang=en#top", - ), + config_overrides_context(CONSOLE_WEB_URL="https://console.example/root?lang=en#top"), patch( "controllers.console.auth.data_source_oauth.application_services", return_value=_services(service), diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_register.py b/api/tests/unit_tests/controllers/console/auth/test_email_register.py index 4f1bec336e5..7b5f859877b 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_email_register.py +++ b/api/tests/unit_tests/controllers/console/auth/test_email_register.py @@ -1,30 +1,57 @@ -"""Unit tests for email register controller endpoints.""" +"""Unit tests for the email-registration Flask adapter.""" from __future__ import annotations -from collections.abc import Callable -from unittest.mock import MagicMock, patch +from collections.abc import Callable, Generator +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import Mock, patch import pytest from flask import Flask +from pydantic import ValidationError +from controllers.console import bp as console_bp from controllers.console.auth.email_register import ( EmailRegisterCheckApi, EmailRegisterResetApi, + EmailRegisterResetPayload, EmailRegisterSendEmailApi, ) -from controllers.console.auth.error import NormalizedEmailAlreadyInUseError -from controllers.console.error import AccountInFreezeError, EmailDomainSuspendedError +from controllers.console.auth.error import ( + EmailAlreadyInUseError, + EmailCodeError, + EmailRegisterLimitError, + EmailRegisterRateLimitExceededError, + InvalidEmailError, + InvalidTokenError, + NormalizedEmailAlreadyInUseError, + PasswordMismatchError, +) +from controllers.console.error import ( + AccountInFreezeError, + EmailDomainSuspendedError, + EmailSendIpLimitError, + SeatsLimitExceeded, +) from enums import DeploymentEdition -from models.account import Account -from services.entities.feature_entities import SystemFeatureModel -from services.errors.account import ( +from services.account_email_registration_service import AccountEmailRegistrationService +from services.account_errors import ( + AccountEmailAlreadyInUseError, + AccountEmailDomainSuspendedError, + AccountEmailFrozenError, AccountNormalizedEmailAlreadyInUseError, - AccountRegisterError, -) -from services.errors.account import ( - EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError, + EmailRegistrationPasswordMismatchError, + EmailRegistrationSeatsLimitError, + EmailRegistrationSendIPLimitedError, + EmailRegistrationSendRateLimitError, + EmailRegistrationVerificationLimitError, + InvalidEmailRegistrationAddressError, + InvalidEmailRegistrationCodeError, + InvalidEmailRegistrationTokenError, ) +from services.entities.account_entities import AccountEmailRegistrationVerification, AccountSessionTokens +from services.entities.feature_entities import SystemFeatureModel @pytest.fixture(autouse=True) @@ -32,6 +59,33 @@ def _cloud_edition(config_overrides: Callable[..., None]) -> None: config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) +@contextmanager +def _request( + app: Flask, + service: Mock, + *, + path: str, + payload: dict[str, str], +) -> Generator[None, None, None]: + services = SimpleNamespace(accounts=SimpleNamespace(email_registration=service)) + features = SystemFeatureModel( + deployment_edition=DeploymentEdition.CLOUD, + enable_email_password_login=True, + is_allow_register=True, + ) + with ( + patch("controllers.console.auth.email_register.application_services", return_value=services), + patch("controllers.console.flask_admission.FeatureService.get_system_features", return_value=features), + patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1"), + app.test_request_context(path, method="POST", json=payload), + ): + yield + + +def _service() -> Mock: + return Mock(spec=AccountEmailRegistrationService) + + def test_normalized_email_conflict_exposes_a_distinct_error_code() -> None: error = NormalizedEmailAlreadyInUseError() @@ -40,323 +94,210 @@ def test_normalized_email_conflict_exposes_a_distinct_error_code() -> None: assert error.data["code"] == "normalized_email_already_in_use" -class TestEmailRegisterSendEmailApi: - @patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback") - @patch("controllers.console.auth.email_register.AccountService.send_email_register_email") - @patch("controllers.console.auth.email_register.BillingService.get_email_freeze_type") - @patch("controllers.console.auth.email_register.AccountService.is_email_send_ip_limit", return_value=False) - @patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1") - def test_send_email_normalizes_and_falls_back( - self, - mock_extract_ip, - mock_is_email_send_ip_limit, - mock_is_freeze, - mock_send_mail, - mock_get_account, - app: Flask, +def test_send_email_delegates_with_remote_ip(app: Flask) -> None: + service = _service() + service.send_code.return_value = "token-123" + + with _request( + app, + service, + path="/email-register/send-email", + payload={"email": "Invitee@Example.com", "language": "zh-Hans"}, ): - mock_send_mail.return_value = "token-123" - mock_is_freeze.return_value = False - account = Account(name="Invitee", email="invitee@example.com") - mock_get_account.return_value = account + response = EmailRegisterSendEmailApi().post() - feature_flags = SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - enable_email_password_login=True, - is_allow_register=True, - ) - with ( - patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), - ): - with app.test_request_context( - "/email-register/send-email", - method="POST", - json={"email": "Invitee@Example.com", "language": "en-US"}, - ): - response = EmailRegisterSendEmailApi().post() + assert response == {"result": "success", "data": "token-123"} + assert service.send_code.call_args.kwargs == { + "remote_ip": "127.0.0.1", + "requested_email": "Invitee@Example.com", + "requested_language": "zh-Hans", + } - assert response == {"result": "success", "data": "token-123"} - mock_is_freeze.assert_called_once_with("invitee@example.com") - mock_send_mail.assert_called_once_with(email="invitee@example.com", account=account, language="en-US") - mock_extract_ip.assert_called_once() - mock_is_email_send_ip_limit.assert_called_once_with("127.0.0.1") - @pytest.mark.parametrize( - ("freeze_type", "expected_error"), - [ - ("freeze", AccountInFreezeError), - ("email_domain_suspended", EmailDomainSuspendedError), - ], +@pytest.mark.parametrize( + ("service_error", "http_error"), + [ + pytest.param(EmailRegistrationSendIPLimitedError(), EmailSendIpLimitError, id="ip-limit"), + pytest.param(EmailRegistrationSendRateLimitError(1), EmailRegisterRateLimitExceededError, id="send-limit"), + pytest.param(AccountEmailFrozenError(), AccountInFreezeError, id="frozen"), + pytest.param(AccountEmailDomainSuspendedError(), EmailDomainSuspendedError, id="suspended-domain"), + ], +) +def test_send_email_translates_application_errors( + app: Flask, + service_error: Exception, + http_error: type[Exception], +) -> None: + service = _service() + service.send_code.side_effect = service_error + + with _request( + app, + service, + path="/email-register/send-email", + payload={"email": "invitee@example.com"}, + ): + with pytest.raises(http_error): + EmailRegisterSendEmailApi().post() + + +def test_verify_email_code_serializes_application_result(app: Flask) -> None: + service = _service() + service.verify_code.return_value = AccountEmailRegistrationVerification( + email="user@example.com", + token="verified-token", ) - @patch("controllers.console.auth.email_register.BillingService.get_email_freeze_type") - @patch("controllers.console.auth.email_register.AccountService.is_email_send_ip_limit", return_value=False) - @patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1") - def test_send_email_rejects_frozen_email( - self, - mock_extract_ip, - mock_is_email_send_ip_limit, - mock_get_freeze_type, - app: Flask, - freeze_type, - expected_error, + + with _request( + app, + service, + path="/email-register/validity", + payload={"email": "User@Example.com", "code": "123456", "token": "pending-token"}, ): - mock_get_freeze_type.return_value = freeze_type - feature_flags = SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - enable_email_password_login=True, - is_allow_register=True, - ) + response = EmailRegisterCheckApi().post() - with ( - patch("controllers.console.auth.email_register.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), - ): - with app.test_request_context( - "/email-register/send-email", - method="POST", - json={"email": "Invitee@Example.com"}, - ): - with pytest.raises(expected_error): - EmailRegisterSendEmailApi().post() - - mock_get_freeze_type.assert_called_once_with("invitee@example.com") - mock_is_email_send_ip_limit.assert_called_once_with("127.0.0.1") - mock_extract_ip.assert_called_once() - - -class TestEmailRegisterCheckApi: - @patch("controllers.console.auth.email_register.AccountService.reset_email_register_error_rate_limit") - @patch("controllers.console.auth.email_register.AccountService.generate_email_register_token") - @patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token") - @patch("controllers.console.auth.email_register.AccountService.add_email_register_error_rate_limit") - @patch("controllers.console.auth.email_register.AccountService.get_email_register_data") - @patch("controllers.console.auth.email_register.AccountService.is_email_register_error_rate_limit") - def test_validity_normalizes_email_before_checks( - self, - mock_rate_limit_check, - mock_get_data, - mock_add_rate, - mock_revoke, - mock_generate_token, - mock_reset_rate, - app: Flask, - ): - mock_rate_limit_check.return_value = False - mock_get_data.return_value = {"email": "User@Example.com", "code": "4321"} - mock_generate_token.return_value = (None, "new-token") - - feature_flags = SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - enable_email_password_login=True, - is_allow_register=True, - ) - with ( - patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), - ): - with app.test_request_context( - "/email-register/validity", - method="POST", - json={"email": "User@Example.com", "code": "4321", "token": "token-123"}, - ): - response = EmailRegisterCheckApi().post() - - assert response == {"is_valid": True, "email": "user@example.com", "token": "new-token"} - mock_rate_limit_check.assert_called_once_with("user@example.com") - mock_generate_token.assert_called_once_with( - "user@example.com", code="4321", additional_data={"phase": "register"} - ) - mock_reset_rate.assert_called_once_with("user@example.com") - mock_add_rate.assert_not_called() - mock_revoke.assert_called_once_with("token-123") - - -class TestEmailRegisterResetApi: - @pytest.mark.parametrize( - ("service_error", "expected_error"), - [ - (EmailDomainSuspendedRegistrationError(), EmailDomainSuspendedError), - (AccountNormalizedEmailAlreadyInUseError(), NormalizedEmailAlreadyInUseError), - (AccountRegisterError("frozen"), AccountInFreezeError), - ], + assert response == {"is_valid": True, "email": "user@example.com", "token": "verified-token"} + service.verify_code.assert_called_once_with( + email="User@Example.com", + code="123456", + token="pending-token", ) - @patch("controllers.console.auth.email_register.AccountService.create_account_and_tenant") - def test_create_new_account_translates_freeze_errors( - self, - mock_create_account, - service_error, - expected_error, + + +@pytest.mark.parametrize( + ("service_error", "http_error"), + [ + pytest.param(EmailRegistrationVerificationLimitError(), EmailRegisterLimitError, id="attempt-limit"), + pytest.param(InvalidEmailRegistrationTokenError(), InvalidTokenError, id="token"), + pytest.param(InvalidEmailRegistrationAddressError(), InvalidEmailError, id="email"), + pytest.param(InvalidEmailRegistrationCodeError(), EmailCodeError, id="code"), + ], +) +def test_verify_email_code_translates_application_errors( + app: Flask, + service_error: Exception, + http_error: type[Exception], +) -> None: + service = _service() + service.verify_code.side_effect = service_error + + with _request( + app, + service, + path="/email-register/validity", + payload={"email": "user@example.com", "code": "wrong", "token": "pending-token"}, ): - mock_create_account.side_effect = service_error + with pytest.raises(http_error): + EmailRegisterCheckApi().post() - with pytest.raises(expected_error): - EmailRegisterResetApi()._create_new_account( - email="user@example.com", - password="ValidPass123!", - ) - @patch("controllers.console.auth.email_register.AccountService.reset_login_error_rate_limit") - @patch("controllers.console.auth.email_register.AccountService.login") - @patch("controllers.console.auth.email_register.EmailRegisterResetApi._create_new_account") - @patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback") - @patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token") - @patch("controllers.console.auth.email_register.AccountService.get_email_register_data") - @patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1") - def test_reset_creates_account_with_normalized_email( - self, - mock_extract_ip, - mock_get_data, - mock_revoke_token, - mock_get_account, - mock_create_account, - mock_login, - mock_reset_login_rate, - app: Flask, +def test_register_delegates_and_serializes_tokens(app: Flask) -> None: + service = _service() + service.register.return_value = AccountSessionTokens( + access_token="access", + refresh_token="refresh", + csrf_token="csrf", + ) + + with _request( + app, + service, + path="/email-register", + payload={ + "token": "verified-token", + "new_password": "ValidPass123!", + "password_confirm": "ValidPass123!", + "language": "zh-Hans", + "timezone": "Asia/Shanghai", + }, ): - mock_get_data.return_value = {"phase": "register", "email": "Invitee@Example.com"} - mock_create_account.return_value = Account(name="Invitee", email="invitee@example.com") - token_pair = MagicMock() - token_pair.model_dump.return_value = {"access_token": "a", "refresh_token": "r"} - mock_login.return_value = token_pair - mock_get_account.return_value = None + response = EmailRegisterResetApi().post() - feature_flags = SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - enable_email_password_login=True, - is_allow_register=True, - ) - with ( - patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), - ): - with app.test_request_context( - "/email-register", - method="POST", - json={"token": "token-123", "new_password": "ValidPass123!", "password_confirm": "ValidPass123!"}, - ): - response = EmailRegisterResetApi().post() + assert response == { + "result": "success", + "data": {"access_token": "access", "refresh_token": "refresh", "csrf_token": "csrf"}, + } + assert service.register.call_args.kwargs == { + "remote_ip": "127.0.0.1", + "token": "verified-token", + "new_password": "ValidPass123!", + "password_confirm": "ValidPass123!", + "language": "zh-Hans", + "timezone": "Asia/Shanghai", + } - assert response == {"result": "success", "data": {"access_token": "a", "refresh_token": "r"}} - mock_create_account.assert_called_once_with( - email="invitee@example.com", - 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") - mock_extract_ip.assert_called_once() - @patch("controllers.console.auth.email_register.AccountService.reset_login_error_rate_limit") - @patch("controllers.console.auth.email_register.AccountService.login") - @patch("controllers.console.auth.email_register.EmailRegisterResetApi._create_new_account") - @patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback") - @patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token") - @patch("controllers.console.auth.email_register.AccountService.get_email_register_data") - @patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1") - def test_reset_passes_timezone_to_new_account( - self, - mock_extract_ip, - mock_get_data, - mock_revoke_token, - mock_get_account, - mock_create_account, - mock_login, - mock_reset_login_rate, - app: Flask, +@pytest.mark.parametrize( + ("service_error", "http_error"), + [ + pytest.param(EmailRegistrationPasswordMismatchError(), PasswordMismatchError, id="password"), + pytest.param(InvalidEmailRegistrationTokenError(), InvalidTokenError, id="token"), + pytest.param( + AccountNormalizedEmailAlreadyInUseError(), + NormalizedEmailAlreadyInUseError, + id="normalized-email-in-use", + ), + pytest.param(AccountEmailAlreadyInUseError(), EmailAlreadyInUseError, id="email-in-use"), + pytest.param(EmailRegistrationSeatsLimitError(), SeatsLimitExceeded, id="seat-limit"), + pytest.param(AccountEmailFrozenError(), AccountInFreezeError, id="frozen"), + pytest.param(AccountEmailDomainSuspendedError(), EmailDomainSuspendedError, id="suspended-domain"), + ], +) +def test_register_translates_application_errors( + app: Flask, + service_error: Exception, + http_error: type[Exception], +) -> None: + service = _service() + service.register.side_effect = service_error + + with _request( + app, + service, + path="/email-register", + payload={ + "token": "verified-token", + "new_password": "ValidPass123!", + "password_confirm": "ValidPass123!", + }, ): - mock_get_data.return_value = {"phase": "register", "email": "Invitee@Example.com"} - mock_create_account.return_value = Account(name="Invitee", email="invitee@example.com") - token_pair = MagicMock() - token_pair.model_dump.return_value = {"access_token": "a", "refresh_token": "r"} - mock_login.return_value = token_pair - mock_get_account.return_value = None + with pytest.raises(http_error): + EmailRegisterResetApi().post() - feature_flags = SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - enable_email_password_login=True, - is_allow_register=True, + +def test_reset_payload_rejects_invalid_timezone() -> None: + with pytest.raises(ValidationError): + EmailRegisterResetPayload.model_validate( + { + "token": "token-123", + "new_password": "ValidPass123!", + "password_confirm": "ValidPass123!", + "timezone": "", + } ) - with ( - patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), - ): - with app.test_request_context( - "/email-register", - method="POST", - json={ - "token": "token-123", - "new_password": "ValidPass123!", - "password_confirm": "ValidPass123!", - "timezone": "Asia/Shanghai", - }, - ): - response = EmailRegisterResetApi().post() - assert response == {"result": "success", "data": {"access_token": "a", "refresh_token": "r"}} - mock_create_account.assert_called_once_with( - email="invitee@example.com", - password="ValidPass123!", - timezone="Asia/Shanghai", - language=None, - ip_address="127.0.0.1", + +def test_invalid_password_is_sanitized_by_real_error_handler(caplog: pytest.LogCaptureFixture) -> None: + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(console_bp) + features = SystemFeatureModel( + deployment_edition=DeploymentEdition.CLOUD, + enable_email_password_login=True, + is_allow_register=True, + ) + password_marker = "SecretMarker" + + with patch("controllers.console.flask_admission.FeatureService.get_system_features", return_value=features): + response = app.test_client().post( + "/console/api/email-register", + json={ + "token": "verified-token", + "new_password": password_marker, + "password_confirm": password_marker, + }, ) - mock_reset_login_rate.assert_called_once_with("invitee@example.com") - mock_revoke_token.assert_called_once_with("token-123") - mock_extract_ip.assert_called_once() - @patch("controllers.console.auth.email_register.AccountService.reset_login_error_rate_limit") - @patch("controllers.console.auth.email_register.AccountService.login") - @patch("controllers.console.auth.email_register.EmailRegisterResetApi._create_new_account") - @patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback") - @patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token") - @patch("controllers.console.auth.email_register.AccountService.get_email_register_data") - @patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1") - def test_reset_passes_language_to_new_account( - self, - mock_extract_ip, - mock_get_data, - mock_revoke_token, - mock_get_account, - mock_create_account, - mock_login, - mock_reset_login_rate, - app: Flask, - ): - mock_get_data.return_value = {"phase": "register", "email": "Invitee@Example.com"} - mock_create_account.return_value = Account(name="Invitee", email="invitee@example.com") - token_pair = MagicMock() - token_pair.model_dump.return_value = {"access_token": "a", "refresh_token": "r"} - mock_login.return_value = token_pair - mock_get_account.return_value = None - - feature_flags = SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - enable_email_password_login=True, - is_allow_register=True, - ) - with ( - patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), - ): - with app.test_request_context( - "/email-register", - method="POST", - json={ - "token": "token-123", - "new_password": "ValidPass123!", - "password_confirm": "ValidPass123!", - "language": "zh-Hans", - }, - ): - response = EmailRegisterResetApi().post() - - assert response == {"result": "success", "data": {"access_token": "a", "refresh_token": "r"}} - mock_create_account.assert_called_once_with( - email="invitee@example.com", - 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") - mock_extract_ip.assert_called_once() + assert response.status_code == 422 + assert password_marker not in response.get_data(as_text=True) + assert password_marker not in caplog.text diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_register_language.py b/api/tests/unit_tests/controllers/console/auth/test_email_register_language.py deleted file mode 100644 index e8331bda8cc..00000000000 --- a/api/tests/unit_tests/controllers/console/auth/test_email_register_language.py +++ /dev/null @@ -1,44 +0,0 @@ -from unittest.mock import ANY, patch - -import pytest -from pydantic import ValidationError - -from controllers.console.auth.email_register import EmailRegisterResetApi, EmailRegisterResetPayload -from models.account import Account - - -@patch("controllers.console.auth.email_register.AccountService.create_account_and_tenant") -def test_create_new_account_uses_requested_language(mock_create_account): - account = Account(name="Invitee", email="invitee@example.com") - mock_create_account.return_value = account - - result = EmailRegisterResetApi()._create_new_account( - "invitee@example.com", - "ValidPass123!", - timezone="Asia/Shanghai", - language="zh-Hans", - ) - - assert result is account - mock_create_account.assert_called_once_with( - email="invitee@example.com", - name="invitee@example.com", - password="ValidPass123!", - interface_language="zh-Hans", - timezone="Asia/Shanghai", - ip_address=None, - check_normalized_email=True, - session=ANY, - ) - - -def test_reset_payload_rejects_invalid_timezone(): - with pytest.raises(ValidationError): - EmailRegisterResetPayload.model_validate( - { - "token": "token-123", - "new_password": "ValidPass123!", - "password_confirm": "ValidPass123!", - "timezone": "", - } - ) diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py index 75ed9f5b6af..930ac17279b 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py +++ b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py @@ -461,14 +461,17 @@ class TestEmailCodeLoginApi: mock_verify_challenge, mock_db, app: Flask, + config_overrides: Callable[..., None], ): + config_overrides( + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED=True, + ) mock_verify_challenge.return_value = EmailCodeLoginChallengeResult( status=EmailCodeLoginChallengeStatus.INVALID_TOKEN ) with ( - patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch("controllers.console.auth.login.dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED", True), app.test_request_context( "/email-code-login/validity", method="POST", @@ -502,10 +505,13 @@ class TestEmailCodeLoginApi: mock_verify_challenge, mock_db, app: Flask, + config_overrides: Callable[..., None], ): + config_overrides( + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED=True, + ) with ( - patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch("controllers.console.auth.login.dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED", True), app.test_request_context( "/email-code-login/validity", method="POST", @@ -527,14 +533,17 @@ class TestEmailCodeLoginApi: mock_verify_challenge, mock_db, app: Flask, + config_overrides: Callable[..., None], ): + config_overrides( + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED=False, + ) mock_verify_challenge.return_value = EmailCodeLoginChallengeResult( status=EmailCodeLoginChallengeStatus.INVALID_TOKEN ) with ( - patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch("controllers.console.auth.login.dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED", False), app.test_request_context( "/email-code-login/validity", method="POST", diff --git a/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py b/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py index 783aac0a5df..b9eda4eeb0a 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py +++ b/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py @@ -17,6 +17,7 @@ from enums import DeploymentEdition from models.account import Account from models.engine import db from services.entities.feature_entities import SystemFeatureModel +from tests.unit_tests.config_override import config_overrides_context @pytest.fixture @@ -61,7 +62,7 @@ class TestForgotPasswordSendEmailApi: "controllers.console.auth.forgot_password.FeatureService.get_system_features", return_value=controller_features, ), - patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), ): with app.test_request_context( @@ -108,7 +109,7 @@ class TestForgotPasswordCheckApi: enable_email_password_login=True, ) with ( - patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), ): with app.test_request_context( @@ -154,7 +155,7 @@ class TestForgotPasswordResetApi: enable_email_password_login=True, ) with ( - patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), ): with database_app.test_request_context( diff --git a/api/tests/unit_tests/controllers/console/auth/test_oauth.py b/api/tests/unit_tests/controllers/console/auth/test_oauth.py index 969e84505aa..1d95860e1b4 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_oauth.py +++ b/api/tests/unit_tests/controllers/console/auth/test_oauth.py @@ -22,6 +22,7 @@ from services.errors.account import AccountRegisterError from services.errors.account import ( EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError, ) +from tests.unit_tests.config_override import config_overrides_context @pytest.fixture(autouse=True) @@ -586,7 +587,7 @@ class TestAccountGeneration: ("freeze", AccountRegisterError), ], ) - @patch("controllers.console.auth.oauth.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + @config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) @patch("controllers.console.auth.oauth.BillingService.get_email_freeze_type") @patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None) @patch("controllers.console.auth.oauth.FeatureService") diff --git a/api/tests/unit_tests/controllers/console/auth/test_oauth_redirect.py b/api/tests/unit_tests/controllers/console/auth/test_oauth_redirect.py index 59a12a9b329..e3910d4a348 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_oauth_redirect.py +++ b/api/tests/unit_tests/controllers/console/auth/test_oauth_redirect.py @@ -7,6 +7,7 @@ from flask import Flask from controllers.console.auth.oauth import OAuthCallback, OAuthLogin from libs.oauth import OAuthUserInfo, encode_oauth_state from models.account import Account, AccountStatus, Tenant +from tests.unit_tests.config_override import config_overrides_context REDIRECT_URL = "/apps?category=workflow" CONSOLE_WEB_URL = "https://console.example.com" @@ -74,7 +75,7 @@ def test_oauth_callback_validates_redirect_url_and_appends_new_user_flag( with ( patch("controllers.console.auth.oauth.get_oauth_providers", return_value={"google": oauth_provider}), - patch("controllers.console.auth.oauth.dify_config.CONSOLE_WEB_URL", CONSOLE_WEB_URL), + config_overrides_context(CONSOLE_WEB_URL=CONSOLE_WEB_URL), patch("controllers.console.auth.oauth._generate_account", return_value=(account, oauth_new_user)), patch("controllers.console.auth.oauth.TenantService.create_owner_tenant_if_not_exist"), patch("controllers.console.auth.oauth.AccountService.login", return_value=token_pair), @@ -109,7 +110,7 @@ def test_oauth_callback_with_invitation_establishes_console_session(app: Flask) with ( patch("controllers.console.auth.oauth.get_oauth_providers", return_value={"google": oauth_provider}), - patch("controllers.console.auth.oauth.dify_config.CONSOLE_WEB_URL", CONSOLE_WEB_URL), + config_overrides_context(CONSOLE_WEB_URL=CONSOLE_WEB_URL), patch("controllers.console.auth.oauth.RegisterService") as register_service, patch("controllers.console.auth.oauth.AccountService.link_account_integrate") as link_account, patch("controllers.console.auth.oauth.AccountService.login", return_value=token_pair) as login, @@ -155,7 +156,7 @@ def test_oauth_callback_with_invitation_rejects_another_account(app: Flask) -> N with ( patch("controllers.console.auth.oauth.get_oauth_providers", return_value={"google": oauth_provider}), - patch("controllers.console.auth.oauth.dify_config.CONSOLE_WEB_URL", CONSOLE_WEB_URL), + config_overrides_context(CONSOLE_WEB_URL=CONSOLE_WEB_URL), patch("controllers.console.auth.oauth.RegisterService") as register_service, patch("controllers.console.auth.oauth.AccountService.link_account_integrate") as link_account, patch("controllers.console.auth.oauth.AccountService.login") as login, diff --git a/api/tests/unit_tests/controllers/console/auth/test_password_reset.py b/api/tests/unit_tests/controllers/console/auth/test_password_reset.py index df68f2f7eaf..0db3b031cd9 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_password_reset.py +++ b/api/tests/unit_tests/controllers/console/auth/test_password_reset.py @@ -26,6 +26,7 @@ from controllers.console.error import AccountNotFound, EmailSendIpLimitError from enums import DeploymentEdition from models.account import Account, Tenant, TenantAccountJoin from services.entities.feature_entities import SystemFeatureModel +from tests.unit_tests.config_override import apply_config_overrides SQLITE_MODELS = (Account, Tenant, TenantAccountJoin) @@ -46,7 +47,7 @@ def _bind_database_session(session: Session) -> Generator[scoped_session[Session def enable_password_login_wrappers(monkeypatch: pytest.MonkeyPatch) -> None: """Keep endpoint decorators deterministic without requiring the configured app database.""" - monkeypatch.setattr("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) monkeypatch.setattr( "controllers.console.wraps.FeatureService.get_system_features", lambda: SystemFeatureModel( diff --git a/api/tests/unit_tests/controllers/console/billing/test_billing.py b/api/tests/unit_tests/controllers/console/billing/test_billing.py index ce26f4a8c6d..b69671a598d 100644 --- a/api/tests/unit_tests/controllers/console/billing/test_billing.py +++ b/api/tests/unit_tests/controllers/console/billing/test_billing.py @@ -24,6 +24,7 @@ from services.errors.billing import ( BillingUpstreamInvalidResponseError, BillingUpstreamUnavailableError, ) +from tests.unit_tests.config_override import config_overrides_context class TestBillingPortal: @@ -188,8 +189,7 @@ class TestPartnerTenants: console_wraps._is_setup_completed.reset_success() monkeypatch.setattr(console_wraps.db, "session", sqlite_session) with ( - patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch("libs.login.dify_config.LOGIN_DISABLED", False), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, LOGIN_DISABLED=False), patch("libs.login.check_csrf_token") as mock_csrf, ): mock_csrf.return_value = None diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py index 91815612355..f3b0cb25953 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py @@ -27,6 +27,7 @@ from models.engine import db from services.entities.knowledge_entities.rag_pipeline_entities import PipelineTemplateInfoEntity from services.errors.account import NoPermissionError from services.errors.rag_pipeline import RagPipelineResourceNotFoundError +from tests.unit_tests.config_override import config_overrides_context def _template_item() -> dict[str, object]: @@ -376,7 +377,7 @@ class TestPublishCustomizedPipelineTemplateApi: dataset = object() with ( - patch.object(module.dify_config, "RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(Pipeline, "retrieve_dataset", return_value=dataset), patch.object(module.DatasetService, "check_dataset_permission") as legacy_acl, patch.object(module.RagPipelineService, "publish_customized_pipeline_template") as publish, @@ -409,7 +410,7 @@ class TestPublishCustomizedPipelineTemplateApi: dataset = object() with ( - patch.object(module.dify_config, "RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(Pipeline, "retrieve_dataset", return_value=dataset), patch.object(module.RagPipelineService, "publish_customized_pipeline_template") as publish, ): @@ -425,7 +426,7 @@ class TestPublishCustomizedPipelineTemplateApi: payload = _payload() with ( - patch.object(module.dify_config, "RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(Pipeline, "retrieve_dataset", return_value=object()), patch.object( module.RagPipelineService, @@ -446,7 +447,7 @@ class TestPublishCustomizedPipelineTemplateApi: payload = _payload() with ( - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(Pipeline, "retrieve_dataset", return_value=dataset), patch.object(module.DatasetService, "check_dataset_permission") as check_permission, patch.object(module.RagPipelineService, "publish_customized_pipeline_template") as publish, @@ -464,7 +465,7 @@ class TestPublishCustomizedPipelineTemplateApi: account.role = TenantAccountRole.NORMAL with ( - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(Pipeline, "retrieve_dataset", return_value=object()), patch.object(module.DatasetService, "check_dataset_permission") as check_permission, patch.object(module.RagPipelineService, "publish_customized_pipeline_template") as publish, @@ -482,7 +483,7 @@ class TestPublishCustomizedPipelineTemplateApi: account.role = TenantAccountRole.EDITOR with ( - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(Pipeline, "retrieve_dataset", return_value=object()), patch.object( module.DatasetService, @@ -503,7 +504,7 @@ class TestPublishCustomizedPipelineTemplateApi: account.role = TenantAccountRole.EDITOR with ( - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(Pipeline, "retrieve_dataset", return_value=None), patch.object(module.DatasetService, "check_dataset_permission") as check_permission, patch.object(module.RagPipelineService, "publish_customized_pipeline_template") as publish, diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py index fdc9dfe54f7..f4a2e2c948f 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py @@ -34,6 +34,7 @@ from models.workflow import Workflow, WorkflowType from services.errors.llm import InvokeRateLimitError from services.errors.rag_pipeline import RagPipelineResourceNotFoundError from services.rag_pipeline.rag_pipeline import RagPipelineService +from tests.unit_tests.config_override import config_overrides_context DEFAULT_WORKFLOW_TENANT_ID = "00000000-0000-0000-0000-000000000001" DEFAULT_WORKFLOW_APP_ID = "00000000-0000-0000-0000-000000000002" @@ -259,7 +260,7 @@ def test_rag_pipeline_transform_rejects_read_only_member(sqlite_engine: Engine) session.add(_dataset()) with ( - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), pytest.raises(Forbidden), ): handler(api, session, DEFAULT_WORKFLOW_TENANT_ID, account, UUID(DEFAULT_DATASET_ID)) @@ -293,7 +294,7 @@ def test_rag_pipeline_transform_enforces_legacy_dataset_permission_before_servic session.add(_dataset(maintainer="00000000-0000-0000-0000-000000000099")) with ( - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module.RagPipelineTransformService, "transform_dataset") as transform_dataset, pytest.raises(Forbidden), ): @@ -315,7 +316,7 @@ def test_rag_pipeline_transform_passes_authorized_dataset_and_account_to_service session.add(dataset) with ( - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module.RagPipelineTransformService, "transform_dataset", return_value=expected) as transform, ): response = handler(api, session, DEFAULT_WORKFLOW_TENANT_ID, account, UUID(DEFAULT_DATASET_ID)) @@ -333,7 +334,7 @@ def test_rag_pipeline_transform_maps_missing_pipeline_to_not_found(sqlite_engine session.add(_dataset()) with ( - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object( module.RagPipelineTransformService, "transform_dataset", @@ -355,7 +356,7 @@ def test_rag_pipeline_transform_skips_legacy_acl_when_rbac_is_enabled(sqlite_eng session.add(_dataset(maintainer="00000000-0000-0000-0000-000000000099")) with ( - patch.object(module.dify_config, "RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(module.RagPipelineTransformService, "transform_dataset", return_value=expected) as transform, ): response = handler(api, session, DEFAULT_WORKFLOW_TENANT_ID, account, UUID(DEFAULT_DATASET_ID)) diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py index ff6b8646658..37d174f8dca 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py @@ -1,5 +1,6 @@ import datetime import json +from collections.abc import Callable from contextlib import ExitStack from inspect import unwrap from types import SimpleNamespace @@ -7,6 +8,7 @@ from unittest.mock import ANY, MagicMock, PropertyMock, call, patch import pytest from flask import Flask +from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest, Forbidden, NotFound import services @@ -46,7 +48,7 @@ from core.rag.index_processor.constant.index_type import IndexStructureType from core.rag.retrieval.retrieval_methods import RetrievalMethod from extensions.storage.storage_type import StorageType from models.account import Account, TenantAccountRole -from models.dataset import Dataset, DatasetQuery, Document +from models.dataset import AppDatasetJoin, Dataset, DatasetPermission, DatasetQuery, Document, DocumentSegment from models.enums import CreatorUserRole, DataSourceType, DocumentCreatedFrom, IndexingStatus from models.knowledge_fs import KnowledgeFSUpgradeJobStatus, KnowledgeFSUpgradeStage from models.model import ApiToken, App, AppMode, IconType, UploadFile @@ -173,7 +175,29 @@ def make_document_status(**overrides) -> Document: return Document(**base) -class TestDatasetList: +def make_document_segment(*, position: int, completed: bool) -> DocumentSegment: + return DocumentSegment( + tenant_id="tenant-1", + dataset_id="dataset-1", + document_id="doc-1", + position=position, + content=f"segment {position}", + word_count=2, + tokens=2, + created_by="account-1", + completed_at=datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC) if completed else None, + ) + + +class _UsesSQLiteSession: + session: Session + + @pytest.fixture(autouse=True) + def _inject_sqlite_session(self, sqlite_session: Session) -> None: + self.session = sqlite_session + + +class TestDatasetList(_UsesSQLiteSession): def _mock_user(self): user = make_account() return user @@ -189,7 +213,7 @@ class TestDatasetList: patch.object(DatasetService, "get_datasets", return_value=(datasets, 1)), patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])), ): - resp, status = method(api, MagicMock(), "tenant-1", current_user) + resp, status = method(api, self.session, "tenant-1", current_user) assert status == 200 assert resp["total"] == 1 assert resp["data"][0]["embedding_available"] is True @@ -250,7 +274,7 @@ class TestDatasetList: method = unwrap(api.get) current_user = self._mock_user() dataset = make_dataset() - session = MagicMock() + session = self.session with app.test_request_context("/datasets"): with ( patch.object(DatasetService, "get_datasets", return_value=([dataset], 1)), @@ -271,7 +295,7 @@ class TestDatasetList: patch.object(DatasetService, "get_datasets_by_ids", return_value=(datasets, 2)) as by_ids_mock, patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])), ): - resp, status = method(api, MagicMock(), "tenant-1", current_user) + resp, status = method(api, self.session, "tenant-1", current_user) by_ids_mock.assert_called_once() assert status == 200 assert resp["total"] == 2 @@ -316,12 +340,15 @@ class TestDatasetList: return_value=permissions, ) as get_permissions, ): - resp, status = method(api, MagicMock(), "tenant-1", current_user) + resp, status = method(api, self.session, "tenant-1", current_user) get_permissions.assert_called_once_with("tenant-1", current_user.id, session=ANY) assert status == 200 assert resp["data"][0]["permission_keys"] == ["dataset.acl.readonly", "dataset.acl.edit"] - def test_get_restricted_whitelist_blocks_own_dataset_fallback(self, app: Flask): + def test_get_restricted_whitelist_blocks_own_dataset_fallback( + self, app: Flask, config_overrides: Callable[..., None] + ): + config_overrides(RBAC_ENABLED=True) api = DatasetListApi() method = unwrap(api.get) current_user = self._mock_user() @@ -332,7 +359,6 @@ class TestDatasetList: ) with app.test_request_context("/datasets"): with ( - patch("controllers.console.datasets.datasets.dify_config.RBAC_ENABLED", True), patch.object(DatasetService, "get_datasets", return_value=([], 0)) as get_datasets, patch( "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.MyPermissions.get", @@ -344,11 +370,14 @@ class TestDatasetList: ), patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])), ): - method(api, MagicMock(), "tenant-1", current_user) + method(api, self.session, "tenant-1", current_user) assert get_datasets.call_args.kwargs["accessible_dataset_ids"] == [] assert get_datasets.call_args.kwargs["include_own_datasets"] is False - def test_get_default_read_is_unrestricted_when_whitelist_unrestricted(self, app: Flask): + def test_get_default_read_is_unrestricted_when_whitelist_unrestricted( + self, app: Flask, config_overrides: Callable[..., None] + ): + config_overrides(RBAC_ENABLED=True) api = DatasetListApi() method = unwrap(api.get) current_user = self._mock_user() @@ -357,7 +386,6 @@ class TestDatasetList: ) with app.test_request_context("/datasets"): with ( - patch("controllers.console.datasets.datasets.dify_config.RBAC_ENABLED", True), patch.object(DatasetService, "get_datasets", return_value=([], 0)) as get_datasets, patch( "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.MyPermissions.get", @@ -369,10 +397,13 @@ class TestDatasetList: ), patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])), ): - method(api, MagicMock(), "tenant-1", current_user) + method(api, self.session, "tenant-1", current_user) assert get_datasets.call_args.kwargs["accessible_dataset_ids"] is None - def test_get_restricted_whitelist_overrides_default_read_permission(self, app: Flask): + def test_get_restricted_whitelist_overrides_default_read_permission( + self, app: Flask, config_overrides: Callable[..., None] + ): + config_overrides(RBAC_ENABLED=True) api = DatasetListApi() method = unwrap(api.get) current_user = self._mock_user() @@ -381,7 +412,6 @@ class TestDatasetList: ) with app.test_request_context("/datasets"): with ( - patch("controllers.console.datasets.datasets.dify_config.RBAC_ENABLED", True), patch.object(DatasetService, "get_datasets", return_value=([], 0)) as get_datasets, patch( "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.MyPermissions.get", @@ -397,7 +427,10 @@ class TestDatasetList: assert get_datasets.call_args.kwargs["accessible_dataset_ids"] == ["dataset-whitelist-only"] assert get_datasets.call_args.kwargs["include_own_datasets"] is False - def test_get_restricted_whitelist_ignores_dataset_read_overrides(self, app: Flask): + def test_get_restricted_whitelist_ignores_dataset_read_overrides( + self, app: Flask, config_overrides: Callable[..., None] + ): + config_overrides(RBAC_ENABLED=True) api = DatasetListApi() method = unwrap(api.get) current_user = self._mock_user() @@ -419,7 +452,6 @@ class TestDatasetList: ) with app.test_request_context("/datasets"): with ( - patch("controllers.console.datasets.datasets.dify_config.RBAC_ENABLED", True), patch.object(DatasetService, "get_datasets", return_value=([], 0)) as get_datasets, patch( "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.MyPermissions.get", @@ -431,20 +463,20 @@ class TestDatasetList: ), patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])), ): - method(api, MagicMock(), "tenant-1", current_user) + method(api, self.session, "tenant-1", current_user) assert get_datasets.call_args.kwargs["accessible_dataset_ids"] == [ "dataset-whitelist-only", ] assert get_datasets.call_args.kwargs["include_own_datasets"] is False - def test_get_with_ids_applies_dataset_visibility(self, app: Flask): + def test_get_with_ids_applies_dataset_visibility(self, app: Flask, config_overrides: Callable[..., None]): + config_overrides(RBAC_ENABLED=True) api = DatasetListApi() method = unwrap(api.get) current_user = self._mock_user() permissions = enterprise_rbac_service.MyPermissionsResponse() with app.test_request_context("/datasets?ids=dataset-1"): with ( - patch("controllers.console.datasets.datasets.dify_config.RBAC_ENABLED", True), patch.object(DatasetService, "get_datasets_by_ids", return_value=([], 0)) as get_datasets_by_ids, patch( "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.MyPermissions.get", @@ -456,9 +488,9 @@ class TestDatasetList: ), patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])), ): - method(api, MagicMock(), "tenant-1", current_user) + method(api, self.session, "tenant-1", current_user) session = get_datasets_by_ids.call_args.kwargs["session"] - assert isinstance(session, MagicMock) + assert session is self.session assert get_datasets_by_ids.call_args.args == (["dataset-1"], "tenant-1") assert get_datasets_by_ids.call_args.kwargs == { "user": current_user, @@ -477,7 +509,7 @@ class TestDatasetList: patch.object(DatasetService, "get_datasets", return_value=(datasets, 1)), patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])), ): - resp, status = method(api, MagicMock(), "tenant-1", current_user) + resp, status = method(api, self.session, "tenant-1", current_user) assert status == 200 def test_get_allows_legacy_weighted_score_without_weight_type(self, app: Flask): @@ -510,7 +542,7 @@ class TestDatasetList: patch.object(DatasetService, "get_datasets", return_value=(datasets, 1)), patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])), ): - resp, status = method(api, MagicMock(), "tenant-1", current_user) + resp, status = method(api, self.session, "tenant-1", current_user) assert status == 200 assert resp["data"][0]["retrieval_model_dict"]["weights"]["weight_type"] is None @@ -524,7 +556,7 @@ class TestDatasetList: patch.object(DatasetService, "get_datasets", return_value=(datasets, 1)), patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])), ): - resp, status = method(api, MagicMock(), "tenant-1", current_user) + resp, status = method(api, self.session, "tenant-1", current_user) assert status == 200 retrieval_model = resp["data"][0]["retrieval_model_dict"] assert retrieval_model["search_method"] == "semantic_search" @@ -548,7 +580,7 @@ class TestDatasetList: patch.object(DatasetService, "get_datasets", return_value=(datasets, 1)), patch.object(ProviderManager, "get_configurations", return_value=config), ): - resp, status = method(api, MagicMock(), "tenant-1", current_user) + resp, status = method(api, self.session, "tenant-1", current_user) assert resp["data"][0]["embedding_available"] is False def test_partial_members_permission(self, app: Flask): @@ -556,8 +588,9 @@ class TestDatasetList: method = unwrap(api.get) current_user = self._mock_user() datasets = [make_dataset(permission="partial_members")] - session = MagicMock() - session.execute.return_value.all.return_value = [("ds-1", "u1")] + session = self.session + session.add(DatasetPermission(dataset_id="ds-1", account_id="u1", tenant_id="tenant-1")) + session.flush() with app.test_request_context("/datasets"): with ( patch.object(DatasetService, "get_datasets", return_value=(datasets, 1)), @@ -567,7 +600,7 @@ class TestDatasetList: assert resp["data"][0]["partial_member_list"] == ["u1"] -class TestDatasetListApiPost: +class TestDatasetListApiPost(_UsesSQLiteSession): def test_post_success(self, app: Flask): api = DatasetListApi() method = unwrap(api.post) @@ -579,7 +612,7 @@ class TestDatasetListApiPost: patch.object(type(console_ns), "payload", payload), patch.object(DatasetService, "create_empty_dataset", return_value=dataset), ): - _, status = method(api, DatasetCreatePayload(**payload), MagicMock(), "tenant-1", user) + _, status = method(api, DatasetCreatePayload(**payload), self.session, "tenant-1", user) assert status == 201 def test_post_forbidden(self, app: Flask): @@ -589,7 +622,7 @@ class TestDatasetListApiPost: user = make_account(TenantAccountRole.NORMAL) with app.test_request_context("/datasets", json=payload), patch.object(type(console_ns), "payload", payload): with pytest.raises(Forbidden): - method(api, DatasetCreatePayload(**payload), MagicMock(), "tenant-1", user) + method(api, DatasetCreatePayload(**payload), self.session, "tenant-1", user) def test_post_duplicate_name(self, app: Flask): api = DatasetListApi() @@ -604,14 +637,14 @@ class TestDatasetListApiPost: ), ): with pytest.raises(DatasetNameDuplicateError): - method(api, DatasetCreatePayload(**payload), MagicMock(), "tenant-1", user) + method(api, DatasetCreatePayload(**payload), self.session, "tenant-1", user) def test_post_invalid_payload_missing_name(self, app: Flask): api = DatasetListApi() method = unwrap(api.post) with app.test_request_context("/datasets", json={}), patch.object(type(console_ns), "payload", {}): with pytest.raises(ValueError): - method(api, DatasetCreatePayload(), MagicMock(), "tenant-1", make_account()) + method(api, DatasetCreatePayload(), self.session, "tenant-1", make_account()) def test_post_invalid_indexing_technique(self, app: Flask): api = DatasetListApi() @@ -619,7 +652,7 @@ class TestDatasetListApiPost: payload = {"name": "bad", "indexing_technique": "invalid-tech"} with app.test_request_context("/datasets", json=payload), patch.object(type(console_ns), "payload", payload): with pytest.raises(ValueError, match="Invalid indexing technique"): - method(api, DatasetCreatePayload(**payload), MagicMock(), "tenant-1", make_account()) + method(api, DatasetCreatePayload(**payload), self.session, "tenant-1", make_account()) def test_post_invalid_provider(self, app: Flask): api = DatasetListApi() @@ -627,10 +660,10 @@ class TestDatasetListApiPost: payload = {"name": "bad", "provider": "unknown"} with app.test_request_context("/datasets", json=payload), patch.object(type(console_ns), "payload", payload): with pytest.raises(ValueError, match="Invalid provider"): - method(api, DatasetCreatePayload(**payload), MagicMock(), "tenant-1", make_account()) + method(api, DatasetCreatePayload(**payload), self.session, "tenant-1", make_account()) -class TestDatasetApiGet: +class TestDatasetApiGet(_UsesSQLiteSession): def test_get_success_basic(self, app: Flask): api = DatasetApi() method = unwrap(api.get) @@ -645,20 +678,20 @@ class TestDatasetApiGet: patch("controllers.console.datasets.datasets.create_plugin_provider_manager") as provider_manager_mock, ): provider_manager_mock.return_value.get_configurations.return_value.get_models.return_value = [] - data, status = method(api, MagicMock(), tenant_id, user, dataset_id) + data, status = method(api, self.session, tenant_id, user, dataset_id) assert status == 200 assert data["embedding_available"] is True - def test_get_attaches_permission_keys_when_rbac_enabled(self, app: Flask): + def test_get_attaches_permission_keys_when_rbac_enabled(self, app: Flask, config_overrides: Callable[..., None]): + config_overrides(RBAC_ENABLED=True) api = DatasetApi() method = unwrap(api.get) dataset_id = "123e4567-e89b-12d3-a456-426614174000" - user = MagicMock(id="account-1") + user = make_account() tenant_id = "tenant-1" dataset = make_dataset(id=dataset_id) with ( app.test_request_context(f"/datasets/{dataset_id}"), - patch("controllers.console.datasets.datasets.dify_config.RBAC_ENABLED", True), patch.object(DatasetService, "get_dataset", return_value=dataset), patch.object(DatasetService, "check_dataset_permission", return_value=None), patch( @@ -676,7 +709,7 @@ class TestDatasetApiGet: patch("controllers.console.datasets.datasets.create_plugin_provider_manager") as provider_manager_mock, ): provider_manager_mock.return_value.get_configurations.return_value.get_models.return_value = [] - data, status = method(api, MagicMock(), tenant_id, user, dataset_id) + data, status = method(api, self.session, tenant_id, user, dataset_id) get_permissions.assert_called_once_with(tenant_id, user.id, dataset_id=dataset_id, session=ANY) assert status == 200 assert data["permission_keys"] == ["dataset.acl.readonly", "dataset.acl.edit"] @@ -693,7 +726,7 @@ class TestDatasetApiGet: patch("controllers.console.datasets.datasets.create_plugin_provider_manager") as provider_manager_mock, ): provider_manager_mock.return_value.get_configurations.return_value.get_models.return_value = [] - data, status = method(api, MagicMock(), "tenant", make_account(), dataset_id) + data, status = method(api, self.session, "tenant", make_account(), dataset_id) assert status == 200 assert data["external_retrieval_model"] == {"top_k": 2, "score_threshold": 0.0, "score_threshold_enabled": None} @@ -706,7 +739,7 @@ class TestDatasetApiGet: patch.object(DatasetService, "get_dataset", return_value=None), ): with pytest.raises(NotFound, match="Dataset not found"): - method(api, MagicMock(), "tenant", make_account(), dataset_id) + method(api, self.session, "tenant", make_account(), dataset_id) def test_get_permission_denied(self, app: Flask): api = DatasetApi() @@ -723,7 +756,7 @@ class TestDatasetApiGet: ), ): with pytest.raises(Forbidden, match="no access"): - method(api, MagicMock(), "tenant", make_account(), dataset_id) + method(api, self.session, "tenant", make_account(), dataset_id) def test_get_high_quality_embedding_unavailable(self, app: Flask): api = DatasetApi() @@ -744,7 +777,7 @@ class TestDatasetApiGet: patch("controllers.console.datasets.datasets.create_plugin_provider_manager") as provider_manager_mock, ): provider_manager_mock.return_value.get_configurations.return_value.get_models.return_value = [] - data, _ = method(api, MagicMock(), tenant_id, user, dataset_id) + data, _ = method(api, self.session, tenant_id, user, dataset_id) assert data["embedding_available"] is False def test_get_partial_members_permission(self, app: Flask): @@ -761,11 +794,11 @@ class TestDatasetApiGet: patch("controllers.console.datasets.datasets.create_plugin_provider_manager") as provider_manager_mock, ): provider_manager_mock.return_value.get_configurations.return_value.get_models.return_value = [] - data, _ = method(api, MagicMock(), "tenant", make_account(), dataset_id) + data, _ = method(api, self.session, "tenant", make_account(), dataset_id) assert data["partial_member_list"] == partial_members -class TestDatasetApiPatch: +class TestDatasetApiPatch(_UsesSQLiteSession): def test_patch_success_basic(self, app: Flask): api = DatasetApi() method = unwrap(api.patch) @@ -782,7 +815,7 @@ class TestDatasetApiPatch: patch.object(DatasetService, "update_dataset", return_value=dataset), patch.object(DatasetPermissionService, "get_dataset_partial_member_list", return_value=[]), ): - result, status = method(api, DatasetUpdatePayload(), MagicMock(), tenant_id, user, dataset_id) + result, status = method(api, DatasetUpdatePayload(), self.session, tenant_id, user, dataset_id) assert status == 200 assert result["partial_member_list"] == [] @@ -794,7 +827,7 @@ class TestDatasetApiPatch: patch.object(DatasetService, "get_dataset", return_value=None), ): with pytest.raises(NotFound, match="Dataset not found"): - method(api, DatasetUpdatePayload(), MagicMock(), "tenant-1", make_account(), "missing") + method(api, DatasetUpdatePayload(), self.session, "tenant-1", make_account(), "missing") def test_patch_permission_denied(self, app: Flask): api = DatasetApi() @@ -809,7 +842,7 @@ class TestDatasetApiPatch: patch.object(DatasetPermissionService, "check_permission", side_effect=Forbidden("no permission")), ): with pytest.raises(Forbidden): - method(api, DatasetUpdatePayload(), MagicMock(), "tenant", make_account(), dataset_id) + method(api, DatasetUpdatePayload(), self.session, "tenant", make_account(), dataset_id) def test_patch_partial_members_update(self, app: Flask): api = DatasetApi() @@ -826,7 +859,7 @@ class TestDatasetApiPatch: patch.object(DatasetPermissionService, "update_partial_member_list", return_value=None), patch.object(DatasetPermissionService, "get_dataset_partial_member_list", return_value=["u1", "u2"]), ): - result, _ = method(api, DatasetUpdatePayload(), MagicMock(), "tenant", make_account(), dataset_id) + result, _ = method(api, DatasetUpdatePayload(), self.session, "tenant", make_account(), dataset_id) assert result["partial_member_list"] == ["u1", "u2"] def test_patch_clear_partial_members(self, app: Flask): @@ -844,11 +877,11 @@ class TestDatasetApiPatch: patch.object(DatasetPermissionService, "clear_partial_member_list", return_value=None), patch.object(DatasetPermissionService, "get_dataset_partial_member_list", return_value=[]), ): - result, _ = method(api, DatasetUpdatePayload(), MagicMock(), "tenant", make_account(), dataset_id) + result, _ = method(api, DatasetUpdatePayload(), self.session, "tenant", make_account(), dataset_id) assert result["partial_member_list"] == [] -class TestDatasetApiDelete: +class TestDatasetApiDelete(_UsesSQLiteSession): def test_delete_success(self, app: Flask): api = DatasetApi() method = unwrap(api.delete) @@ -859,7 +892,7 @@ class TestDatasetApiDelete: patch.object(DatasetService, "delete_dataset", return_value=True), patch.object(DatasetPermissionService, "clear_partial_member_list", return_value=None), ): - result, status = method(api, MagicMock(), user, dataset_id) + result, status = method(api, self.session, user, dataset_id) assert status == 204 assert result == "" @@ -870,7 +903,7 @@ class TestDatasetApiDelete: user = make_account(TenantAccountRole.NORMAL) with app.test_request_context(f"/datasets/{dataset_id}"): with pytest.raises(Forbidden): - method(api, MagicMock(), user, dataset_id) + method(api, self.session, user, dataset_id) def test_delete_dataset_not_found(self, app: Flask): api = DatasetApi() @@ -882,7 +915,7 @@ class TestDatasetApiDelete: patch.object(DatasetService, "delete_dataset", return_value=False), ): with pytest.raises(NotFound, match="Dataset not found"): - method(api, MagicMock(), user, dataset_id) + method(api, self.session, user, dataset_id) def test_delete_dataset_in_use(self, app: Flask): api = DatasetApi() @@ -894,7 +927,7 @@ class TestDatasetApiDelete: patch.object(DatasetService, "delete_dataset", side_effect=services.errors.dataset.DatasetInUseError()), ): with pytest.raises(DatasetInUseError): - method(api, MagicMock(), user, dataset_id) + method(api, self.session, user, dataset_id) class TestDatasetKnowledgeFSUpgradeApi: @@ -1129,7 +1162,7 @@ class TestDatasetKnowledgeFSUpgradeApi: enqueue.assert_not_called() -class TestDatasetUseCheckApi: +class TestDatasetUseCheckApi(_UsesSQLiteSession): @pytest.mark.parametrize("is_using", [True, False]) def test_get_use_check(self, app: Flask, is_using: bool): api = DatasetUseCheckApi() @@ -1137,7 +1170,7 @@ class TestDatasetUseCheckApi: dataset_id = "dataset-id" dataset = make_dataset(id=dataset_id) current_user = make_account() - session = MagicMock() + session = self.session with ( app.test_request_context(f"/datasets/{dataset_id}/use-check"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset) as get_dataset, @@ -1151,14 +1184,14 @@ class TestDatasetUseCheckApi: check_permission.assert_called_once_with(dataset, current_user, session) dataset_use_check.assert_called_once_with(DatasetRef("tenant-1", dataset_id), session) - def test_get_use_check_relies_on_rbac_in_rbac_mode(self, app: Flask): + def test_get_use_check_relies_on_rbac_in_rbac_mode(self, app: Flask, config_overrides: Callable[..., None]): + config_overrides(RBAC_ENABLED=True) api = DatasetUseCheckApi() method = unwrap(api.get) dataset = make_dataset(id="dataset-id") - session = MagicMock() + session = self.session with ( app.test_request_context("/datasets/dataset-id/use-check"), - patch("controllers.console.datasets.datasets.dify_config.RBAC_ENABLED", True), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset), patch.object(DatasetService, "check_dataset_permission") as check_permission, patch.object(DatasetService, "dataset_use_check", return_value=False), @@ -1173,11 +1206,11 @@ class TestDatasetUseCheckApi: "api_cls", [DatasetUseCheckApi, DatasetIndexingStatusApi, DatasetErrorDocs, DatasetAutoDisableLogApi], ) -def test_dataset_scoped_read_permission_denied(app: Flask, api_cls): +def test_dataset_scoped_read_permission_denied(app: Flask, api_cls, sqlite_session: Session): api = api_cls() method = unwrap(api.get) dataset = make_dataset(id="dataset-1") - session = MagicMock() + session = sqlite_session with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset), @@ -1191,7 +1224,7 @@ def test_dataset_scoped_read_permission_denied(app: Flask, api_cls): method(api, session, "tenant-1", make_account(), "dataset-1") -class TestDatasetQueryApi: +class TestDatasetQueryApi(_UsesSQLiteSession): def _query_record(self, index: int = 1) -> DatasetQuery: query = DatasetQuery( dataset_id="dataset-id", @@ -1218,7 +1251,7 @@ class TestDatasetQueryApi: patch.object(DatasetService, "check_dataset_permission", return_value=None), patch.object(DatasetService, "get_dataset_queries", return_value=(queries, 2)), ): - response, status = method(api, MagicMock(), current_user, dataset_id) + response, status = method(api, self.session, current_user, dataset_id) assert status == 200 assert response["total"] == 2 assert response["page"] == 1 @@ -1241,24 +1274,30 @@ class TestDatasetQueryApi: dataset = make_dataset(id="dataset-id") query = self._query_record() query.content = json.dumps([{"content_type": "image_query", "content": "file-1"}]) - upload_file = SimpleNamespace( - id="file-1", + upload_file = UploadFile( + tenant_id="tenant-1", + storage_type=StorageType.LOCAL, + key="image.png", name="image.png", size=10, extension="png", mime_type="image/png", + created_by_role=CreatorUserRole.ACCOUNT, + created_by="account-1", + created_at=datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC), + used=False, ) - session = MagicMock() - session.scalar.return_value = upload_file + upload_file.id = "file-1" + session = self.session + session.add(upload_file) + session.flush() with ( app.test_request_context("/datasets/queries"), patch.object(DatasetService, "get_dataset", return_value=dataset), patch.object(DatasetService, "check_dataset_permission", return_value=None), patch.object(DatasetService, "get_dataset_queries", return_value=([query], 1)), - patch("models.dataset.db") as db_mock, patch("models.dataset.sign_upload_file_preview_url", return_value="signed-url"), ): - db_mock.session.scalar.return_value = upload_file response, status = method(api, session, make_account(), "dataset-id") assert status == 200 @@ -1276,8 +1315,7 @@ class TestDatasetQueryApi: }, } ] - session.scalar.assert_called_once() - db_mock.session.scalar.assert_not_called() + assert session.get(UploadFile, "file-1") is upload_file def test_get_queries_dataset_not_found(self, app: Flask): api = DatasetQueryApi() @@ -1289,7 +1327,7 @@ class TestDatasetQueryApi: patch.object(DatasetService, "get_dataset", return_value=None), ): with pytest.raises(NotFound, match="Dataset not found"): - method(api, MagicMock(), current_user, dataset_id) + method(api, self.session, current_user, dataset_id) def test_get_queries_permission_denied(self, app: Flask): api = DatasetQueryApi() @@ -1307,7 +1345,7 @@ class TestDatasetQueryApi: ), ): with pytest.raises(Forbidden): - method(api, MagicMock(), current_user, dataset_id) + method(api, self.session, current_user, dataset_id) def test_get_queries_pagination_has_more(self, app: Flask): api = DatasetQueryApi() @@ -1322,13 +1360,13 @@ class TestDatasetQueryApi: patch.object(DatasetService, "check_dataset_permission", return_value=None), patch.object(DatasetService, "get_dataset_queries", return_value=(queries, 40)), ): - response, status = method(api, MagicMock(), current_user, dataset_id) + response, status = method(api, self.session, current_user, dataset_id) assert status == 200 assert response["has_more"] is True assert len(response["data"]) == 20 -class TestDatasetIndexingEstimateApi: +class TestDatasetIndexingEstimateApi(_UsesSQLiteSession): def _upload_file(self, *, tenant_id: str = "tenant-1", file_id: str = "file-1") -> UploadFile: upload_file = UploadFile( tenant_id=tenant_id, @@ -1361,8 +1399,9 @@ class TestDatasetIndexingEstimateApi: method = unwrap(api.post) payload = self._base_payload() mock_file = self._upload_file() - session = MagicMock() - session.scalars.return_value.all.return_value = [mock_file] + session = self.session + session.add(mock_file) + session.flush() mock_response = IndexingEstimate(total_segments=100, preview=[]) @@ -1391,8 +1430,7 @@ class TestDatasetIndexingEstimateApi: api = DatasetIndexingEstimateApi() method = unwrap(api.post) payload = self._base_payload() - session = MagicMock() - session.scalars.return_value.all.return_value = None + session = self.session with ( app.test_request_context("/"), patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload), @@ -1411,8 +1449,9 @@ class TestDatasetIndexingEstimateApi: method = unwrap(api.post) mock_file = self._upload_file() payload = self._base_payload() - session = MagicMock() - session.scalars.return_value.all.return_value = [mock_file] + session = self.session + session.add(mock_file) + session.flush() with ( app.test_request_context("/"), patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload), @@ -1435,8 +1474,9 @@ class TestDatasetIndexingEstimateApi: method = unwrap(api.post) mock_file = self._upload_file() payload = self._base_payload() - session = MagicMock() - session.scalars.return_value.all.return_value = [mock_file] + session = self.session + session.add(mock_file) + session.flush() with ( app.test_request_context("/"), patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload), @@ -1459,8 +1499,9 @@ class TestDatasetIndexingEstimateApi: method = unwrap(api.post) mock_file = self._upload_file() payload = self._base_payload() - session = MagicMock() - session.scalars.return_value.all.return_value = [mock_file] + session = self.session + session.add(mock_file) + session.flush() with ( app.test_request_context("/"), patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload), @@ -1478,16 +1519,16 @@ class TestDatasetIndexingEstimateApi: ) -class TestDatasetRelatedAppListApi: +class TestDatasetRelatedAppListApi(_UsesSQLiteSession): def test_get_success(self, app: Flask): api = DatasetRelatedAppListApi() method = unwrap(api.get) dataset = make_dataset(id="dataset-1") app1 = make_related_app(id="app-1", name="App 1") app2 = make_related_app(id="app-2", name="App 2") - join1 = MagicMock(app_id="app-1") - join2 = MagicMock(app_id="app-2") - session = MagicMock() + join1 = AppDatasetJoin(app_id="app-1", dataset_id="dataset-1") + join2 = AppDatasetJoin(app_id="app-2", dataset_id="dataset-1") + session = self.session with ( app.test_request_context("/"), patch("controllers.console.datasets.datasets.DatasetService.get_dataset", return_value=dataset), @@ -1540,7 +1581,7 @@ class TestDatasetRelatedAppListApi: patch("controllers.console.datasets.datasets.DatasetService.get_dataset", return_value=None), ): with pytest.raises(NotFound): - method(api, MagicMock(), make_account(), "dataset-1") + method(api, self.session, make_account(), "dataset-1") def test_get_permission_denied(self, app: Flask): api = DatasetRelatedAppListApi() @@ -1555,16 +1596,16 @@ class TestDatasetRelatedAppListApi: ), ): with pytest.raises(Forbidden): - method(api, MagicMock(), make_account(), "dataset-1") + method(api, self.session, make_account(), "dataset-1") def test_get_filters_none_apps(self, app: Flask): api = DatasetRelatedAppListApi() method = unwrap(api.get) dataset = make_dataset(id="dataset-1") app1 = make_related_app() - join1 = MagicMock(app_id="app-1") - join2 = MagicMock(app_id="app-2") - session = MagicMock() + join1 = AppDatasetJoin(app_id="app-1", dataset_id="dataset-1") + join2 = AppDatasetJoin(app_id="app-2", dataset_id="dataset-1") + session = self.session with ( app.test_request_context("/"), patch("controllers.console.datasets.datasets.DatasetService.get_dataset", return_value=dataset), @@ -1592,26 +1633,17 @@ class TestDatasetRelatedAppListApi: ] -class TestDatasetIndexingStatusApi: +class TestDatasetIndexingStatusApi(_UsesSQLiteSession): def test_get_success_with_documents(self, app: Flask): api = DatasetIndexingStatusApi() method = unwrap(api.get) dataset = make_dataset(id="dataset-1") current_user = make_account() - document = MagicMock() - document.id = "doc-1" - document.indexing_status = "completed" - document.processing_started_at = None - document.parsing_completed_at = None - document.cleaning_completed_at = None - document.splitting_completed_at = None - document.completed_at = None - document.paused_at = None - document.error = None - document.stopped_at = None - session = MagicMock() - session.scalars.return_value.all.return_value = [document] - session.scalar.return_value = 3 + document = make_document_status() + session = self.session + session.add(document) + session.add_all([make_document_segment(position=position, completed=True) for position in range(1, 4)]) + session.flush() with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset) as get_dataset, @@ -1626,16 +1658,13 @@ class TestDatasetIndexingStatusApi: assert item["total_segments"] == 3 get_dataset.assert_called_once_with("dataset-1", "tenant-1", session=session) check_permission.assert_called_once_with(dataset, current_user, session) - assert {"dataset-1", "tenant-1"} <= set(session.scalars.call_args.args[0].compile().params.values()) - for segment_count_call in session.scalar.call_args_list: - assert {"dataset-1", "tenant-1", "doc-1"} <= set(segment_count_call.args[0].compile().params.values()) + assert session.get(Document, "doc-1") is document def test_get_success_no_documents(self, app: Flask): api = DatasetIndexingStatusApi() method = unwrap(api.get) dataset = make_dataset(id="dataset-1") - session = MagicMock() - session.scalars.return_value.all.return_value = [] + session = self.session with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset), @@ -1649,20 +1678,11 @@ class TestDatasetIndexingStatusApi: api = DatasetIndexingStatusApi() method = unwrap(api.get) dataset = make_dataset(id="dataset-1") - document = MagicMock() - document.id = "doc-1" - document.indexing_status = "indexing" - document.processing_started_at = None - document.parsing_completed_at = None - document.cleaning_completed_at = None - document.splitting_completed_at = None - document.completed_at = None - document.paused_at = None - document.error = None - document.stopped_at = None - session = MagicMock() - session.scalars.return_value.all.return_value = [document] - session.scalar.side_effect = [2, 5] + document = make_document_status(indexing_status=IndexingStatus.INDEXING) + session = self.session + session.add(document) + session.add_all([make_document_segment(position=position, completed=position <= 2) for position in range(1, 6)]) + session.flush() with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset), @@ -1675,7 +1695,7 @@ class TestDatasetIndexingStatusApi: assert item["total_segments"] == 5 -class TestDatasetApiKeyApi: +class TestDatasetApiKeyApi(_UsesSQLiteSession): def test_get_api_keys_success(self, app: Flask): api = DatasetApiKeyApi() method = unwrap(api.get) @@ -1693,8 +1713,11 @@ class TestDatasetApiKeyApi: last_used_at=None, created_at=None, ) - session = MagicMock() - session.scalars.return_value.all.return_value = [mock_key_1, mock_key_2] + session = self.session + mock_key_1.tenant_id = "tenant-1" + mock_key_2.tenant_id = "tenant-1" + session.add_all([mock_key_1, mock_key_2]) + session.flush() pending_last_used_at = datetime.datetime(2026, 8, 11, 12, 30, 0, tzinfo=datetime.UTC) with ( app.test_request_context("/"), @@ -1717,30 +1740,31 @@ class TestDatasetApiKeyApi: def test_post_create_api_key_success(self, app: Flask): api = DatasetApiKeyApi() method = unwrap(api.post) - mock_token = MagicMock() - mock_token.id = "new-key-id" - mock_token.last_used_at = None - mock_token.created_at = datetime.datetime(2024, 1, 1, 0, 0, 0, tzinfo=datetime.UTC) - mock_api_token_cls = MagicMock() - mock_api_token_cls.return_value = mock_token - mock_api_token_cls.generate_api_key.return_value = "dataset-abc123" - session = MagicMock() - session.scalar.return_value = 3 - with app.test_request_context("/"), patch("controllers.console.datasets.datasets.ApiToken", mock_api_token_cls): + session = self.session + with ( + app.test_request_context("/"), + patch.object(ApiToken, "generate_api_key", return_value="dataset-abc123") as generate_api_key, + ): response, status = method(api, session, "tenant-1") assert status == 200 assert isinstance(response, dict) - assert response["id"] == "new-key-id" assert response["token"] == "dataset-abc123" assert response["type"] == "dataset" assert response["created_at"] is not None - mock_api_token_cls.generate_api_key.assert_called_once_with("dataset-", 24, session=session) + generate_api_key.assert_called_once_with("dataset-", 24, session=session) + assert session.get(ApiToken, response["id"]).token == "dataset-abc123" def test_post_exceed_max_keys(self, app: Flask): api = DatasetApiKeyApi() method = unwrap(api.post) - session = MagicMock() - session.scalar.return_value = 10 + session = self.session + session.add_all( + [ + ApiToken(id=f"key-{index}", tenant_id="tenant-1", type="dataset", token=f"ds-{index}") + for index in range(10) + ] + ) + session.flush() with app.test_request_context("/"): with pytest.raises(BadRequest) as exc_info: method(api, session, "tenant-1") @@ -1751,36 +1775,42 @@ class TestDatasetApiKeyApi: } -class TestDatasetApiDeleteApi: +class TestDatasetApiDeleteApi(_UsesSQLiteSession): def test_delete_success(self, app: Flask): api = DatasetApiDeleteApi() method = unwrap(api.delete) - mock_key = MagicMock() - session = MagicMock() - session.scalar.return_value = mock_key - with app.test_request_context("/"): + session = self.session + key = ApiToken(id="api-key-id", tenant_id="tenant-1", type="dataset", token="dataset-secret") + session.add(key) + session.flush() + with ( + app.test_request_context("/"), + patch("controllers.console.datasets.datasets.ApiTokenCache.delete") as delete_cache, + ): response, status = method(api, session, "tenant-1", "api-key-id") assert status == 204 assert response == "" + delete_cache.assert_called_once() + session.flush() + assert session.get(ApiToken, "api-key-id") is None def test_delete_key_not_found(self, app: Flask): api = DatasetApiDeleteApi() method = unwrap(api.delete) - session = MagicMock() - session.scalar.return_value = None + session = self.session with app.test_request_context("/"): with pytest.raises(NotFound): method(api, session, "tenant-1", "api-key-id") -class TestDatasetEnableApiApi: +class TestDatasetEnableApiApi(_UsesSQLiteSession): @pytest.mark.parametrize(("status_value", "enabled"), [("enable", True), ("disable", False)]) def test_update_api_status(self, app: Flask, status_value: str, enabled: bool): api = DatasetEnableApiApi() method = unwrap(api.post) dataset = make_dataset(id="dataset-1") current_user = make_account() - session = MagicMock() + session = self.session with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset) as get_dataset, @@ -1798,7 +1828,7 @@ class TestDatasetEnableApiApi: api = DatasetEnableApiApi() method = unwrap(api.post) dataset = make_dataset(id="dataset-1") - session = MagicMock() + session = self.session with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset), @@ -1818,44 +1848,44 @@ class TestDatasetEnableApiApi: class TestDatasetApiBaseUrlApi: - def test_get_api_base_url_from_config(self, app: Flask): + def test_get_api_base_url_from_config(self, app: Flask, config_overrides: Callable[..., None]): + config_overrides(SERVICE_API_URL="https://example.com") api = DatasetApiBaseUrlApi() method = unwrap(api.get) with ( app.test_request_context("/"), - patch("controllers.console.datasets.datasets.dify_config.SERVICE_API_URL", "https://example.com"), ): response = method(api) assert response["api_base_url"] == "https://example.com/v1" - def test_get_api_base_url_from_request(self, app: Flask): + def test_get_api_base_url_from_request(self, app: Flask, config_overrides: Callable[..., None]): + config_overrides(SERVICE_API_URL=None) api = DatasetApiBaseUrlApi() method = unwrap(api.get) with ( app.test_request_context("http://localhost:5000/"), - patch("controllers.console.datasets.datasets.dify_config.SERVICE_API_URL", None), ): response = method(api) assert response["api_base_url"] == "http://localhost:5000/v1" - def test_get_api_base_url_no_double_v1(self, app: Flask): + def test_get_api_base_url_no_double_v1(self, app: Flask, config_overrides: Callable[..., None]): + config_overrides(SERVICE_API_URL="https://example.com/v1") api = DatasetApiBaseUrlApi() method = unwrap(api.get) with ( app.test_request_context("/"), - patch("controllers.console.datasets.datasets.dify_config.SERVICE_API_URL", "https://example.com/v1"), ): response = method(api) assert response["api_base_url"] == "https://example.com/v1" class TestDatasetRetrievalSettingApi: - def test_get_success(self, app: Flask): + def test_get_success(self, app: Flask, config_overrides: Callable[..., None]): + config_overrides(VECTOR_STORE="qdrant") api = DatasetRetrievalSettingApi() method = unwrap(api.get) with ( app.test_request_context("/"), - patch("controllers.console.datasets.datasets.dify_config.VECTOR_STORE", "qdrant"), patch( "controllers.console.datasets.datasets._get_retrieval_methods_by_vector_type", return_value={"retrieval_method": ["semantic", "hybrid"]}, @@ -1864,21 +1894,15 @@ class TestDatasetRetrievalSettingApi: response = method(api) assert "retrieval_method" in response - def test_tidb_vector_returns_semantic_only_when_fulltext_disabled(self): - with patch( - "controllers.console.datasets.datasets.dify_config.TIDB_VECTOR_ENABLE_FULLTEXT_SEARCH", - False, - ): - response = _get_retrieval_methods_by_vector_type(VectorType.TIDB_VECTOR) + def test_tidb_vector_returns_semantic_only_when_fulltext_disabled(self, config_overrides: Callable[..., None]): + config_overrides(TIDB_VECTOR_ENABLE_FULLTEXT_SEARCH=False) + response = _get_retrieval_methods_by_vector_type(VectorType.TIDB_VECTOR) assert response["retrieval_method"] == [RetrievalMethod.SEMANTIC_SEARCH.value] - def test_tidb_vector_returns_full_methods_when_fulltext_enabled(self): - with patch( - "controllers.console.datasets.datasets.dify_config.TIDB_VECTOR_ENABLE_FULLTEXT_SEARCH", - True, - ): - response = _get_retrieval_methods_by_vector_type(VectorType.TIDB_VECTOR) + def test_tidb_vector_returns_full_methods_when_fulltext_enabled(self, config_overrides: Callable[..., None]): + config_overrides(TIDB_VECTOR_ENABLE_FULLTEXT_SEARCH=True) + response = _get_retrieval_methods_by_vector_type(VectorType.TIDB_VECTOR) assert response["retrieval_method"] == [ RetrievalMethod.SEMANTIC_SEARCH.value, @@ -1887,7 +1911,7 @@ class TestDatasetRetrievalSettingApi: ] -class TestDatasetRetrievalSettingMockApi: +class TestDatasetRetrievalSettingMockApi(_UsesSQLiteSession): def test_get_success(self, app: Flask): api = DatasetRetrievalSettingMockApi() method = unwrap(api.get) @@ -1902,14 +1926,14 @@ class TestDatasetRetrievalSettingMockApi: assert response["retrieval_method"] == ["semantic"] -class TestDatasetErrorDocs: +class TestDatasetErrorDocs(_UsesSQLiteSession): def test_get_success(self, app: Flask): api = DatasetErrorDocs() method = unwrap(api.get) dataset = make_dataset(id="dataset-1") error_doc = make_document_status(id="error-doc", indexing_status=IndexingStatus.ERROR, error="failed") current_user = make_account() - session = MagicMock() + session = self.session with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset) as get_dataset, @@ -1929,7 +1953,7 @@ class TestDatasetErrorDocs: def test_get_dataset_not_found(self, app: Flask): api = DatasetErrorDocs() method = unwrap(api.get) - session = MagicMock() + session = self.session with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=None) as get_dataset, @@ -1939,7 +1963,7 @@ class TestDatasetErrorDocs: get_dataset.assert_called_once_with("dataset-1", "tenant-1", session=session) -class TestDatasetPermissionUserListApi: +class TestDatasetPermissionUserListApi(_UsesSQLiteSession): def test_get_success(self, app: Flask): api = DatasetPermissionUserListApi() method = unwrap(api.get) @@ -1954,7 +1978,7 @@ class TestDatasetPermissionUserListApi: return_value=users, ), ): - response, status = method(api, MagicMock(), make_account(), "dataset-1") + response, status = method(api, self.session, make_account(), "dataset-1") assert status == 200 assert response["data"] == users @@ -1971,17 +1995,17 @@ class TestDatasetPermissionUserListApi: ), ): with pytest.raises(Forbidden): - method(api, MagicMock(), make_account(), "dataset-1") + method(api, self.session, make_account(), "dataset-1") -class TestDatasetAutoDisableLogApi: +class TestDatasetAutoDisableLogApi(_UsesSQLiteSession): def test_get_success(self, app: Flask): api = DatasetAutoDisableLogApi() method = unwrap(api.get) dataset = make_dataset(id="dataset-1") logs = {"document_ids": ["doc-1"], "count": 1} current_user = make_account() - session = MagicMock() + session = self.session with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset) as get_dataset, @@ -1998,7 +2022,7 @@ class TestDatasetAutoDisableLogApi: def test_get_dataset_not_found(self, app: Flask): api = DatasetAutoDisableLogApi() method = unwrap(api.get) - session = MagicMock() + session = self.session with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=None) as get_dataset, diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py index 459af12ba76..060b6029bc7 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py @@ -55,6 +55,7 @@ from services.vector_space_admission_service import ( VECTOR_SPACE_ADMISSION_ERROR_CODE, format_vector_space_admission_error, ) +from tests.unit_tests.config_override import config_overrides_context def make_serializable_document(**overrides): @@ -504,7 +505,7 @@ class TestDatasetInitApi: with ( app.test_request_context("/", json=payload), patch.object(type(console_ns), "payload", payload), - patch("controllers.console.datasets.datasets_document.dify_config.RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch( "controllers.console.datasets.datasets_document.DocumentService.document_create_args_validate", return_value=None, @@ -560,7 +561,7 @@ class TestDocumentResource: api = DocumentResource() session = MagicMock() with ( - patch("controllers.console.datasets.datasets_document.dify_config.RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch( "controllers.console.datasets.datasets_document.DatasetService.get_dataset_for_tenant", return_value=dataset, diff --git a/api/tests/unit_tests/controllers/console/datasets/test_metadata.py b/api/tests/unit_tests/controllers/console/datasets/test_metadata.py index 6cf5664bd96..696e0223255 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_metadata.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_metadata.py @@ -22,6 +22,7 @@ from services.entities.knowledge_entities.knowledge_entities import MetadataArgs from services.errors.account import NoPermissionError from services.errors.metadata import MetadataResourceNotFoundError from services.metadata_service import MetadataService +from tests.unit_tests.config_override import config_overrides_context @pytest.fixture @@ -150,7 +151,7 @@ class TestDatasetMetadataGetApi: method = unwrap(api.get) with ( app.test_request_context("/"), - patch("controllers.console.datasets.metadata.dify_config.RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset), patch.object(DatasetService, "check_dataset_permission") as check_permission, patch.object( diff --git a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py index ad4de5468ab..8af0e633620 100644 --- a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py +++ b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py @@ -37,6 +37,32 @@ def _snippet(**overrides) -> CustomizedSnippet: return CustomizedSnippet(**data) +def _workflow(**overrides) -> SimpleNamespace: + data = { + "id": "workflow-1", + "graph_dict": {"nodes": [], "edges": []}, + "features_dict": {}, + "unique_hash": "hash-1", + "version": "2024-01-01 00:00:00", + "marked_name": "v1", + "marked_comment": "first version", + "created_by_account": None, + "created_at": datetime(2024, 1, 1), + "updated_by_account": None, + "updated_at": datetime(2024, 1, 1), + "tool_published": False, + "environment_variables": [], + "conversation_variables": [], + "rag_pipeline_variables": [], + } + data.update(overrides) + workflow = SimpleNamespace(**data) + workflow.get_created_by_account = Mock(return_value=workflow.created_by_account) + workflow.get_updated_by_account = Mock(return_value=workflow.updated_by_account) + workflow.get_tool_published = Mock(return_value=workflow.tool_published) + return workflow + + @pytest.fixture(autouse=True) def _patch_snippet_service_factory(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> None: snippet_session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) @@ -114,6 +140,34 @@ def test_draft_workflow_get_raises_when_missing(app: Flask, monkeypatch: pytest. handler(api, snippet=snippet) +def test_draft_workflow_get_uses_session_aware_response_source(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + workflow = _workflow() + snippet = _snippet() + session = Mock(spec=Session) + monkeypatch.setattr(snippet_workflow_module, "db", SimpleNamespace(session=Mock(return_value=session))) + monkeypatch.setattr( + snippet_workflow_module, + "_snippet_service", + lambda: SimpleNamespace(get_draft_workflow=Mock(return_value=workflow)), + ) + monkeypatch.setattr( + snippet_workflow_module.WorkflowAgentPublishService, + "project_draft_bindings_to_graph", + Mock(return_value=workflow.graph_dict), + ) + + api = snippet_workflow_module.SnippetDraftWorkflowApi() + handler = unwrap(api.get) + + with app.test_request_context("/snippets/snippet-1/workflows/draft"): + response = handler(api, snippet=snippet) + + assert response["id"] == "workflow-1" + workflow.get_created_by_account.assert_called_once_with(session=session) + workflow.get_updated_by_account.assert_called_once_with(session=session) + workflow.get_tool_published.assert_called_once_with(session=session) + + def test_draft_workflow_post_returns_400_for_invalid_graph(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: user = _account("account-1") snippet = _snippet() @@ -161,6 +215,29 @@ def test_published_workflow_get_returns_none_when_not_published(app) -> None: assert handler(api, snippet=SimpleNamespace(id="snippet-1", is_published=False)) is None +def test_published_workflow_get_uses_session_aware_response_source(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + workflow = _workflow() + session = Mock(spec=Session) + snippet = SimpleNamespace(id="snippet-1", is_published=True, input_fields_list=[]) + monkeypatch.setattr(snippet_workflow_module, "db", SimpleNamespace(session=Mock(return_value=session))) + monkeypatch.setattr( + snippet_workflow_module, + "_snippet_service", + lambda: SimpleNamespace(get_published_workflow=Mock(return_value=workflow)), + ) + + api = snippet_workflow_module.SnippetPublishedWorkflowApi() + handler = unwrap(api.get) + + with app.test_request_context("/snippets/snippet-1/workflows/publish"): + response = handler(api, snippet=snippet) + + assert response["id"] == "workflow-1" + workflow.get_created_by_account.assert_called_once_with(session=session) + workflow.get_updated_by_account.assert_called_once_with(session=session) + workflow.get_tool_published.assert_called_once_with(session=session) + + @pytest.mark.parametrize("sqlite_session", [(CustomizedSnippet,)], indirect=True) def test_published_workflow_post_returns_400_when_publish_fails( app: Flask, @@ -247,23 +324,7 @@ def test_list_published_snippet_workflows_includes_input_fields( monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, ) -> None: - workflow = SimpleNamespace( - id="workflow-1", - graph_dict={"nodes": [], "edges": []}, - features_dict={}, - unique_hash="hash-1", - version="2024-01-01 00:00:00", - marked_name="", - marked_comment="", - created_by_account=None, - created_at=datetime(2024, 1, 1), - updated_by_account=None, - updated_at=datetime(2024, 1, 1), - tool_published=False, - environment_variables=[], - conversation_variables=[], - rag_pipeline_variables=[], - ) + workflow = _workflow(marked_name="", marked_comment="") input_fields = [{"variable": "query", "type": "text"}] snippet = _snippet(input_fields=json.dumps(input_fields)) @@ -406,23 +467,7 @@ def test_update_published_snippet_workflow_returns_updated_workflow( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, ) -> None: - workflow = SimpleNamespace( - id="workflow-1", - graph_dict={"nodes": [], "edges": []}, - features_dict={}, - unique_hash="hash-1", - version="2024-01-01 00:00:00", - marked_name="v1", - marked_comment="first version", - created_by_account=None, - created_at=datetime(2024, 1, 1), - updated_by_account=None, - updated_at=datetime(2024, 1, 1), - tool_published=False, - environment_variables=[], - conversation_variables=[], - rag_pipeline_variables=[], - ) + workflow = _workflow() user = _account("account-1") input_fields = [{"variable": "query", "type": "text"}] snippet = _snippet(input_fields=json.dumps(input_fields)) diff --git a/api/tests/unit_tests/controllers/console/tag/test_tags.py b/api/tests/unit_tests/controllers/console/tag/test_tags.py index 573698ed9e4..5d19e08a557 100644 --- a/api/tests/unit_tests/controllers/console/tag/test_tags.py +++ b/api/tests/unit_tests/controllers/console/tag/test_tags.py @@ -31,6 +31,7 @@ from services.tag_application_service import ( TagSummary, UpdateTagInput, ) +from tests.unit_tests.config_override import config_overrides_context def unwrap(func): @@ -142,7 +143,7 @@ class TestTagListApi: with ( app.test_request_context("/", json={"name": "Tag", "type": "knowledge"}), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(dataset_operator, "tenant-1")), ): result, status = unwrap(TagListApi().post)( @@ -163,7 +164,7 @@ class TestTagListApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), patch.object(module, "enforce_rbac_access") as enforce_rbac_access, ): @@ -186,7 +187,7 @@ class TestTagListApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(readonly, "tenant-1")), ): with pytest.raises(Forbidden): @@ -204,7 +205,7 @@ class TestTagListApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), ): with pytest.raises(ValueError, match="Tag name already exists") as exc_info: @@ -225,7 +226,7 @@ class TestTagListApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), ): with pytest.raises(TagApplicationError, match="unexpected"): @@ -246,7 +247,7 @@ class TestTagUpdateDeleteApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), patch.object(module, "enforce_rbac_access") as enforce_rbac_access, ): @@ -273,7 +274,7 @@ class TestTagUpdateDeleteApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(readonly, "tenant-1")), ): with pytest.raises(Forbidden): @@ -292,7 +293,7 @@ class TestTagUpdateDeleteApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), ): with pytest.raises(NotFound, match="Tag not found") as exc_info: @@ -313,7 +314,7 @@ class TestTagUpdateDeleteApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(dataset_operator, "tenant-1")), ): with pytest.raises(Forbidden): @@ -326,7 +327,7 @@ class TestTagUpdateDeleteApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), ): result, status = unwrap(TagUpdateDeleteApi().delete)(TagUpdateDeleteApi(), request_context, "tag-1") @@ -342,7 +343,7 @@ class TestTagUpdateDeleteApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), patch.object(module, "enforce_rbac_access") as enforce_rbac_access, ): @@ -360,7 +361,7 @@ class TestTagUpdateDeleteApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), patch.object(module, "enforce_rbac_access") as enforce_rbac_access, ): @@ -383,7 +384,7 @@ class TestTagBindings: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), ): result, status = unwrap(TagBindingCollectionApi().post)(TagBindingCollectionApi(), payload, request_context) @@ -402,7 +403,7 @@ class TestTagBindings: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), ): result, status = unwrap(TagBindingCollectionApi().post)(TagBindingCollectionApi(), payload, request_context) @@ -422,7 +423,7 @@ class TestTagBindings: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), ): with pytest.raises(NotFound, match="App not found") as exc_info: @@ -437,7 +438,7 @@ class TestTagBindings: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(readonly, "tenant-1")), ): with pytest.raises(Forbidden): @@ -455,7 +456,7 @@ class TestTagBindings: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), ): result, status = unwrap(TagBindingRemoveApi().post)(TagBindingRemoveApi(), payload, request_context) @@ -475,7 +476,7 @@ class TestTagBindings: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), ): with pytest.raises(NotFound, match="Dataset not found") as exc_info: @@ -490,7 +491,7 @@ class TestTagBindings: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(readonly, "tenant-1")), ): with pytest.raises(Forbidden): diff --git a/api/tests/unit_tests/controllers/console/test_apikey.py b/api/tests/unit_tests/controllers/console/test_apikey.py index 06965e67021..5013ea58ffd 100644 --- a/api/tests/unit_tests/controllers/console/test_apikey.py +++ b/api/tests/unit_tests/controllers/console/test_apikey.py @@ -12,7 +12,6 @@ from sqlalchemy import event, select from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest, Forbidden, NotFound -from configs import dify_config from controllers.console.agent.roster import AgentApiKeyListApi from controllers.console.apikey import ( AppApiKeyListResource, @@ -249,7 +248,12 @@ def test_delete_api_key_rejects_foreign_tenant_token(sqlite_session: Session) -> assert session.get(ApiToken, "key-1") is api_key -def test_api_key_lists_require_matching_rbac_permission() -> None: +def test_api_key_lists_require_matching_rbac_permission(config_overrides: Callable[..., None]) -> None: + config_overrides( + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + LOGIN_DISABLED=True, + RBAC_ENABLED=True, + ) app = Flask(__name__) account = _make_account(TenantAccountRole.OWNER) api_id = UUID("00000000-0000-0000-0000-000000000001") @@ -277,9 +281,6 @@ def test_api_key_lists_require_matching_rbac_permission() -> None: with ( app.test_request_context("/"), - patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch.object(dify_config, "LOGIN_DISABLED", True), - patch.object(dify_config, "RBAC_ENABLED", True), patch("controllers.console.wraps.current_account_with_tenant", return_value=(account, "tenant-1")), patch("controllers.common.wraps.current_account_with_tenant", return_value=(account, "tenant-1")), patch.object(BaseApiKeyListResource, "_get_api_key_list") as get_api_key_list, @@ -300,7 +301,12 @@ def test_api_key_lists_require_matching_rbac_permission() -> None: get_api_key_list.assert_not_called() -def test_api_key_lists_reject_legacy_read_only_members() -> None: +def test_api_key_lists_reject_legacy_read_only_members(config_overrides: Callable[..., None]) -> None: + config_overrides( + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + LOGIN_DISABLED=True, + RBAC_ENABLED=False, + ) app = Flask(__name__) account = _make_account(TenantAccountRole.NORMAL) api_id = UUID("00000000-0000-0000-0000-000000000001") @@ -310,9 +316,6 @@ def test_api_key_lists_reject_legacy_read_only_members() -> None: with ( app.test_request_context("/"), - patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch.object(dify_config, "LOGIN_DISABLED", True), - patch.object(dify_config, "RBAC_ENABLED", False), patch("libs.login.current_user", current_user), patch("controllers.console.wraps.current_account_with_tenant", return_value=(account, "tenant-1")), patch.object(BaseApiKeyListResource, "_get_api_key_list") as get_api_key_list, diff --git a/api/tests/unit_tests/controllers/console/test_extension.py b/api/tests/unit_tests/controllers/console/test_extension.py index 97bad4420f7..ea60db4a1a3 100644 --- a/api/tests/unit_tests/controllers/console/test_extension.py +++ b/api/tests/unit_tests/controllers/console/test_extension.py @@ -9,6 +9,8 @@ import pytest from flask import Flask from flask.views import MethodView as FlaskMethodView +from tests.unit_tests.config_override import apply_config_overrides + _NEEDS_METHOD_VIEW_CLEANUP = False if not hasattr(builtins, "MethodView"): builtins.__dict__["MethodView"] = FlaskMethodView @@ -63,9 +65,12 @@ def _mock_console_guards(monkeypatch: pytest.MonkeyPatch) -> Account: account.id = "account-123" account._current_tenant = tenant - monkeypatch.setattr(wraps_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) - monkeypatch.setattr(wraps_module.dify_config, "INIT_PASSWORD", "") - monkeypatch.setattr("libs.login.dify_config.LOGIN_DISABLED", True) + apply_config_overrides( + monkeypatch, + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + INIT_PASSWORD="", + LOGIN_DISABLED=True, + ) monkeypatch.setattr(wraps_module, "current_account_with_tenant", lambda: (account, "tenant-123")) # The login_required decorator consults the shared LocalProxy in libs.login. diff --git a/api/tests/unit_tests/controllers/console/test_fastopenapi_init_validate.py b/api/tests/unit_tests/controllers/console/test_fastopenapi_init_validate.py index 1d73ee30a68..dc8604a88f7 100644 --- a/api/tests/unit_tests/controllers/console/test_fastopenapi_init_validate.py +++ b/api/tests/unit_tests/controllers/console/test_fastopenapi_init_validate.py @@ -14,6 +14,7 @@ from services.init_validation_service import ( InitValidationService, InvalidInitializationPasswordError, ) +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture @@ -59,10 +60,7 @@ def test_validate_init_password_success( init_validation: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr( - "controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) client = app.test_client() response = client.post("/console/api/init", json={"password": "expected"}) @@ -79,10 +77,7 @@ def test_validate_init_password_rejects_a_mismatch( init_validation: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr( - "controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) init_validation.validate_password.side_effect = InvalidInitializationPasswordError client = app.test_client() @@ -98,10 +93,7 @@ def test_validate_init_password_rejects_an_initialized_installation( init_validation: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr( - "controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) init_validation.validate_password.side_effect = AlreadyInitializedError response = app.test_client().post("/console/api/init", json={"password": "expected"}) @@ -122,10 +114,7 @@ def test_validate_init_password_rejects_an_invalid_payload( monkeypatch: pytest.MonkeyPatch, payload: dict[str, str], ) -> None: - monkeypatch.setattr( - "controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) response = app.test_client().post("/console/api/init", json=payload) @@ -138,10 +127,7 @@ def test_validate_init_password_is_not_available_in_cloud( init_validation: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr( - "controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.CLOUD, - ) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) response = app.test_client().post("/console/api/init", json={"password": "expected"}) diff --git a/api/tests/unit_tests/controllers/console/test_fastopenapi_setup.py b/api/tests/unit_tests/controllers/console/test_fastopenapi_setup.py index 8018ed3c115..19fa4f47089 100644 --- a/api/tests/unit_tests/controllers/console/test_fastopenapi_setup.py +++ b/api/tests/unit_tests/controllers/console/test_fastopenapi_setup.py @@ -1,4 +1,5 @@ import builtins +from collections.abc import Callable from datetime import datetime from types import SimpleNamespace from unittest.mock import Mock, create_autospec @@ -7,7 +8,6 @@ import pytest from flask.views import MethodView from controllers.console import setup as setup_controller -from controllers.console import wraps from controllers.console.error import AlreadySetupError, NotInitValidateError from dify_app import DifyApp from enums import DeploymentEdition @@ -78,8 +78,9 @@ def test_console_setup_fastopenapi_post_success( setup_service: Mock, monkeypatch: pytest.MonkeyPatch, deployment_edition: DeploymentEdition, + config_overrides: Callable[..., None], ) -> None: - monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", deployment_edition) + config_overrides(DEPLOYMENT_EDITION=deployment_edition) monkeypatch.setattr(setup_controller, "is_init_validated", lambda: True) mark_setup_completed = Mock() monkeypatch.setattr(setup_controller, "mark_setup_completed", mark_setup_completed) @@ -114,9 +115,9 @@ def test_console_setup_fastopenapi_post_success( def test_console_setup_fastopenapi_post_rejects_cloud_edition( app: DifyApp, setup_service: Mock, - monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], ) -> None: - monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) response = app.test_client().post( "/console/api/setup", @@ -167,10 +168,10 @@ def test_console_setup_fastopenapi_post_rejects_cloud_edition( def test_console_setup_fastopenapi_post_rejects_invalid_payload_before_service_call( app: DifyApp, setup_service: Mock, - monkeypatch: pytest.MonkeyPatch, payload: dict[str, str], + config_overrides: Callable[..., None], ) -> None: - monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) response = app.test_client().post("/console/api/setup", json=payload) @@ -195,8 +196,9 @@ def test_console_setup_translates_service_errors_to_controller_errors( monkeypatch: pytest.MonkeyPatch, service_error: Exception, expected_controller_error: type[Exception], + config_overrides: Callable[..., None], ) -> None: - monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) monkeypatch.setattr(setup_controller, "is_init_validated", lambda: False) mark_setup_completed = Mock() monkeypatch.setattr(setup_controller, "mark_setup_completed", mark_setup_completed) @@ -226,8 +228,9 @@ def test_console_setup_fastopenapi_does_not_mark_setup_completed_when_service_fa app: DifyApp, setup_service: Mock, monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], ) -> None: - monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) monkeypatch.setattr(setup_controller, "is_init_validated", lambda: True) mark_setup_completed = Mock() monkeypatch.setattr(setup_controller, "mark_setup_completed", mark_setup_completed) diff --git a/api/tests/unit_tests/controllers/console/test_fastopenapi_system.py b/api/tests/unit_tests/controllers/console/test_fastopenapi_system.py index 08aa94e6ca3..e82eca70956 100644 --- a/api/tests/unit_tests/controllers/console/test_fastopenapi_system.py +++ b/api/tests/unit_tests/controllers/console/test_fastopenapi_system.py @@ -1,5 +1,4 @@ import builtins -from unittest.mock import patch import pytest from flask.views import MethodView @@ -7,6 +6,7 @@ from flask.views import MethodView from configs import dify_config from dify_app import DifyApp from extensions import ext_fastopenapi +from tests.unit_tests.config_override import config_overrides_context if not hasattr(builtins, "MethodView"): builtins.MethodView = MethodView # type: ignore[attr-defined] @@ -31,7 +31,7 @@ def test_console_ping_fastopenapi_returns_pong(app: DifyApp) -> None: def test_console_version_fastopenapi_returns_current_version(app: DifyApp) -> None: ext_fastopenapi.init_app(app) - with patch("controllers.console.system.dify_config.CHECK_UPDATE_URL", None): + with config_overrides_context(CHECK_UPDATE_URL=None): response = app.test_client().get("/console/api/version", query_string={"current_version": "0.0.0"}) assert response.status_code == 200 diff --git a/api/tests/unit_tests/controllers/console/test_human_input_form.py b/api/tests/unit_tests/controllers/console/test_human_input_form.py index 7265f98b504..39e8f089ce9 100644 --- a/api/tests/unit_tests/controllers/console/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/console/test_human_input_form.py @@ -28,6 +28,7 @@ from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom from models.human_input import RecipientType from models.model import App, AppMode from models.workflow import WorkflowRun +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture(autouse=True) @@ -256,7 +257,7 @@ def test_post_form_decorated_success_validates_request_body(app: Flask, monkeypa "controllers.console.wraps.current_account_with_tenant", lambda: (current_user, "tenant-1"), ) - monkeypatch.setattr("libs.login.dify_config.LOGIN_DISABLED", True) + apply_config_overrides(monkeypatch, LOGIN_DISABLED=True) with app.test_request_context( "/console/api/form/human_input/token", diff --git a/api/tests/unit_tests/controllers/console/test_init_validate.py b/api/tests/unit_tests/controllers/console/test_init_validate.py index 80145f7cae6..cfd6efd230b 100644 --- a/api/tests/unit_tests/controllers/console/test_init_validate.py +++ b/api/tests/unit_tests/controllers/console/test_init_validate.py @@ -6,7 +6,7 @@ from unittest.mock import Mock, create_autospec import pytest from flask import Flask -from controllers.console import init_validate, wraps +from controllers.console import init_validate from controllers.console.error import AlreadySetupError, InitValidateFailedError from enums import DeploymentEdition from services.init_validation_service import ( @@ -14,6 +14,7 @@ from services.init_validation_service import ( InitValidationService, InvalidInitializationPasswordError, ) +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture @@ -49,7 +50,7 @@ def test_validate_init_password_already_setup( init_validation: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) init_validation.validate_password.side_effect = AlreadyInitializedError app.secret_key = "test-secret" @@ -63,7 +64,7 @@ def test_validate_init_password_wrong_password( init_validation: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) init_validation.validate_password.side_effect = InvalidInitializationPasswordError app.secret_key = "test-secret" @@ -78,7 +79,7 @@ def test_validate_init_password_success( init_validation: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) app.secret_key = "test-secret" with app.test_request_context("/console/api/init", method="POST"): diff --git a/api/tests/unit_tests/controllers/console/test_notification.py b/api/tests/unit_tests/controllers/console/test_notification.py new file mode 100644 index 00000000000..48843d1af8a --- /dev/null +++ b/api/tests/unit_tests/controllers/console/test_notification.py @@ -0,0 +1,77 @@ +from inspect import unwrap +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from controllers.console.notification import ( + DismissNotificationPayload, + NotificationApi, + NotificationDismissApi, +) +from machinery.context import RequestContext +from services.entities.notification_entities import NotificationItem, NotificationResult + + +def _request_context() -> RequestContext: + return RequestContext( + request_id="request-1", + trace_id="trace-1", + account_id="account-1", + active_workspace_id="workspace-1", + ) + + +def test_get_notification_delegates_and_serializes_result() -> None: + service = Mock() + service.get_active.return_value = NotificationResult( + should_show=True, + notifications=( + NotificationItem( + notification_id="notification-1", + frequency="once", + lang="en-US", + title="Title", + subtitle="Subtitle", + body="Body", + title_pic_url="https://example.com/title.png", + ), + ), + ) + services = SimpleNamespace(notifications=service) + api = NotificationApi() + method = unwrap(api.get) + context = _request_context() + + with patch("controllers.console.notification.application_services", return_value=services): + result, status = method(api, context) + + assert status == 200 + assert result == { + "should_show": True, + "notifications": [ + { + "notification_id": "notification-1", + "frequency": "once", + "lang": "en-US", + "title": "Title", + "subtitle": "Subtitle", + "body": "Body", + "title_pic_url": "https://example.com/title.png", + } + ], + } + service.get_active.assert_called_once_with(context) + + +def test_dismiss_notification_delegates_with_stable_account_context() -> None: + service = Mock() + services = SimpleNamespace(notifications=service) + api = NotificationDismissApi() + method = unwrap(api.post) + context = _request_context() + + with patch("controllers.console.notification.application_services", return_value=services): + result, status = method(api, DismissNotificationPayload(notification_id="notification-1"), context) + + assert status == 200 + assert result == {"result": "success"} + service.dismiss.assert_called_once_with(context, "notification-1") diff --git a/api/tests/unit_tests/controllers/console/test_onboarding.py b/api/tests/unit_tests/controllers/console/test_onboarding.py index 8d613f7c202..90a9521a2fa 100644 --- a/api/tests/unit_tests/controllers/console/test_onboarding.py +++ b/api/tests/unit_tests/controllers/console/test_onboarding.py @@ -2,47 +2,48 @@ from __future__ import annotations from datetime import UTC, datetime from inspect import unwrap -from unittest.mock import Mock +from types import SimpleNamespace +from unittest.mock import Mock, patch import pytest -from flask import Flask from pydantic import ValidationError from controllers.console.onboarding import ( StepByStepTourStateApi, StepByStepTourStatePatchPayload, + StepByStepTourStateResponse, ) -from extensions.ext_database import db -from models.account import Account, AccountStatus -from services.step_by_step_tour_service import StepByStepTourService +from machinery.context import RequestContext +from services.entities.onboarding_entities import StepByStepTourPatch, StepByStepTourResult -def _account() -> Account: - account = Account(name="User", email="user@example.com", status=AccountStatus.ACTIVE) - account.id = "account-1" - return account +def _request_context() -> RequestContext: + return RequestContext( + request_id="request-1", + trace_id="trace-1", + account_id="account-1", + active_workspace_id="workspace-1", + ) -def _state_response() -> dict[str, object]: - return { - "first_workspace_id": "workspace-1", - "skipped": False, - "completed_task_ids": ["home"], - "manually_enabled_workspace_ids": [], - "manually_disabled_workspace_ids": [], - "updated_at": datetime(2026, 6, 28, tzinfo=UTC), - } +def _state_result() -> StepByStepTourResult: + return StepByStepTourResult( + first_workspace_id="workspace-1", + completed_task_ids=("home",), + updated_at=datetime(2026, 6, 28, tzinfo=UTC), + ) -def test_get_step_by_step_tour_state(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - get_state = Mock(return_value=_state_response()) - monkeypatch.setattr(StepByStepTourService, "get_state", get_state) - +def test_get_step_by_step_tour_state_delegates_with_request_context() -> None: + service = Mock() + service.get_state.return_value = _state_result() + services = SimpleNamespace(step_by_step_tour=service) api = StepByStepTourStateApi() method = unwrap(api.get) + context = _request_context() - with app.test_request_context("/console/api/onboarding/step-by-step-tour/state", method="GET"): - result = method(api, "workspace-1", _account()) + with patch("controllers.console.onboarding.application_services", return_value=services): + result = method(api, context) assert result == { "first_workspace_id": "workspace-1", @@ -52,35 +53,26 @@ def test_get_step_by_step_tour_state(app: Flask, monkeypatch: pytest.MonkeyPatch "manually_disabled_workspace_ids": [], "updated_at": "2026-06-28T00:00:00Z", } - get_state.assert_called_once() - assert get_state.call_args.kwargs["current_tenant_id"] == "workspace-1" - assert get_state.call_args.kwargs["session"] is db.session + service.get_state.assert_called_once_with(context) -def test_patch_step_by_step_tour_state_passes_action_payload( - app: Flask, - monkeypatch: pytest.MonkeyPatch, -) -> None: - patch_state = Mock(return_value=_state_response()) - monkeypatch.setattr(StepByStepTourService, "patch_state", patch_state) - +def test_patch_step_by_step_tour_state_maps_transport_payload_to_command() -> None: + service = Mock() + service.patch_state.return_value = _state_result() + services = SimpleNamespace(step_by_step_tour=service) api = StepByStepTourStateApi() method = unwrap(api.patch) - payload = {"action": "complete_task", "task_id": "studio"} + context = _request_context() + payload = StepByStepTourStatePatchPayload.model_validate({"action": "complete_task", "task_id": "studio"}) - req_data = StepByStepTourStatePatchPayload.model_validate(payload) - with app.test_request_context( - "/console/api/onboarding/step-by-step-tour/state", - method="PATCH", - json=payload, - ): - result = method(api, req_data, "workspace-1", _account()) + with patch("controllers.console.onboarding.application_services", return_value=services): + result = method(api, payload, context) assert result["completed_task_ids"] == ["home"] - patch_state.assert_called_once() - assert patch_state.call_args.kwargs["current_tenant_id"] == "workspace-1" - assert patch_state.call_args.kwargs["patch"] == payload - assert patch_state.call_args.kwargs["session"] is db.session + service.patch_state.assert_called_once_with( + context, + StepByStepTourPatch(action="complete_task", task_id="studio"), + ) def test_patch_payload_rejects_non_action_fields() -> None: @@ -96,3 +88,21 @@ def test_patch_payload_rejects_task_id_without_task_action() -> None: def test_patch_payload_requires_action() -> None: with pytest.raises(ValidationError): StepByStepTourStatePatchPayload.model_validate({"task_id": "home"}) + + +def test_step_by_step_tour_schemas_preserve_enum_values() -> None: + patch_schema = StepByStepTourStatePatchPayload.model_json_schema() + action_schema = patch_schema["properties"]["action"] + task_id_schema = patch_schema["properties"]["task_id"] + task_id_values = next(candidate["enum"] for candidate in task_id_schema["anyOf"] if "enum" in candidate) + response_schema = StepByStepTourStateResponse.model_json_schema() + + assert set(action_schema["enum"]) == { + "skip", + "complete_task", + "uncomplete_task", + "enable_current_workspace", + "disable_current_workspace", + } + assert set(task_id_values) == {"home", "studio", "knowledge", "integration"} + assert set(response_schema["properties"]["completed_task_ids"]["items"]["enum"]) == set(task_id_values) diff --git a/api/tests/unit_tests/controllers/console/test_system.py b/api/tests/unit_tests/controllers/console/test_system.py index 3c390003eb1..8eeb5e48ab1 100644 --- a/api/tests/unit_tests/controllers/console/test_system.py +++ b/api/tests/unit_tests/controllers/console/test_system.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import pytest import controllers.console.system as system_module +from tests.unit_tests.config_override import config_overrides_context class TestHasNewVersion: @@ -37,11 +38,7 @@ class TestCheckVersionUpdate: query = system_module.VersionQuery(current_version="1.0.0") with ( - patch.object( - system_module.dify_config, - "CHECK_UPDATE_URL", - "", - ), + config_overrides_context(CHECK_UPDATE_URL=""), patch.object( system_module.dify_config.project, "version", @@ -56,11 +53,7 @@ class TestCheckVersionUpdate: query = system_module.VersionQuery(current_version="1.0.0") with ( - patch.object( - system_module.dify_config, - "CHECK_UPDATE_URL", - "http://example.com", - ), + config_overrides_context(CHECK_UPDATE_URL="http://example.com"), patch.object( system_module.httpx, "get", @@ -83,11 +76,7 @@ class TestCheckVersionUpdate: } with ( - patch.object( - system_module.dify_config, - "CHECK_UPDATE_URL", - "http://example.com", - ), + config_overrides_context(CHECK_UPDATE_URL="http://example.com"), patch.object( system_module.httpx, "get", @@ -113,11 +102,7 @@ class TestCheckVersionUpdate: } with ( - patch.object( - system_module.dify_config, - "CHECK_UPDATE_URL", - "http://example.com", - ), + config_overrides_context(CHECK_UPDATE_URL="http://example.com"), patch.object( system_module.httpx, "get", diff --git a/api/tests/unit_tests/controllers/console/test_workflow_run_archive.py b/api/tests/unit_tests/controllers/console/test_workflow_run_archive.py index ea0fc9c2dc8..0a33e67163b 100644 --- a/api/tests/unit_tests/controllers/console/test_workflow_run_archive.py +++ b/api/tests/unit_tests/controllers/console/test_workflow_run_archive.py @@ -6,7 +6,6 @@ import pytest from flask import Flask from werkzeug.exceptions import Conflict, Forbidden, NotFound -from configs import dify_config from controllers.console import flask_admission, workflow_run_archive from controllers.console.workflow_run_archive import ( WorkflowRunArchiveDownloadApi, @@ -37,6 +36,7 @@ _ENDPOINTS = [ WorkflowRunArchiveDownloadApi.get, WorkflowRunArchiveDownloadFileApi.get, ] +from tests.unit_tests.config_override import apply_config_overrides def _account(role: TenantAccountRole) -> Account: @@ -82,7 +82,7 @@ def test_workflow_run_archive_endpoints_reject_non_manager_when_rbac_is_disabled method, ) -> None: account = _account(TenantAccountRole.NORMAL) - monkeypatch.setattr(dify_config, "RBAC_ENABLED", False) + apply_config_overrides(monkeypatch, RBAC_ENABLED=False) monkeypatch.setattr( flask_admission, "current_account_with_tenant", @@ -108,7 +108,7 @@ def test_workflow_run_archive_endpoints_are_hidden_outside_cloud( method, args: tuple[object, ...], ) -> None: - monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) app = Flask(__name__) with app.test_request_context(), pytest.raises(NotFound): @@ -119,7 +119,7 @@ def test_workflow_run_archive_endpoint_allows_admitted_role_when_rbac_is_enabled monkeypatch: pytest.MonkeyPatch, ) -> None: account = _account(TenantAccountRole.NORMAL) - monkeypatch.setattr(dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) account_with_tenant = AccountWithTenant(account=account, tenant_id="tenant-1") monkeypatch.setattr(flask_admission, "current_account_with_tenant", lambda: account_with_tenant) monkeypatch.setattr("controllers.console.wraps.current_account_with_tenant", lambda: account_with_tenant) diff --git a/api/tests/unit_tests/controllers/console/test_workspace_account.py b/api/tests/unit_tests/controllers/console/test_workspace_account.py index ae2002850c8..d93bcfa296d 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_account.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_account.py @@ -4,17 +4,23 @@ from unittest.mock import MagicMock, patch from uuid import NAMESPACE_URL, uuid5 import pytest -from flask import Flask +from flask import Flask, request from sqlalchemy.orm import Session from controllers.console.auth.error import InvalidTokenError from controllers.console.error import EducationActivateLimitError, EducationVerifyLimitError, EmailDomainSuspendedError from controllers.console.workspace.account import ( AccountDeleteUpdateFeedbackApi, + AccountDeletionFeedbackPayload, ChangeEmailCheckApi, ChangeEmailResetApi, + ChangeEmailResetPayload, ChangeEmailSendEmailApi, + ChangeEmailSendPayload, + ChangeEmailValidityPayload, CheckEmailUnique, + CheckEmailUniquePayload, + EducationActivatePayload, EducationApi, EducationVerifyApi, ) @@ -127,7 +133,7 @@ class TestEducationApi: ): api = EducationApi() method = inspect.unwrap(api.post) - result = method(api, request_context) + result = method(api, EducationActivatePayload.model_validate(request.get_json() or {}), request_context) assert result == {"message": "success"} education.activate.assert_called_once_with( @@ -181,7 +187,9 @@ class TestEducationApi: ): api = EducationApi() with pytest.raises(EducationActivateLimitError): - inspect.unwrap(api.post)(api, request_context) + inspect.unwrap(api.post)( + api, EducationActivatePayload.model_validate(request.get_json() or {}), request_context + ) def _change_email_context(account_id: str = "acc") -> RequestContext: @@ -218,7 +226,9 @@ class TestChangeEmailControllers: ), ): api = ChangeEmailSendEmailApi() - response = inspect.unwrap(api.post)(api, context) + response = inspect.unwrap(api.post)( + api, ChangeEmailSendPayload.model_validate(request.get_json() or {}), context + ) assert response == {"result": "success", "data": "change-token"} change_email.send_code.assert_called_once_with( @@ -248,7 +258,7 @@ class TestChangeEmailControllers: api = ChangeEmailSendEmailApi() method = inspect.unwrap(api.post) with pytest.raises(InvalidTokenError): - method(api, _change_email_context()) + method(api, ChangeEmailSendPayload.model_validate(request.get_json() or {}), _change_email_context()) def test_validity_serializes_promoted_token(self, app: Flask): change_email = MagicMock() @@ -270,7 +280,9 @@ class TestChangeEmailControllers: ), ): api = ChangeEmailCheckApi() - response = inspect.unwrap(api.post)(api, context) + response = inspect.unwrap(api.post)( + api, ChangeEmailValidityPayload.model_validate(request.get_json() or {}), context + ) assert response == {"is_valid": True, "email": "new@example.com", "token": "verified-token"} change_email.verify_code.assert_called_once_with( @@ -298,7 +310,9 @@ class TestChangeEmailControllers: ), ): api = ChangeEmailResetApi() - response = inspect.unwrap(api.post)(api, context) + response = inspect.unwrap(api.post)( + api, ChangeEmailResetPayload.model_validate(request.get_json() or {}), context + ) assert response["email"] == "new@example.com" change_email.reset.assert_called_once_with( @@ -324,7 +338,9 @@ class TestChangeEmailControllers: ): api = ChangeEmailResetApi() with pytest.raises(EmailDomainSuspendedError): - inspect.unwrap(api.post)(api, _change_email_context()) + inspect.unwrap(api.post)( + api, ChangeEmailResetPayload.model_validate(request.get_json() or {}), _change_email_context() + ) class TestAccountServiceSendChangeEmailEmail: @@ -433,7 +449,7 @@ class TestAccountDeletionFeedback: ): api = AccountDeleteUpdateFeedbackApi() method = inspect.unwrap(api.post) - response = method(api) + response = method(api, AccountDeletionFeedbackPayload.model_validate(request.get_json() or {})) assert response == {"result": "success"} deletion_feedback.submit.assert_called_once_with(email="User@Example.com", feedback="test") @@ -455,7 +471,7 @@ class TestCheckEmailUnique: ), ): api = CheckEmailUnique() - response = inspect.unwrap(api.post)(api) + response = inspect.unwrap(api.post)(api, CheckEmailUniquePayload.model_validate(request.get_json() or {})) assert response == {"result": "success"} change_email.ensure_available.assert_called_once_with("Case@Test.com") @@ -477,7 +493,7 @@ class TestCheckEmailUnique: ): api = CheckEmailUnique() with pytest.raises(EmailDomainSuspendedError): - inspect.unwrap(api.post)(api) + inspect.unwrap(api.post)(api, CheckEmailUniquePayload.model_validate(request.get_json() or {})) @pytest.mark.parametrize( diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py index ee9a54a21cd..20a7291ec66 100644 --- a/api/tests/unit_tests/controllers/console/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -45,6 +45,7 @@ from models import Account, DifySetup from models.account import AccountStatus, TenantAccountRole from models.dataset import Dataset, RateLimitLog from services.entities.feature_entities import LicenseStatus +from tests.unit_tests.config_override import config_overrides_context @pytest.fixture(autouse=True) @@ -195,6 +196,64 @@ class TestCurrentContextInjection: login_required.assert_called_once() account_initialization_required.assert_called_once() + def test_console_email_registration_admission_checks_features_once(self): + features = SimpleNamespace(enable_email_password_login=True, is_allow_register=True) + with ( + patch( + "controllers.console.flask_admission.setup_required", side_effect=lambda view: view + ) as setup_required, + patch( + "controllers.console.flask_admission.FeatureService.get_system_features", + return_value=features, + ) as get_system_features, + ): + + class Handler: + @flask_admission.console_email_registration_admission + def post(self): + return "ok" + + with Flask(__name__).test_request_context(): + result = Handler().post() + + assert result == "ok" + setup_required.assert_called_once() + get_system_features.assert_called_once_with() + + @pytest.mark.parametrize( + ("enable_email_password_login", "is_allow_register"), + [ + pytest.param(False, True, id="password-login-disabled"), + pytest.param(True, False, id="registration-disabled"), + ], + ) + def test_console_email_registration_admission_rejects_disabled_features( + self, + enable_email_password_login: bool, + is_allow_register: bool, + ) -> None: + features = SimpleNamespace( + enable_email_password_login=enable_email_password_login, + is_allow_register=is_allow_register, + ) + with ( + patch("controllers.console.flask_admission.setup_required", side_effect=lambda view: view), + patch( + "controllers.console.flask_admission.FeatureService.get_system_features", + return_value=features, + ), + ): + + class Handler: + @flask_admission.console_email_registration_admission + def post(self): + return "ok" + + with Flask(__name__).test_request_context(), pytest.raises(HTTPException) as exc_info: + Handler().post() + + assert exc_info.value.code == 403 + def test_console_account_admission_preserves_route_kwarg_named_request_context(self): current_user = make_account() @@ -228,10 +287,7 @@ class TestCurrentContextInjection: return request_context with ( - patch( - "controllers.console.flask_admission.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY), Flask(__name__).test_request_context(), pytest.raises(HTTPException) as exc_info, ): @@ -247,7 +303,7 @@ class TestCurrentContextInjection: patch("controllers.console.flask_admission.setup_required", side_effect=lambda view: view), patch("controllers.console.flask_admission.login_required", side_effect=lambda view: view), patch("controllers.console.flask_admission.account_initialization_required", side_effect=lambda view: view), - patch("controllers.console.flask_admission.dify_config.RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch( "controllers.console.flask_admission.current_account_with_tenant", return_value=AccountWithTenant(account=current_user, tenant_id="tenant-123"), @@ -273,7 +329,7 @@ class TestCurrentContextInjection: patch("controllers.console.flask_admission.setup_required", side_effect=lambda view: view), patch("controllers.console.flask_admission.login_required", side_effect=lambda view: view), patch("controllers.console.flask_admission.account_initialization_required", side_effect=lambda view: view), - patch("controllers.console.flask_admission.dify_config.RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch( "controllers.console.flask_admission.current_account_with_tenant", return_value=AccountWithTenant(account=current_user, tenant_id="tenant-123"), diff --git a/api/tests/unit_tests/controllers/console/workspace/test_accounts.py b/api/tests/unit_tests/controllers/console/workspace/test_accounts.py index d0178451eb2..a0487e7b329 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_accounts.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_accounts.py @@ -19,21 +19,32 @@ from controllers.console.auth.error import ( from controllers.console.error import AccountInFreezeError, EmailDomainSuspendedError from controllers.console.workspace.account import ( AccountAvatarApi, + AccountAvatarPayload, AccountAvatarQuery, AccountDeleteApi, + AccountDeletePayload, AccountDeleteVerifyApi, AccountInitApi, + AccountInitPayload, AccountIntegrateApi, AccountInterfaceLanguageApi, + AccountInterfaceLanguagePayload, AccountInterfaceThemeApi, + AccountInterfaceThemePayload, AccountNameApi, + AccountNamePayload, AccountPasswordApi, + AccountPasswordPayload, AccountProfileApi, AccountProfilePatchPayload, AccountTimezoneApi, + AccountTimezonePayload, ChangeEmailCheckApi, ChangeEmailResetApi, + ChangeEmailResetPayload, + ChangeEmailValidityPayload, CheckEmailUnique, + CheckEmailUniquePayload, ) from controllers.console.workspace.error import ( AccountAlreadyInitedError, @@ -59,6 +70,7 @@ from services.account_errors import ( MissingInvitationCodeError, ) from services.entities.account_entities import AccountIntegrationStatus, AccountProfileChanges +from tests.unit_tests.config_override import config_overrides_context def make_account(account_id: str = "u1", *, status: AccountStatus = AccountStatus.ACTIVE) -> Account: @@ -119,7 +131,7 @@ class TestAccountInitApi: return_value=SimpleNamespace(accounts=SimpleNamespace(initialization=initialization)), ), ): - resp = method(api, request_context) + resp = method(api, AccountInitPayload.model_validate(payload), request_context) assert resp["result"] == "success" initialization.initialize.assert_called_once_with( @@ -151,7 +163,7 @@ class TestAccountInitApi: ), ): with pytest.raises(AccountAlreadyInitedError): - method(api, request_context) + method(api, AccountInitPayload.model_validate(payload), request_context) def test_init_missing_invitation_code_is_mapped(self, app: Flask): api = AccountInitApi() @@ -174,7 +186,7 @@ class TestAccountInitApi: ), ): with pytest.raises(MissingInvitationCodeRequestError) as exc_info: - method(api, request_context) + method(api, AccountInitPayload.model_validate(payload), request_context) assert exc_info.value.data == { "code": "missing_invitation_code", @@ -212,25 +224,27 @@ class TestAccountProfileApi: class TestAccountUpdateApis: @pytest.mark.parametrize( - ("api_cls", "payload", "expected_changes"), + ("api_cls", "payload_model", "payload", "expected_changes"), [ - (AccountNameApi, {"name": "test"}, AccountProfileChanges(name="test")), - (AccountAvatarApi, {"avatar": "img.png"}, AccountProfileChanges(avatar="img.png")), + (AccountNameApi, AccountNamePayload, {"name": "test"}, AccountProfileChanges(name="test")), + (AccountAvatarApi, AccountAvatarPayload, {"avatar": "img.png"}, AccountProfileChanges(avatar="img.png")), ( AccountInterfaceLanguageApi, + AccountInterfaceLanguagePayload, {"interface_language": "en-US"}, AccountProfileChanges(interface_language="en-US"), ), ( AccountInterfaceThemeApi, + AccountInterfaceThemePayload, {"interface_theme": "dark"}, AccountProfileChanges(interface_theme="dark"), ), - (AccountTimezoneApi, {"timezone": "UTC"}, AccountProfileChanges(timezone="UTC")), + (AccountTimezoneApi, AccountTimezonePayload, {"timezone": "UTC"}, AccountProfileChanges(timezone="UTC")), ], ) def test_deprecated_update_routes_delegate_to_profile_service( - self, app: Flask, api_cls, payload, expected_changes: AccountProfileChanges + self, app: Flask, api_cls, payload_model, payload, expected_changes: AccountProfileChanges ): api = api_cls() method = inspect.unwrap(api.post) @@ -251,7 +265,7 @@ class TestAccountUpdateApis: return_value=SimpleNamespace(accounts=SimpleNamespace(profile=profile)), ), ): - result = method(api, request_context) + result = method(api, payload_model.model_validate(payload), request_context) assert result["id"] == user.id profile.update.assert_called_once_with(request_context, expected_changes) @@ -409,7 +423,7 @@ class TestAccountAvatarApiGet: with ( app.test_request_context("/account/avatar"), patch("controllers.console.wraps._is_setup_completed", return_value=True), - patch("libs.login.dify_config.LOGIN_DISABLED", True), + config_overrides_context(LOGIN_DISABLED=True), patch( "controllers.console.wraps.current_account_with_tenant", return_value=(account, "workspace-1"), @@ -425,6 +439,30 @@ class TestAccountAvatarApiGet: assert exc_info.value.code == 422 +class TestConvertedPostDecorator: + def test_rejects_an_invalid_body_through_the_decorator(self, app: Flask): + """The decorator validates the JSON body before the view runs, for the POST handlers too.""" + account = make_account() + + with ( + app.test_request_context("/account/name", method="POST", json={}), + patch("controllers.console.wraps._is_setup_completed", return_value=True), + config_overrides_context(LOGIN_DISABLED=True), + patch( + "controllers.console.wraps.current_account_with_tenant", + return_value=(account, "workspace-1"), + ), + patch( + "controllers.console.flask_admission.current_account_with_tenant", + return_value=SimpleNamespace(account=account, tenant_id="workspace-1"), + ), + ): + with pytest.raises(UnprocessableEntity) as exc_info: + AccountNameApi().post() + + assert exc_info.value.code == 422 + + class TestAccountPasswordApi: def test_password_success(self, app: Flask): api = AccountPasswordApi() @@ -453,7 +491,7 @@ class TestAccountPasswordApi: return_value=SimpleNamespace(accounts=SimpleNamespace(password=password)), ), ): - result = method(api, request_context) + result = method(api, AccountPasswordPayload.model_validate(payload), request_context) assert result["id"] == user.id password.change.assert_called_once_with( @@ -489,7 +527,7 @@ class TestAccountPasswordApi: ), ): with pytest.raises(CurrentPasswordIncorrectError): - method(api, request_context) + method(api, AccountPasswordPayload.model_validate(payload), request_context) def test_password_policy_error_is_mapped(self, app: Flask): api = AccountPasswordApi() @@ -518,7 +556,7 @@ class TestAccountPasswordApi: ), ): with pytest.raises(InvalidAccountPasswordRequestError) as exc_info: - method(api, request_context) + method(api, AccountPasswordPayload.model_validate(payload), request_context) assert exc_info.value.data == { "code": "invalid_account_password", @@ -609,7 +647,7 @@ class TestAccountDeleteApi: ), ): with pytest.raises(InvalidAccountDeletionCodeError): - method(api, request_context) + method(api, AccountDeletePayload.model_validate(payload), request_context) def test_delete_verify_maps_rate_limit(self, app: Flask): api = AccountDeleteVerifyApi() @@ -652,7 +690,7 @@ class TestAccountDeleteApi: return_value=SimpleNamespace(accounts=SimpleNamespace(deletion=deletion)), ), ): - result = method(api, request_context) + result = method(api, AccountDeletePayload.model_validate(payload), request_context) assert result["result"] == "success" deletion.request_deletion.assert_called_once_with(request_context, token="token", code="123456") @@ -688,7 +726,7 @@ class TestChangeEmailApis: ), ): with pytest.raises(EmailCodeError): - method(api, request_context) + method(api, ChangeEmailValidityPayload.model_validate(payload), request_context) def test_reset_email_already_used(self, app: Flask): api = ChangeEmailResetApi() @@ -719,7 +757,7 @@ class TestChangeEmailApis: ), ): with pytest.raises(EmailAlreadyInUseError): - method(api, request_context) + method(api, ChangeEmailResetPayload.model_validate(payload), request_context) class TestCheckEmailUniqueApi: @@ -743,7 +781,7 @@ class TestCheckEmailUniqueApi: return_value=SimpleNamespace(accounts=SimpleNamespace(change_email=change_email)), ), ): - result = method(api) + result = method(api, CheckEmailUniquePayload.model_validate(payload)) assert result["result"] == "success" @@ -769,7 +807,7 @@ class TestCheckEmailUniqueApi: ), ): with pytest.raises(AccountInFreezeError): - method(api) + method(api, CheckEmailUniquePayload.model_validate(payload)) def test_email_domain_is_suspended(self, app: Flask): api = CheckEmailUnique() @@ -793,4 +831,4 @@ class TestCheckEmailUniqueApi: ), ): with pytest.raises(EmailDomainSuspendedError): - method(api) + method(api, CheckEmailUniquePayload.model_validate(payload)) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py b/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py index 93a6133007c..a4f14e5255d 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py @@ -38,6 +38,7 @@ from services.entities.model_provider_entities import ( SystemConfigurationResponse, ) from services.workspace_service import EffectiveCreditPool +from tests.unit_tests.config_override import config_overrides_context VALID_UUID = "123e4567-e89b-12d3-a456-426614174000" INVALID_UUID = "123" @@ -603,9 +604,11 @@ class TestModelProviderPaymentCheckoutUrlApi: with ( app.test_request_context("/"), - patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch.object(dify_config, "LOGIN_DISABLED", True), - patch.object(dify_config, "RBAC_ENABLED", False), + config_overrides_context( + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + LOGIN_DISABLED=True, + RBAC_ENABLED=False, + ), patch( "controllers.console.workspace.model_providers.BillingService.get_model_provider_payment_link", ) as get_model_provider_payment_link, diff --git a/api/tests/unit_tests/controllers/console/workspace/test_plugin.py b/api/tests/unit_tests/controllers/console/workspace/test_plugin.py index 88e9487f5dc..ea0f79a851c 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_plugin.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_plugin.py @@ -82,6 +82,7 @@ from models.account import ( TenantPluginDebugPermission, TenantPluginInstallPermission, ) +from tests.unit_tests.config_override import config_overrides_context def _plugin_category_list_item(category: str = "tool") -> dict[str, Any]: @@ -653,7 +654,7 @@ class TestPluginUploadFromPkgApi: with ( app.test_request_context("/", data=data, content_type="multipart/form-data"), - patch("controllers.console.workspace.plugin.dify_config.PLUGIN_MAX_PACKAGE_SIZE", 0), + config_overrides_context(PLUGIN_MAX_PACKAGE_SIZE=0), patch("controllers.console.workspace.plugin.PluginService.upload_pkg") as upload_pkg_mock, ): with pytest.raises(ValueError) as exc_info: @@ -937,7 +938,7 @@ class TestPluginUploadFromBundleApi: data={"bundle": file}, content_type="multipart/form-data", ), - patch("controllers.console.workspace.plugin.dify_config.PLUGIN_MAX_BUNDLE_SIZE", 0), + config_overrides_context(PLUGIN_MAX_BUNDLE_SIZE=0), patch("controllers.console.workspace.plugin.PluginService.upload_bundle") as upload_bundle_mock, ): with pytest.raises(ValueError) as exc_info: diff --git a/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py b/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py index bc963f7065d..c205f931cc6 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py @@ -4,6 +4,7 @@ from __future__ import annotations import builtins import importlib +from collections.abc import Callable from contextlib import ExitStack, contextmanager from inspect import unwrap from types import ModuleType @@ -40,7 +41,7 @@ def app() -> Flask: @pytest.fixture -def controller_module(monkeypatch: pytest.MonkeyPatch): +def controller_module(monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]): """ Import the controller with auth decorators neutralized only during import. @@ -74,8 +75,7 @@ def controller_module(monkeypatch: pytest.MonkeyPatch): global _WRAPS_MODULE wraps_module = importlib.import_module("controllers.console.wraps") _WRAPS_MODULE = wraps_module - monkeypatch.setattr(module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) - monkeypatch.setattr(wraps_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) login_module = importlib.import_module("libs.login") monkeypatch.setattr(login_module, "check_csrf_token", lambda *args, **kwargs: None) @@ -718,20 +718,24 @@ def test_tool_labels_list(app: Flask, controller_module, monkeypatch: pytest.Mon # --- _resolve_identity_mode: gating + None-resolution (PR #36839 review) --- -def test_resolve_identity_mode_none_keeps_current_when_enterprise(controller_module, monkeypatch: pytest.MonkeyPatch): +def test_resolve_identity_mode_none_keeps_current_when_enterprise( + controller_module, config_overrides: Callable[..., None] +): """None means 'leave unchanged' — fall back to the stored mode (update path).""" identity_mode = importlib.import_module("core.entities.mcp_provider").IdentityMode - monkeypatch.setattr(controller_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) resolved = controller_module._resolve_identity_mode(None, current=identity_mode.IDP_TOKEN) assert resolved == identity_mode.IDP_TOKEN -def test_resolve_identity_mode_explicit_value_overrides_current(controller_module, monkeypatch: pytest.MonkeyPatch): +def test_resolve_identity_mode_explicit_value_overrides_current( + controller_module, config_overrides: Callable[..., None] +): """An explicit value wins over the stored mode.""" identity_mode = importlib.import_module("core.entities.mcp_provider").IdentityMode - monkeypatch.setattr(controller_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) resolved = controller_module._resolve_identity_mode(identity_mode.OFF, current=identity_mode.IDP_TOKEN) @@ -739,12 +743,12 @@ def test_resolve_identity_mode_explicit_value_overrides_current(controller_modul def test_resolve_identity_mode_coerces_non_off_to_off_when_not_enterprise( - controller_module, monkeypatch: pytest.MonkeyPatch + controller_module, config_overrides: Callable[..., None] ): """Gate: a non-EE deployment must never persist a non-OFF mode — the runtime won't forward, so the stored row must not imply it does.""" identity_mode = importlib.import_module("core.entities.mcp_provider").IdentityMode - monkeypatch.setattr(controller_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) # Both an explicit idp_token request AND an inherited non-OFF current # must collapse to OFF. @@ -756,11 +760,11 @@ def test_resolve_identity_mode_coerces_non_off_to_off_when_not_enterprise( def test_resolve_identity_mode_off_is_passthrough_when_not_enterprise( - controller_module, monkeypatch: pytest.MonkeyPatch + controller_module, config_overrides: Callable[..., None] ): """OFF is always fine — the gate only neutralizes non-OFF values.""" identity_mode = importlib.import_module("core.entities.mcp_provider").IdentityMode - monkeypatch.setattr(controller_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) assert controller_module._resolve_identity_mode(None, current=identity_mode.OFF) == identity_mode.OFF diff --git a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py index 9c399e18add..6a7d95d0f0a 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py @@ -45,6 +45,7 @@ from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfi from repositories.workspace_query_repository import WorkspaceQueryRepository from services import workspace_plan_gateway from services.workspace_query_service import WorkspaceQueryService, WorkspaceRecord +from tests.unit_tests.config_override import config_overrides_context @pytest.fixture @@ -485,7 +486,7 @@ class TestCustomConfigWorkspaceApi: with ( app.test_request_context("/workspaces/custom-config"), - patch("controllers.console.workspace.workspace.dify_config.FILES_URL", "https://files.example.com"), + config_overrides_context(FILES_URL="https://files.example.com"), ): result = method(api, workspace_session, tenant.id) diff --git a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py index 53024f24e01..66c339a78e3 100644 --- a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py +++ b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py @@ -38,6 +38,7 @@ from controllers.inner_api.plugin.plugin import ( ) from core.workflow.file_reference import build_file_reference from models import Account, Tenant +from tests.unit_tests.config_override import apply_config_overrides def _tenant() -> Tenant: @@ -362,7 +363,7 @@ class TestPluginUploadFileRequestApi: """Test that post() generates a signed URL and returns it""" # Arrange mock_get_uri.return_value = "/files/upload/for-plugin?sign=1" - monkeypatch.setattr(plugin_module.dify_config, "INTERNAL_FILES_URL", "http://api:5001") + apply_config_overrides(monkeypatch, INTERNAL_FILES_URL="http://api:5001") tenant = _tenant() user = _user() mock_payload = MagicMock() @@ -432,8 +433,11 @@ class TestPluginDownloadFileRequestApi: size=123, download_uri="/files/tools/report.pdf?sign=1", ) - monkeypatch.setattr(plugin_module.dify_config, "FILES_URL", "https://files.example.com") - monkeypatch.setattr(plugin_module.dify_config, "INTERNAL_FILES_URL", "http://api:5001") + apply_config_overrides( + monkeypatch, + FILES_URL="https://files.example.com", + INTERNAL_FILES_URL="http://api:5001", + ) mock_payload = MagicMock() mock_payload.tenant_id = tenant.id mock_payload.user_id = "user-id" diff --git a/api/tests/unit_tests/controllers/inner_api/test_agent_files.py b/api/tests/unit_tests/controllers/inner_api/test_agent_files.py index a4874ae2077..f5bb418d76a 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_agent_files.py +++ b/api/tests/unit_tests/controllers/inner_api/test_agent_files.py @@ -15,6 +15,7 @@ from controllers.inner_api.agent.files import ( from core.workflow.file_reference import build_file_reference from models.account import Account, Tenant from services.file_request_service import DownloadFileRequestResult +from tests.unit_tests.config_override import apply_config_overrides MODULE = "controllers.inner_api.agent.files" @@ -132,7 +133,7 @@ def test_download_request_binds_frontend_url( "file": {"transfer_method": "tool_file", "reference": reference}, "for_frontend": True, } - monkeypatch.setattr(f"{MODULE}.dify_config.FILES_URL", "https://files.example.com") + apply_config_overrides(monkeypatch, FILES_URL="https://files.example.com") session = unbound_session with app.test_request_context("/", method="POST", json=payload): with ( diff --git a/api/tests/unit_tests/controllers/inner_api/test_agent_llm.py b/api/tests/unit_tests/controllers/inner_api/test_agent_llm.py index 15160f8e749..f37e1acd971 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_agent_llm.py +++ b/api/tests/unit_tests/controllers/inner_api/test_agent_llm.py @@ -15,6 +15,7 @@ from graphon.model_runtime.entities.llm_entities import LLMResultChunk, LLMResul from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, UserPromptMessage from services.agent_llm_inner_service import AgentLLMInnerServiceError, PreparedAgentLLMInvocation from services.entities.agent_llm_inner import AgentLLMInvokeRequest +from tests.unit_tests.config_override import config_overrides_context def _payload() -> dict[str, object]: @@ -44,8 +45,7 @@ def _payload() -> dict[str, object]: @contextmanager def _agent_inner_auth() -> Generator[None]: with ( - patch("configs.dify_config.PLUGIN_DAEMON_KEY", "plugin-daemon-key"), - patch("configs.dify_config.INNER_API_KEY_FOR_PLUGIN", "inner-key"), + config_overrides_context(PLUGIN_DAEMON_KEY="plugin-daemon-key", INNER_API_KEY_FOR_PLUGIN="inner-key"), ): yield diff --git a/api/tests/unit_tests/controllers/inner_api/test_agent_tools.py b/api/tests/unit_tests/controllers/inner_api/test_agent_tools.py index 2a92ecbbc10..186615f3d90 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_agent_tools.py +++ b/api/tests/unit_tests/controllers/inner_api/test_agent_tools.py @@ -9,6 +9,7 @@ from flask import Flask from controllers.inner_api import bp as inner_api_bp from services.entities.agent_tool_inner import AgentToolInvokeResponse from services.errors.agent_tool_inner import AgentToolInnerServiceError +from tests.unit_tests.config_override import config_overrides_context def _headers(api_key: str | None = "inner-key") -> dict[str, str]: @@ -48,8 +49,7 @@ def _payload() -> dict[str, object]: @contextmanager def _agent_inner_auth() -> Generator[None]: with ( - patch("configs.dify_config.PLUGIN_DAEMON_KEY", "plugin-daemon-key"), - patch("configs.dify_config.INNER_API_KEY_FOR_PLUGIN", "inner-key"), + config_overrides_context(PLUGIN_DAEMON_KEY="plugin-daemon-key", INNER_API_KEY_FOR_PLUGIN="inner-key"), ): yield diff --git a/api/tests/unit_tests/controllers/inner_api/test_knowledge_retrieval.py b/api/tests/unit_tests/controllers/inner_api/test_knowledge_retrieval.py index 00eab81cbe6..805385b09b2 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_knowledge_retrieval.py +++ b/api/tests/unit_tests/controllers/inner_api/test_knowledge_retrieval.py @@ -16,6 +16,7 @@ from services.errors.knowledge_retrieval import ( InnerKnowledgeRetrieveAppNotFoundError, InnerKnowledgeRetrieveDatasetTenantMismatchError, ) +from tests.unit_tests.config_override import config_overrides_context @pytest.fixture @@ -58,8 +59,7 @@ def _payload() -> dict[str, object]: @contextmanager def _plugin_inner_auth() -> Iterator[None]: with ( - patch("configs.dify_config.PLUGIN_DAEMON_KEY", "plugin-daemon-key"), - patch("configs.dify_config.INNER_API_KEY_FOR_PLUGIN", "inner-key"), + config_overrides_context(PLUGIN_DAEMON_KEY="plugin-daemon-key", INNER_API_KEY_FOR_PLUGIN="inner-key"), ): yield diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_composition.py b/api/tests/unit_tests/controllers/openapi/auth/test_composition.py index 41d4416efc5..e1b7a4f3cd3 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_composition.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_composition.py @@ -17,6 +17,7 @@ from enums import DeploymentEdition from libs.oauth_bearer import Scope, TokenType from models.account import TenantAccountRole from services.enterprise.enterprise_service import WebAppAccessMode +from tests.unit_tests.config_override import config_overrides_context def test_account_pipeline_is_auth_pipeline(): @@ -163,10 +164,7 @@ def _selected_webapp_steps(*, scope, app_access_mode): features.webapp_auth.enabled = True selected = [] with ( - patch( - "controllers.openapi.auth.conditions.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.ENTERPRISE, - ), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE), patch("controllers.openapi.auth.conditions.FeatureService.get_system_features", return_value=features), ): for step in account_pipeline._auth: diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py b/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py index b9cd877f0bf..a1f6b26c9ca 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py @@ -24,6 +24,7 @@ from enums import DeploymentEdition from libs.oauth_bearer import Scope, TokenType from models.account import TenantAccountRole from services.enterprise.enterprise_service import WebAppAccessMode +from tests.unit_tests.config_override import config_overrides_context def _ctx(token_type=TokenType.OAUTH_ACCOUNT, path_params=None, **kwargs): @@ -117,29 +118,20 @@ def test_path_has_app_id_false(): def test_edition_community(): - with patch( - "controllers.openapi.auth.conditions.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ): + with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY): assert EDITION_COMMUNITY(_ctx()) is True assert EDITION_ENTERPRISE(_ctx()) is False assert EDITION_CLOUD(_ctx()) is False def test_edition_enterprise(): - with patch( - "controllers.openapi.auth.conditions.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.ENTERPRISE, - ): + with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE): assert EDITION_ENTERPRISE(_ctx()) is True assert EDITION_COMMUNITY(_ctx()) is False def test_edition_cloud(): - with patch( - "controllers.openapi.auth.conditions.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.CLOUD, - ): + with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD): assert EDITION_CLOUD(_ctx()) is True diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py b/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py index a9f5df5aae6..f483c80eb38 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py @@ -9,6 +9,7 @@ from controllers.openapi.auth.data import AuthData from controllers.openapi.auth.pipeline import AuthPipeline, PipelineRoute, PipelineRouter from enums import DeploymentEdition from libs.oauth_bearer import Scope, TokenType +from tests.unit_tests.config_override import config_overrides_context def _make_identity( @@ -76,10 +77,7 @@ def test_guard_edition_gate_returns_404(app): router = _make_router() with app.test_request_context("/test"): - with patch( - "controllers.openapi.auth.pipeline.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ): + with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY): @router.guard(scope=Scope.FULL, edition=frozenset({DeploymentEdition.ENTERPRISE})) def view(*, auth_data): @@ -97,10 +95,7 @@ def test_guard_token_type_gate_returns_403(app): patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"), patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth, patch("controllers.openapi.auth.pipeline.emit_wrong_surface"), - patch( - "controllers.openapi.auth.pipeline.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY), ): identity = _fake_identity() identity.token_type = TokenType.OAUTH_EXTERNAL_SSO @@ -121,10 +116,7 @@ def test_guard_unregistered_token_type_returns_403(app): with ( patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"), patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth, - patch( - "controllers.openapi.auth.pipeline.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY), ): identity = _fake_identity() identity.token_type = TokenType.OAUTH_EXTERNAL_SSO @@ -213,10 +205,7 @@ def test_router_rejects_token_type_on_wrong_edition(app): with ( patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"), patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth, - patch( - "controllers.openapi.auth.pipeline.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY), ): identity = _make_identity(token_type=TokenType.OAUTH_EXTERNAL_SSO) mock_auth.return_value.authenticate.return_value = identity diff --git a/api/tests/unit_tests/controllers/openapi/test_app_run_rate_limit.py b/api/tests/unit_tests/controllers/openapi/test_app_run_rate_limit.py index d9c468d0a4e..d2787a57591 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_run_rate_limit.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_run_rate_limit.py @@ -8,8 +8,12 @@ import pytest from werkzeug.exceptions import TooManyRequests from controllers.openapi.app_run import _translate_service_errors +from controllers.service_api.app.error import TriggerWorkflowServiceModeUnavailableError from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from core.errors.error import AppInvokeQuotaExceededError +from services.errors.app import ( + TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError, +) from services.errors.llm import InvokeRateLimitError @@ -27,3 +31,11 @@ def test_translate_maps_workflow_quota_to_rate_limit_error(): raise InvokeRateLimitError("workflow quota exhausted") assert exc.value.error_code == "rate_limit_error" assert exc.value.code == 429 + + +def test_translate_maps_trigger_workflow_to_stable_unavailable_error(): + with pytest.raises(TriggerWorkflowServiceModeUnavailableError) as exc: + with _translate_service_errors(): + raise TriggerWorkflowServiceModeUnavailableServiceError() + assert exc.value.error_code == "trigger_workflow_service_mode_unavailable" + assert exc.value.code == 403 diff --git a/api/tests/unit_tests/controllers/openapi/test_meta_version.py b/api/tests/unit_tests/controllers/openapi/test_meta_version.py index 3da3c4fca21..9befa1c9551 100644 --- a/api/tests/unit_tests/controllers/openapi/test_meta_version.py +++ b/api/tests/unit_tests/controllers/openapi/test_meta_version.py @@ -5,6 +5,7 @@ from __future__ import annotations import pytest from enums import DeploymentEdition +from tests.unit_tests.config_override import apply_config_overrides def test_version_endpoint_returns_200_without_auth(openapi_app): @@ -35,9 +36,7 @@ def test_version_endpoint_ignores_bearer_header(openapi_app): def test_version_endpoint_reflects_edition_config(openapi_app, monkeypatch: pytest.MonkeyPatch): - from configs import dify_config - - monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) client = openapi_app.test_client() response = client.get("/openapi/v1/_version") @@ -47,9 +46,7 @@ def test_version_endpoint_reflects_edition_config(openapi_app, monkeypatch: pyte def test_version_endpoint_reflects_enterprise_edition(openapi_app, monkeypatch: pytest.MonkeyPatch): - from configs import dify_config - - monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) client = openapi_app.test_client() response = client.get("/openapi/v1/_version") diff --git a/api/tests/unit_tests/controllers/service_api/app/test_annotation.py b/api/tests/unit_tests/controllers/service_api/app/test_annotation.py index 866018bd49d..4bd305008bc 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_annotation.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_annotation.py @@ -18,7 +18,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, Mock import pytest -from flask import Flask +from flask import Flask, request from flask_restx.api import HTTPStatus from pydantic import ValidationError @@ -170,7 +170,8 @@ class TestAnnotationReplyActionApi: method="POST", json={"score_threshold": 0.5, "embedding_provider_name": "p", "embedding_model_name": "m"}, ): - response, status = handler(api, app_model=app_model, action="enable") + payload = AnnotationReplyActionPayload.model_validate(request.get_json() or {}) + response, status = handler(api, payload, app_model=app_model, action="enable") assert status == 200 assert response == {"job_id": "job-1", "job_status": "waiting"} enable_mock.assert_called_once() @@ -186,7 +187,8 @@ class TestAnnotationReplyActionApi: method="POST", json={"score_threshold": 0.5, "embedding_provider_name": "p", "embedding_model_name": "m"}, ): - response, status = handler(api, app_model=app_model, action="disable") + payload = AnnotationReplyActionPayload.model_validate(request.get_json() or {}) + response, status = handler(api, payload, app_model=app_model, action="disable") assert status == 200 assert response == {"job_id": "job-1", "job_status": "waiting"} disable_mock.assert_called_once() @@ -273,7 +275,8 @@ class TestAnnotationListApi: handler = unwrap(api.post) app_model = SimpleNamespace(id="app") with app.test_request_context("/apps/annotations", method="POST", json={"question": "q", "answer": "a"}): - response, status = handler(api, MagicMock(), app_model=app_model) + payload = AnnotationCreatePayload.model_validate(request.get_json() or {}) + response, status = handler(api, payload, MagicMock(), app_model=app_model) assert status == HTTPStatus.CREATED assert response["question"] == "q" @@ -291,7 +294,8 @@ class TestAnnotationUpdateDeleteApi: delete_handler = unwrap(api.delete) app_model = SimpleNamespace(id="app", tenant_id="tenant") with app.test_request_context("/apps/annotations/1", method="PUT", json={"question": "q", "answer": "a"}): - response = put_handler(api, MagicMock(), app_model=app_model, annotation_id="1") + payload = AnnotationCreatePayload.model_validate(request.get_json() or {}) + response = put_handler(api, payload, MagicMock(), app_model=app_model, annotation_id="1") assert response["answer"] == "a" with app.test_request_context("/apps/annotations/1", method="DELETE"): response, status = delete_handler(api, MagicMock(), app_model=app_model, annotation_id="1") diff --git a/api/tests/unit_tests/controllers/service_api/app/test_audio.py b/api/tests/unit_tests/controllers/service_api/app/test_audio.py index 091c129c874..9e51ffea837 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_audio.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_audio.py @@ -13,7 +13,7 @@ from inspect import unwrap from unittest.mock import Mock, patch import pytest -from flask import Flask +from flask import Flask, request from sqlalchemy.orm import Session from werkzeug.datastructures import FileStorage from werkzeug.exceptions import InternalServerError @@ -285,7 +285,8 @@ class TestTextApi: method="POST", json={"text": "hello", "voice": "v"}, ): - response = handler(api, app_model=app_model, end_user=end_user) + payload = TextToAudioPayload.model_validate(request.get_json() or {}) + response = handler(api, payload, app_model=app_model, end_user=end_user) assert response == {"audio": "ok"} @@ -308,7 +309,8 @@ class TestTextApi: method="POST", json={"text": "hello", "message_id": "message-1"}, ): - response = handler(api, app_model=app_model, end_user=end_user) + payload = TextToAudioPayload.model_validate(request.get_json() or {}) + response = handler(api, payload, app_model=app_model, end_user=end_user) assert response == {"audio": "ok"} assert calls["message_ref"] == MessageRef(AppRef("tenant-1", "a1"), "message-1", end_user_id="end-user-1") @@ -324,5 +326,6 @@ class TestTextApi: end_user = _end_user(end_user_id="end-user-1", external_user_id="ext") with app.test_request_context("/text-to-audio", method="POST", json={"text": "hello"}): + payload = TextToAudioPayload.model_validate(request.get_json() or {}) with pytest.raises(ProviderQuotaExceededError): - handler(api, app_model=app_model, end_user=end_user) + handler(api, payload, app_model=app_model, end_user=end_user) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_completion.py b/api/tests/unit_tests/controllers/service_api/app/test_completion.py index 39c986e3ca2..a83e8b9f734 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_completion.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_completion.py @@ -54,6 +54,7 @@ from services.conversation_service import ConversationService from services.errors.app import IsDraftWorkflowError, WorkflowIdFormatError, WorkflowNotFoundError from services.errors.conversation import ConversationNotExistsError from services.errors.llm import InvokeRateLimitError +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture @@ -556,7 +557,7 @@ class TestChatApiController: self, app: Flask, monkeypatch: pytest.MonkeyPatch, orm_session: Session ) -> None: completion_module = sys.modules["controllers.service_api.app.completion"] - monkeypatch.setattr(completion_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) billing_get_info = Mock(return_value={"enabled": True, "subscription": {"plan": CloudPlan.SANDBOX}}) generate = Mock() @@ -602,7 +603,7 @@ class TestChatApiController: workflow_id: str | None, ) -> None: completion_module = sys.modules["controllers.service_api.app.completion"] - monkeypatch.setattr(completion_module.dify_config, "DEPLOYMENT_EDITION", deployment_edition) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=deployment_edition) billing_get_info = Mock(return_value={"enabled": billing_enabled, "subscription": {"plan": plan}}) generate = Mock(return_value={"result": "ok"}) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_conversation.py b/api/tests/unit_tests/controllers/service_api/app/test_conversation.py index 3a28e8c3fbd..2c02a14ca58 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_conversation.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_conversation.py @@ -21,7 +21,7 @@ from types import SimpleNamespace from unittest.mock import Mock, patch import pytest -from flask import Flask +from flask import Flask, request from sqlalchemy.engine import Engine from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest, NotFound @@ -617,9 +617,11 @@ class TestConversationRenameApiController: method="POST", json={"auto_generate": True}, ): + payload = ConversationRenamePayload.model_validate(request.get_json() or {}) with pytest.raises(NotFound): handler( api, + payload, app_model=app_model, end_user=end_user, conversation_id="00000000-0000-0000-0000-000000000001", @@ -728,9 +730,11 @@ class TestConversationVariableDetailApiController: method="PUT", json={"value": "x"}, ): + payload = ConversationVariableUpdatePayload.model_validate(request.get_json() or {}) with pytest.raises(BadRequest): handler( api, + payload, app_model=app_model, end_user=end_user, conversation_id="00000000-0000-0000-0000-000000000001", @@ -754,9 +758,11 @@ class TestConversationVariableDetailApiController: method="PUT", json={"value": "x"}, ): + payload = ConversationVariableUpdatePayload.model_validate(request.get_json() or {}) with pytest.raises(NotFound): handler( api, + payload, app_model=app_model, end_user=end_user, conversation_id="00000000-0000-0000-0000-000000000001", @@ -788,8 +794,10 @@ class TestConversationVariableDetailApiController: method="PUT", json={"value": 1}, ): + payload = ConversationVariableUpdatePayload.model_validate(request.get_json() or {}) result = handler( api, + payload, app_model=app_model, end_user=end_user, conversation_id="00000000-0000-0000-0000-000000000001", diff --git a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py index 55a130d8b09..d0d77557cad 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py @@ -50,6 +50,7 @@ from repositories.api_workflow_node_execution_repository import WorkflowNodeExec from repositories.entities.workflow_pause import WorkflowPauseEntity from services.app_generate_service import AppGenerateService from services.workflow_event_snapshot_service import _build_snapshot_events +from tests.unit_tests.config_override import apply_config_overrides class _DummyRateLimit: @@ -380,7 +381,7 @@ class TestHitlServiceApi: monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, ) -> None: - monkeypatch.setattr(ags_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) monkeypatch.setattr(ags_module, "RateLimit", _DummyRateLimit) workflow = MagicMock() diff --git a/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py index 0f47f0d6303..a602a3bdc10 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py @@ -10,7 +10,7 @@ from types import SimpleNamespace from unittest.mock import Mock import pytest -from flask import Flask +from flask import Flask, request from werkzeug.exceptions import NotFound from controllers.common.human_input import HumanInputFormSubmitPayload @@ -184,7 +184,8 @@ class TestWorkflowHumanInputFormApi: method="POST", json={"inputs": {"name": "Alice"}, "action": "approve", "user": "external-1"}, ): - response, status = handler(api, app_model=app_model, end_user=end_user, form_token="token-1") + payload = HumanInputFormSubmitPayload.model_validate(request.get_json() or {}) + response, status = handler(api, payload, app_model=app_model, end_user=end_user, form_token="token-1") assert response == {} assert status == 200 @@ -238,7 +239,8 @@ class TestWorkflowHumanInputFormApi: method="POST", json={"inputs": inputs, "action": "approve", "user": "external-1"}, ): - response, status = handler(api, app_model=app_model, end_user=end_user, form_token="token-1") + payload = HumanInputFormSubmitPayload.model_validate(request.get_json() or {}) + response, status = handler(api, payload, app_model=app_model, end_user=end_user, form_token="token-1") assert response == {} assert status == 200 @@ -294,7 +296,8 @@ class TestWorkflowHumanInputFormApi: method="POST", json={"inputs": {"name": "Alice"}, "action": "approve", "user": "external-1"}, ): + payload = HumanInputFormSubmitPayload.model_validate(request.get_json() or {}) with pytest.raises(NotFound): - handler(api, app_model=app_model, end_user=end_user, form_token="token-1") + handler(api, payload, app_model=app_model, end_user=end_user, form_token="token-1") service_mock.submit_form_by_token.assert_not_called() diff --git a/api/tests/unit_tests/controllers/service_api/app/test_message.py b/api/tests/unit_tests/controllers/service_api/app/test_message.py index 2920e6565ad..a1e1f699bbe 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_message.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_message.py @@ -21,7 +21,7 @@ from types import SimpleNamespace from unittest.mock import Mock, patch import pytest -from flask import Flask +from flask import Flask, request from sqlalchemy import Engine from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest, InternalServerError, NotFound @@ -476,8 +476,9 @@ class TestMessageFeedbackApi: method="POST", json={"rating": "like", "content": "ok"}, ): + payload = MessageFeedbackPayload.model_validate(request.get_json() or {}) with pytest.raises(NotFound): - handler(api, app_model=app_model, end_user=end_user, message_id="m1") + handler(api, payload, app_model=app_model, end_user=end_user, message_id="m1") class TestAppGetFeedbacksApi: diff --git a/api/tests/unit_tests/controllers/service_api/app/test_workflow.py b/api/tests/unit_tests/controllers/service_api/app/test_workflow.py index c1cf3539477..9dd436d8795 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_workflow.py @@ -16,6 +16,7 @@ Focus on: import json import sys import uuid +from collections.abc import Callable from datetime import UTC, datetime from inspect import unwrap from types import SimpleNamespace @@ -27,7 +28,11 @@ from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import BadRequest, NotFound -from controllers.service_api.app.error import NotWorkflowAppError, WorkflowVersionExecutionNotAllowedError +from controllers.service_api.app.error import ( + NotWorkflowAppError, + TriggerWorkflowServiceModeUnavailableError, + WorkflowVersionExecutionNotAllowedError, +) from controllers.service_api.app.workflow import ( AppQueueManager, GraphEngineManager, @@ -50,7 +55,13 @@ from models.model import App, AppMode, EndUser from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowRun, WorkflowType from services.app_generate_service import AppGenerateService from services.billing_service import BillingService -from services.errors.app import IsDraftWorkflowError, WorkflowNotFoundError +from services.errors.app import ( + IsDraftWorkflowError, + WorkflowNotFoundError, +) +from services.errors.app import ( + TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError, +) from services.errors.llm import InvokeRateLimitError from services.workflow_app_service import WorkflowAppService @@ -581,11 +592,41 @@ class TestWorkflowRunApi: with pytest.raises(InvokeRateLimitHttpError): handler(api, session=sqlite_session, app_model=app_model, end_user=end_user) - def test_sandbox_billing_does_not_gate_default_workflow_run( - self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session + def test_trigger_workflow_returns_stable_unavailable_error( + self, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, ) -> None: + monkeypatch.setattr( + AppGenerateService, + "generate", + Mock(side_effect=TriggerWorkflowServiceModeUnavailableServiceError()), + ) + api = WorkflowRunApi() + handler = unwrap(api.post) + + with app.test_request_context("/workflows/run", method="POST", json={"inputs": {}}): + with pytest.raises(TriggerWorkflowServiceModeUnavailableError) as exc_info: + handler( + api, + session=sqlite_session, + app_model=_make_app_model(), + end_user=_make_end_user(), + ) + + assert exc_info.value.code == 403 + assert exc_info.value.error_code == "trigger_workflow_service_mode_unavailable" + + def test_sandbox_billing_does_not_gate_default_workflow_run( + self, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + config_overrides: Callable[..., None], + ) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) workflow_module = sys.modules["controllers.service_api.app.workflow"] - monkeypatch.setattr(workflow_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) billing_get_info = Mock(return_value={"enabled": True, "subscription": {"plan": CloudPlan.SANDBOX}}) generate = Mock(return_value={"result": "ok"}) @@ -609,11 +650,42 @@ class TestWorkflowRunApi: class TestWorkflowRunByIdApi: - def test_rejects_sandbox_plan_with_upgrade_error( - self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session + def test_trigger_workflow_version_returns_stable_unavailable_error( + self, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, ) -> None: + monkeypatch.setattr( + AppGenerateService, + "generate", + Mock(side_effect=TriggerWorkflowServiceModeUnavailableServiceError()), + ) + api = WorkflowRunByIdApi() + handler = unwrap(api.post) + + with app.test_request_context("/workflows/w1/run", method="POST", json={"inputs": {}}): + with pytest.raises(TriggerWorkflowServiceModeUnavailableError) as exc_info: + handler( + api, + session=sqlite_session, + app_model=_make_app_model(), + end_user=_make_end_user(), + workflow_id=str(uuid.uuid4()), + ) + + assert exc_info.value.code == 403 + assert exc_info.value.error_code == "trigger_workflow_service_mode_unavailable" + + def test_rejects_sandbox_plan_with_upgrade_error( + self, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + config_overrides: Callable[..., None], + ) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) workflow_module = sys.modules["controllers.service_api.app.workflow"] - monkeypatch.setattr(workflow_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) billing_get_info = Mock(return_value={"enabled": True, "subscription": {"plan": CloudPlan.SANDBOX}}) generate = Mock() @@ -664,9 +736,9 @@ class TestWorkflowRunByIdApi: billing_enabled: bool, plan: CloudPlan, sqlite_session: Session, + config_overrides: Callable[..., None], ) -> None: - workflow_module = sys.modules["controllers.service_api.app.workflow"] - monkeypatch.setattr(workflow_module.dify_config, "DEPLOYMENT_EDITION", deployment_edition) + config_overrides(DEPLOYMENT_EDITION=deployment_edition) billing_get_info = Mock(return_value={"enabled": billing_enabled, "subscription": {"plan": plan}}) generate = Mock(return_value={"result": "ok"}) @@ -694,9 +766,15 @@ class TestWorkflowRunByIdApi: billing_get_info.assert_not_called() @pytest.mark.parametrize("sqlite_session", [()], indirect=True) - def test_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: + def test_not_found( + self, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + config_overrides: Callable[..., None], + ) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) workflow_module = sys.modules["controllers.service_api.app.workflow"] - monkeypatch.setattr(workflow_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) monkeypatch.setattr( AppGenerateService, "generate", @@ -713,9 +791,15 @@ class TestWorkflowRunByIdApi: handler(api, session=sqlite_session, app_model=app_model, end_user=end_user, workflow_id="w1") @pytest.mark.parametrize("sqlite_session", [()], indirect=True) - def test_draft_workflow(self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: + def test_draft_workflow( + self, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + config_overrides: Callable[..., None], + ) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) workflow_module = sys.modules["controllers.service_api.app.workflow"] - monkeypatch.setattr(workflow_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) monkeypatch.setattr( AppGenerateService, "generate", diff --git a/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py b/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py index df1c9ff00d2..4a87b4a8ff6 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py @@ -506,7 +506,10 @@ class TestDatasourceNodeRunApiPost: The source asserts ``isinstance(current_user, Account)`` and delegates to ``RagPipelineService`` and ``PipelineGenerator``, so we patch those plus - ``current_user`` and ``service_api_ns``. + ``current_user``. ``post`` is wrapped in ``@model_validate``, which parses + the JSON request body live, so payloads are supplied via + ``test_request_context(json=...)`` and validation runs before the dataset + ownership guard. """ @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.helper") @@ -516,10 +519,8 @@ class TestDatasourceNodeRunApiPost: new_callable=lambda: Account(name="Test Account", email="test@example.com"), ) @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.RagPipelineService") - @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.service_api_ns") def test_post_success( self, - mock_ns, mock_svc_cls, current_account, mock_gen, @@ -534,12 +535,6 @@ class TestDatasourceNodeRunApiPost: _persist_dataset(sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id) - mock_ns.payload = { - "inputs": {"url": "https://example.com"}, - "datasource_type": "online_document", - "is_published": True, - } - pipeline = _persist_pipeline(sqlite_session, tenant_id=tenant_id) mock_svc_instance = Mock() mock_svc_instance.get_pipeline.return_value = pipeline @@ -549,7 +544,15 @@ class TestDatasourceNodeRunApiPost: mock_gen.convert_to_event_stream.return_value = iter(["stream_event"]) mock_helper.compact_generate_response.return_value = {"result": "ok"} - with app.test_request_context("/datasets/test/pipeline/datasource/nodes/node_abc/run", method="POST"): + with app.test_request_context( + "/datasets/test/pipeline/datasource/nodes/node_abc/run", + method="POST", + json={ + "inputs": {"url": "https://example.com"}, + "datasource_type": "online_document", + "is_published": True, + }, + ): api = DatasourceNodeRunApi() response = api.post(tenant_id=tenant_id, dataset_id=dataset_id, node_id=node_id) @@ -561,7 +564,13 @@ class TestDatasourceNodeRunApiPost: def test_post_not_found(self, app: Flask, sqlite_session: Session): """Test NotFound when dataset check fails.""" - with app.test_request_context("/datasets/test/pipeline/datasource/nodes/n1/run", method="POST"): + # `@model_validate` parses the body before the ownership guard, so a + # valid payload is required to reach the NotFound branch. + with app.test_request_context( + "/datasets/test/pipeline/datasource/nodes/n1/run", + method="POST", + json={"inputs": {}, "datasource_type": "online_document", "is_published": True}, + ): api = DatasourceNodeRunApi() with pytest.raises(NotFound): api.post(tenant_id=str(uuid.uuid4()), dataset_id=str(uuid.uuid4()), node_id="n1") @@ -570,19 +579,17 @@ class TestDatasourceNodeRunApiPost: "controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.current_user", new="not_account", ) - @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.service_api_ns") - def test_post_fails_when_current_user_not_account(self, mock_ns, app: Flask, sqlite_session: Session): + def test_post_fails_when_current_user_not_account(self, app: Flask, sqlite_session: Session): """Test AssertionError when current_user is not an Account instance.""" tenant_id = str(uuid.uuid4()) dataset_id = str(uuid.uuid4()) _persist_dataset(sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id) - mock_ns.payload = { - "inputs": {}, - "datasource_type": "local_file", - "is_published": True, - } - with app.test_request_context("/datasets/test/pipeline/datasource/nodes/n1/run", method="POST"): + with app.test_request_context( + "/datasets/test/pipeline/datasource/nodes/n1/run", + method="POST", + json={"inputs": {}, "datasource_type": "local_file", "is_published": True}, + ): api = DatasourceNodeRunApi() with pytest.raises(AssertionError): api.post(tenant_id=tenant_id, dataset_id=dataset_id, node_id="n1") diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_apis.py b/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_apis.py index fc17d166994..ade48fd2c03 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_apis.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_apis.py @@ -10,7 +10,7 @@ from inspect import unwrap from unittest.mock import MagicMock, patch import pytest -from flask import Flask +from flask import Flask, request from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden, NotFound @@ -462,7 +462,7 @@ class TestDatasetApiPatch: dataset: Dataset, controller_session: Session, ) -> None: - from controllers.service_api.dataset.dataset import DatasetApi + from controllers.service_api.dataset.dataset import DatasetApi, DatasetUpdatePayload dataset.name = "Updated Dataset" mock_dataset_svc.get_dataset.return_value = dataset @@ -481,8 +481,12 @@ class TestDatasetApiPatch: json=payload, ): api = DatasetApi() + # `patch` is wrapped in @model_validate, so the unwrapped view expects + # the validated model where the decorator would have injected it. + validated_payload = DatasetUpdatePayload.model_validate(request.get_json() or {}) response, status = unwrap(api.patch)( api, + validated_payload, controller_session, _=dataset.tenant_id, dataset_id=dataset.id, diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_tag_apis.py b/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_tag_apis.py index 1f20b74180f..55aae9be652 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_tag_apis.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_tag_apis.py @@ -10,7 +10,7 @@ from inspect import unwrap from unittest.mock import MagicMock, patch import pytest -from flask import Flask +from flask import Flask, request from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden @@ -109,7 +109,7 @@ class TestDatasetTagsApiPost: tenant: Tenant, controller_session: Session, ) -> None: - from controllers.service_api.dataset.dataset import DatasetTagsApi + from controllers.service_api.dataset.dataset import DatasetTagsApi, TagCreatePayload tag = make_tag(controller_session, tenant, account, id="tag-new", name="New Tag") mock_tag_svc.save_tags.return_value = tag @@ -120,7 +120,8 @@ class TestDatasetTagsApiPost: json={"name": "New Tag"}, ): api = DatasetTagsApi() - response, status = unwrap(api.post)(api, controller_session, _=None) + payload = TagCreatePayload.model_validate(request.get_json() or {}) + response, status = unwrap(api.post)(api, payload, controller_session, _=None) assert status == 200 assert response == {"id": "tag-new", "name": "New Tag", "type": "knowledge", "binding_count": "0"} @@ -155,7 +156,7 @@ class TestDatasetTagsApiPatch: tenant: Tenant, controller_session: Session, ) -> None: - from controllers.service_api.dataset.dataset import DatasetTagsApi + from controllers.service_api.dataset.dataset import DatasetTagsApi, TagUpdatePayload tag = make_tag(controller_session, tenant, account, id="tag-1", name="Updated Tag") mock_tag_svc.update_tags.return_value = tag @@ -168,7 +169,8 @@ class TestDatasetTagsApiPatch: json={"name": "Updated Tag", "tag_id": "tag-1"}, ): api = DatasetTagsApi() - response, status = unwrap(api.patch)(api, controller_session, _=None) + payload = TagUpdatePayload.model_validate(request.get_json() or {}) + response, status = unwrap(api.patch)(api, payload, controller_session, _=None) assert status == 200 assert response == {"id": "tag-1", "name": "Updated Tag", "type": "knowledge", "binding_count": "5"} @@ -206,7 +208,7 @@ class TestDatasetTagsApiDelete: app: Flask, controller_session: Session, ) -> None: - from controllers.service_api.dataset.dataset import DatasetTagsApi + from controllers.service_api.dataset.dataset import DatasetTagsApi, TagDeletePayload mock_tag_svc.delete_tag.return_value = None mock_service_api_ns.payload = {"tag_id": "tag-1"} @@ -217,7 +219,8 @@ class TestDatasetTagsApiDelete: json={"tag_id": "tag-1"}, ): api = DatasetTagsApi() - result = unwrap(api.delete)(api, controller_session, _=None) + payload = TagDeletePayload.model_validate(request.get_json() or {}) + result = unwrap(api.delete)(api, payload, controller_session, _=None) assert result == ("", 204) mock_tag_svc.delete_tag.assert_called_once_with("tag-1", controller_session, tag_type=TagType.KNOWLEDGE) @@ -263,7 +266,7 @@ class TestDatasetTagBindingApiPost: app: Flask, controller_session: Session, ) -> None: - from controllers.service_api.dataset.dataset import DatasetTagBindingApi + from controllers.service_api.dataset.dataset import DatasetTagBindingApi, TagBindingPayload mock_tag_svc.save_tag_binding.return_value = None @@ -273,7 +276,8 @@ class TestDatasetTagBindingApiPost: json={"tag_ids": ["tag-1"], "target_id": "ds-1"}, ): api = DatasetTagBindingApi() - result = unwrap(api.post)(api, controller_session, _=None) + payload = TagBindingPayload.model_validate(request.get_json() or {}) + result = unwrap(api.post)(api, payload, controller_session, _=None) assert result == ("", 204) from services.tag_service import TagBindingCreatePayload @@ -309,7 +313,7 @@ class TestDatasetTagUnbindingApiPost: app: Flask, controller_session: Session, ) -> None: - from controllers.service_api.dataset.dataset import DatasetTagUnbindingApi + from controllers.service_api.dataset.dataset import DatasetTagUnbindingApi, TagUnbindingPayload mock_tag_svc.delete_tag_binding.return_value = None @@ -319,7 +323,8 @@ class TestDatasetTagUnbindingApiPost: json={"tag_ids": ["tag-1"], "target_id": "ds-1"}, ): api = DatasetTagUnbindingApi() - result = unwrap(api.post)(api, controller_session, _=None) + payload = TagUnbindingPayload.model_validate(request.get_json() or {}) + result = unwrap(api.post)(api, payload, controller_session, _=None) assert result == ("", 204) from services.tag_service import TagBindingDeletePayload @@ -337,7 +342,7 @@ class TestDatasetTagUnbindingApiPost: app: Flask, controller_session: Session, ) -> None: - from controllers.service_api.dataset.dataset import DatasetTagUnbindingApi + from controllers.service_api.dataset.dataset import DatasetTagUnbindingApi, TagUnbindingPayload mock_tag_svc.delete_tag_binding.return_value = None @@ -347,7 +352,8 @@ class TestDatasetTagUnbindingApiPost: json={"tag_id": "tag-1", "target_id": "ds-1"}, ): api = DatasetTagUnbindingApi() - result = unwrap(api.post)(api, controller_session, _=None) + payload = TagUnbindingPayload.model_validate(request.get_json() or {}) + result = unwrap(api.post)(api, payload, controller_session, _=None) assert result == ("", 204) from services.tag_service import TagBindingDeletePayload diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_metadata.py b/api/tests/unit_tests/controllers/service_api/dataset/test_metadata.py index 8d6c3536550..e138a5b3c77 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_metadata.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_metadata.py @@ -13,6 +13,8 @@ Decorator strategy: via ``functools.wraps`` → call the unwrapped method directly. - Methods without billing decorators → call directly; only patch ``db``, services, and ``current_user``. +- ``@model_validate`` injects the parsed payload as the first argument after + ``self``, so unwrapped calls must pass the validated model explicitly. """ import uuid @@ -20,10 +22,11 @@ from inspect import unwrap from unittest.mock import ANY, patch import pytest -from flask import Flask +from flask import Flask, request from sqlalchemy.orm import Session from werkzeug.exceptions import NotFound +from controllers.common.controller_schemas import MetadataUpdatePayload from controllers.service_api.dataset import metadata as metadata_module from controllers.service_api.dataset.metadata import ( DatasetMetadataBuiltInFieldActionServiceApi, @@ -35,6 +38,7 @@ from controllers.service_api.dataset.metadata import ( from models.account import Account, Tenant from models.dataset import Dataset from models.enums import PermissionEnum +from services.entities.knowledge_entities.knowledge_entities import MetadataArgs, MetadataOperationData from services.errors.metadata import MetadataResourceNotFoundError @@ -98,7 +102,10 @@ class TestDatasetMetadataCreatePost(_UsesSQLiteSession): @staticmethod def _call_post(api, session: Session, **kwargs): - return unwrap(api.post)(api, session, **kwargs) + # `post` is wrapped in @model_validate, so the unwrapped view expects the + # validated model where the decorator would have injected it. + metadata_args = MetadataArgs.model_validate(request.get_json() or {}) + return unwrap(api.post)(api, metadata_args, session, **kwargs) @patch("controllers.service_api.dataset.metadata.MetadataService") @patch("controllers.service_api.dataset.metadata.DatasetService") @@ -230,7 +237,8 @@ class TestDatasetMetadataServiceApiPatch(_UsesSQLiteSession): @staticmethod def _call_patch(api, session: Session, **kwargs): - return unwrap(api.patch)(api, session, **kwargs) + payload = MetadataUpdatePayload.model_validate(request.get_json() or {}) + return unwrap(api.patch)(api, payload, session, **kwargs) @patch("controllers.service_api.dataset.metadata.MetadataService") @patch("controllers.service_api.dataset.metadata.DatasetService") @@ -529,7 +537,8 @@ class TestDocumentMetadataEditPost(_UsesSQLiteSession): @staticmethod def _call_post(api, session: Session, **kwargs): - return unwrap(api.post)(api, session, **kwargs) + metadata_args = MetadataOperationData.model_validate(request.get_json() or {}) + return unwrap(api.post)(api, metadata_args, session, **kwargs) @patch("controllers.service_api.dataset.metadata.MetadataService") @patch("controllers.service_api.dataset.metadata.DatasetService") diff --git a/api/tests/unit_tests/controllers/service_api/test_wraps.py b/api/tests/unit_tests/controllers/service_api/test_wraps.py index 9b058fc889c..82c03952c8d 100644 --- a/api/tests/unit_tests/controllers/service_api/test_wraps.py +++ b/api/tests/unit_tests/controllers/service_api/test_wraps.py @@ -29,6 +29,7 @@ from models.account import TenantAccountRole from models.dataset import Dataset, RateLimitLog from models.enums import ApiTokenType from models.model import ApiToken, App, AppMode, IconType +from tests.unit_tests.config_override import config_overrides_context def _configure_current_app_mock(mock_current_app): @@ -346,7 +347,7 @@ class TestCloudEditionBillingResourceCheck: # Act with ( app.test_request_context("/", method="GET"), - patch("controllers.service_api.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD), ): result = add_segment() @@ -376,7 +377,7 @@ class TestCloudEditionBillingResourceCheck: with ( app.test_request_context("/", method="GET"), - patch("controllers.service_api.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD), pytest.raises(ServiceUnavailable) as exc_info, ): upload_document() @@ -406,7 +407,7 @@ class TestCloudEditionBillingResourceCheck: with ( app.test_request_context("/", method="GET"), - patch("controllers.service_api.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD), ): result = upload_document() diff --git a/api/tests/unit_tests/controllers/test_swagger.py b/api/tests/unit_tests/controllers/test_swagger.py index af2e3a1396f..6f9f4677bb4 100644 --- a/api/tests/unit_tests/controllers/test_swagger.py +++ b/api/tests/unit_tests/controllers/test_swagger.py @@ -626,12 +626,9 @@ def test_console_member_invite_documents_bad_request_response(): } -def test_console_billing_routes_document_error_responses(monkeypatch: pytest.MonkeyPatch): - from configs import dify_config +def test_console_billing_routes_document_error_responses(): from controllers.console import bp as console_bp - monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True) - app = Flask(__name__) app.config["TESTING"] = True app.config["RESTX_INCLUDE_ALL_MODELS"] = True @@ -684,12 +681,9 @@ def test_console_billing_routes_document_error_responses(monkeypatch: pytest.Mon assert compliance_response["required"] == ["url"] -def test_console_model_provider_checkout_route_is_deprecated(monkeypatch: pytest.MonkeyPatch): - from configs import dify_config +def test_console_model_provider_checkout_route_is_deprecated(): from controllers.console import bp as console_bp - monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True) - app = Flask(__name__) app.config["TESTING"] = True app.config["RESTX_INCLUDE_ALL_MODELS"] = True diff --git a/api/tests/unit_tests/controllers/web/test_audio.py b/api/tests/unit_tests/controllers/web/test_audio.py index abcc27a9d47..10dc7a3429f 100644 --- a/api/tests/unit_tests/controllers/web/test_audio.py +++ b/api/tests/unit_tests/controllers/web/test_audio.py @@ -146,24 +146,21 @@ class TestAudioApi: # --------------------------------------------------------------------------- class TestTextApi: @patch("controllers.web.audio.AudioService.transcript_tts", return_value="audio-bytes") - @patch("controllers.web.audio.web_ns") - def test_happy_path(self, mock_ns: MagicMock, mock_tts: MagicMock, app: Flask) -> None: - mock_ns.payload = {"text": "hello", "voice": "alloy"} - - with app.test_request_context("/text-to-audio", method="POST"): + def test_happy_path(self, mock_tts: MagicMock, app: Flask) -> None: + with app.test_request_context("/text-to-audio", method="POST", json={"text": "hello", "voice": "alloy"}): result = TextApi().post(_app_model(), _end_user()) assert result == "audio-bytes" mock_tts.assert_called_once() @patch("controllers.web.audio.AudioService.transcript_tts", return_value="audio-bytes") - @patch("controllers.web.audio.web_ns") - def test_happy_path_with_message_ref(self, mock_ns: MagicMock, mock_tts: MagicMock, app: Flask) -> None: + def test_happy_path_with_message_ref(self, mock_tts: MagicMock, app: Flask) -> None: message_id = "550e8400-e29b-41d4-a716-446655440000" - mock_ns.payload = {"text": "hello", "message_id": message_id} app_model = _app_model() - with app.test_request_context("/text-to-audio", method="POST"): + with app.test_request_context( + "/text-to-audio", method="POST", json={"text": "hello", "message_id": message_id} + ): result = TextApi().post(app_model, _end_user()) assert result == "audio-bytes" @@ -177,10 +174,7 @@ class TestTextApi: "controllers.web.audio.AudioService.transcript_tts", side_effect=InvokeError(description="invoke failed"), ) - @patch("controllers.web.audio.web_ns") - def test_invoke_error_mapped(self, mock_ns: MagicMock, mock_tts: MagicMock, app: Flask) -> None: - mock_ns.payload = {"text": "hello"} - - with app.test_request_context("/text-to-audio", method="POST"): + def test_invoke_error_mapped(self, mock_tts: MagicMock, app: Flask) -> None: + with app.test_request_context("/text-to-audio", method="POST", json={"text": "hello"}): with pytest.raises(CompletionRequestError): TextApi().post(_app_model(), _end_user()) diff --git a/api/tests/unit_tests/controllers/web/test_remote_files.py b/api/tests/unit_tests/controllers/web/test_remote_files.py index ae912489e14..68cb0e6b6bb 100644 --- a/api/tests/unit_tests/controllers/web/test_remote_files.py +++ b/api/tests/unit_tests/controllers/web/test_remote_files.py @@ -134,12 +134,10 @@ class TestRemoteFileUploadApi: @patch("controllers.web.remote_files.FileService") @patch("controllers.web.remote_files.helpers.guess_file_info_from_response") @patch("controllers.web.remote_files.remote_fetcher") - @patch("controllers.web.remote_files.web_ns") @patch("controllers.web.remote_files.db") def test_upload_success( self, mock_db: MagicMock, - mock_ns: MagicMock, mock_proxy: MagicMock, mock_guess: MagicMock, mock_file_svc_cls: MagicMock, @@ -148,7 +146,6 @@ class TestRemoteFileUploadApi: sqlite_engine: Engine, ) -> None: mock_db.engine = sqlite_engine - mock_ns.payload = {"url": "https://example.com/file.pdf"} head_resp = MagicMock() head_resp.status_code = 200 head_resp.content = b"pdf-content" @@ -164,7 +161,9 @@ class TestRemoteFileUploadApi: mock_file_svc_cls.return_value.upload_file.return_value = _upload_file() - with app.test_request_context("/remote-files/upload", method="POST"): + with app.test_request_context( + "/remote-files/upload", method="POST", json={"url": "https://example.com/file.pdf"} + ): result, status = RemoteFileUploadApi().post(_app_model(), _end_user()) assert status == 201 @@ -173,16 +172,13 @@ class TestRemoteFileUploadApi: @patch("controllers.web.remote_files.FileService.is_file_size_within_limit", return_value=False) @patch("controllers.web.remote_files.helpers.guess_file_info_from_response") @patch("controllers.web.remote_files.remote_fetcher") - @patch("controllers.web.remote_files.web_ns") def test_file_too_large( self, - mock_ns: MagicMock, mock_proxy: MagicMock, mock_guess: MagicMock, mock_size_check: MagicMock, app: Flask, ) -> None: - mock_ns.payload = {"url": "https://example.com/big.zip"} head_resp = MagicMock() head_resp.status_code = 200 mock_proxy.make_request.return_value = head_resp @@ -190,18 +186,18 @@ class TestRemoteFileUploadApi: filename="big.zip", extension="zip", mimetype="application/zip", size=999999999 ) - with app.test_request_context("/remote-files/upload", method="POST"): + with app.test_request_context( + "/remote-files/upload", method="POST", json={"url": "https://example.com/big.zip"} + ): with pytest.raises(FileTooLargeError): RemoteFileUploadApi().post(_app_model(), _end_user()) @patch("controllers.web.remote_files.remote_fetcher") - @patch("controllers.web.remote_files.web_ns") - def test_fetch_failure_raises(self, mock_ns: MagicMock, mock_proxy: MagicMock, app: Flask) -> None: + def test_fetch_failure_raises(self, mock_proxy: MagicMock, app: Flask) -> None: import httpx - mock_ns.payload = {"url": "https://example.com/bad"} mock_proxy.make_request.side_effect = httpx.RequestError("connection failed") - with app.test_request_context("/remote-files/upload", method="POST"): + with app.test_request_context("/remote-files/upload", method="POST", json={"url": "https://example.com/bad"}): with pytest.raises(RemoteFileUploadError): RemoteFileUploadApi().post(_app_model(), _end_user()) diff --git a/api/tests/unit_tests/controllers/web/test_web_forgot_password.py b/api/tests/unit_tests/controllers/web/test_web_forgot_password.py index 5532220f52f..f43bf1fbf0a 100644 --- a/api/tests/unit_tests/controllers/web/test_web_forgot_password.py +++ b/api/tests/unit_tests/controllers/web/test_web_forgot_password.py @@ -18,6 +18,7 @@ from enums import DeploymentEdition from models.account import Account from models.engine import db from services.entities.feature_entities import SystemFeatureModel +from tests.unit_tests.config_override import config_overrides_context @pytest.fixture @@ -39,7 +40,7 @@ def _patch_wraps(): ) with ( patch("controllers.console.wraps.db") as mock_db, - patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), ): yield diff --git a/api/tests/unit_tests/controllers/web/test_workflow.py b/api/tests/unit_tests/controllers/web/test_workflow.py index 2013b1e8db5..711976ed441 100644 --- a/api/tests/unit_tests/controllers/web/test_workflow.py +++ b/api/tests/unit_tests/controllers/web/test_workflow.py @@ -11,11 +11,15 @@ from controllers.web.error import ( NotWorkflowAppError, ProviderNotInitializeError, ProviderQuotaExceededError, + TriggerWorkflowServiceModeUnavailableError, ) from controllers.web.workflow import WorkflowRunApi, WorkflowTaskStopApi from core.errors.error import ProviderTokenNotInitError, QuotaExceededError from models.enums import EndUserType from models.model import App, AppMode, EndUser +from services.errors.app import ( + TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError, +) def _workflow_app() -> App: @@ -68,6 +72,26 @@ class TestWorkflowRunApi: with pytest.raises(ProviderNotInitializeError): WorkflowRunApi().post(_workflow_app(), _end_user()) + @patch( + "controllers.web.workflow.AppGenerateService.generate", + side_effect=TriggerWorkflowServiceModeUnavailableServiceError(), + ) + @patch("controllers.web.workflow.web_ns") + def test_trigger_workflow_returns_stable_unavailable_error( + self, + mock_ns: MagicMock, + mock_gen: MagicMock, + app: Flask, + ) -> None: + mock_ns.payload = {"inputs": {}} + + with app.test_request_context("/workflows/run", method="POST"): + with pytest.raises(TriggerWorkflowServiceModeUnavailableError) as exc_info: + WorkflowRunApi().post(_workflow_app(), _end_user()) + + assert exc_info.value.code == 403 + assert exc_info.value.error_code == "trigger_workflow_service_mode_unavailable" + @patch( "controllers.web.workflow.AppGenerateService.generate", side_effect=QuotaExceededError(), diff --git a/api/tests/unit_tests/core/app/app_config/common/test_parameters_mapping.py b/api/tests/unit_tests/core/app/app_config/common/test_parameters_mapping.py index 6dbf301f656..56a19e54149 100644 --- a/api/tests/unit_tests/core/app/app_config/common/test_parameters_mapping.py +++ b/api/tests/unit_tests/core/app/app_config/common/test_parameters_mapping.py @@ -1,26 +1,24 @@ -from unittest.mock import MagicMock - import pytest # Module under test from core.app.app_config.common import parameters_mapping +from tests.unit_tests.config_override import apply_config_overrides class TestGetParametersFromFeatureDict: """Test suite for get_parameters_from_feature_dict""" @pytest.fixture - def mock_config(self, monkeypatch: pytest.MonkeyPatch): - """Mock dify_config values""" - mock = MagicMock() - mock.UPLOAD_IMAGE_FILE_SIZE_LIMIT = 1 - mock.UPLOAD_VIDEO_FILE_SIZE_LIMIT = 2 - mock.UPLOAD_AUDIO_FILE_SIZE_LIMIT = 3 - mock.UPLOAD_FILE_SIZE_LIMIT = 4 - mock.WORKFLOW_FILE_UPLOAD_LIMIT = 5 - - monkeypatch.setattr(parameters_mapping, "dify_config", mock) - return mock + def mock_config(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Override file limits on the shared typed config.""" + apply_config_overrides( + monkeypatch, + UPLOAD_IMAGE_FILE_SIZE_LIMIT=1, + UPLOAD_VIDEO_FILE_SIZE_LIMIT=2, + UPLOAD_AUDIO_FILE_SIZE_LIMIT=3, + UPLOAD_FILE_SIZE_LIMIT=4, + WORKFLOW_FILE_UPLOAD_LIMIT=5, + ) @pytest.fixture def mock_default_file_limits(self, monkeypatch: pytest.MonkeyPatch): diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py index b5396e3acd5..b99e0d23394 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py @@ -1,12 +1,15 @@ from __future__ import annotations +import json import logging from contextlib import contextmanager +from decimal import Decimal from types import SimpleNamespace from unittest.mock import MagicMock import pytest from pydantic import BaseModel, ValidationError +from sqlalchemy import Engine, event from sqlalchemy.orm import Session from constants import UUID_NIL @@ -21,8 +24,88 @@ from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom from core.ops.ops_trace_manager import TraceQueueManager from libs.datetime_utils import naive_utc_now -from models.enums import MessageStatus -from models.model import AppMode +from models.account import Account +from models.enums import ConversationFromSource, EndUserType, MessageStatus +from models.model import App, AppMode, Conversation, EndUser, Message +from models.workflow import Workflow, WorkflowType +from tests.unit_tests.config_override import apply_config_overrides + + +def _make_app(*, app_id: str = "app", tenant_id: str = "tenant") -> App: + return App( + id=app_id, + tenant_id=tenant_id, + name="Advanced Chat App", + mode=AppMode.ADVANCED_CHAT, + enable_site=False, + enable_api=False, + ) + + +def _make_workflow( + *, + workflow_id: str = "workflow-id", + tenant_id: str = "tenant", + app_id: str = "app", + features: dict[str, object] | None = None, +) -> Workflow: + return Workflow( + id=workflow_id, + tenant_id=tenant_id, + app_id=app_id, + type=WorkflowType.CHAT, + version=Workflow.VERSION_DRAFT, + graph="{}", + features=json.dumps(features or {}), + created_by="user", + ) + + +def _make_account(*, account_id: str = "user-id") -> Account: + account = Account(name="Advanced Chat User", email=f"{account_id}@example.com") + account.id = account_id + return account + + +def _make_end_user(*, end_user_id: str = "end-user-id", session_id: str = "session-id") -> EndUser: + return EndUser( + id=end_user_id, + tenant_id="tenant", + app_id="app", + type=EndUserType.BROWSER, + session_id=session_id, + ) + + +def _make_conversation(*, conversation_id: str = "conversation-id", app_id: str = "app") -> Conversation: + return Conversation( + id=conversation_id, + app_id=app_id, + mode=AppMode.ADVANCED_CHAT, + name="Advanced Chat Conversation", + inputs={}, + from_source=ConversationFromSource.API, + ) + + +def _make_message( + *, message_id: str = "message-id", conversation_id: str = "conversation-id", app_id: str = "app" +) -> Message: + return Message( + id=message_id, + app_id=app_id, + conversation_id=conversation_id, + inputs={}, + query="hello", + message={}, + answer="", + status=MessageStatus.NORMAL, + message_unit_price=Decimal(0), + answer_unit_price=Decimal(0), + currency="USD", + from_source=ConversationFromSource.API, + created_at=naive_utc_now(), + ) class TestAdvancedChatAppGeneratorValidation: @@ -31,9 +114,9 @@ class TestAdvancedChatAppGeneratorValidation: with pytest.raises(ValueError, match="query is required"): generator.generate( - app_model=SimpleNamespace(), - workflow=SimpleNamespace(), - user=SimpleNamespace(), + app_model=_make_app(), + workflow=_make_workflow(), + user=_make_account(), args={"inputs": {}}, invoke_from=InvokeFrom.WEB_APP, workflow_run_id="run-id", @@ -46,9 +129,9 @@ class TestAdvancedChatAppGeneratorValidation: with pytest.raises(ValueError, match="query must be a string"): generator.generate( - app_model=SimpleNamespace(), - workflow=SimpleNamespace(), - user=SimpleNamespace(), + app_model=_make_app(), + workflow=_make_workflow(), + user=_make_account(), args={"inputs": {}, "query": 123}, invoke_from=InvokeFrom.WEB_APP, workflow_run_id="run-id", @@ -61,10 +144,10 @@ class TestAdvancedChatAppGeneratorValidation: with pytest.raises(ValueError, match="node_id is required"): generator.single_iteration_generate( - app_model=SimpleNamespace(), - workflow=SimpleNamespace(), + app_model=_make_app(), + workflow=_make_workflow(), node_id="", - user=SimpleNamespace(), + user=_make_account(), args={"inputs": {}}, streaming=False, session=unbound_session, @@ -72,10 +155,10 @@ class TestAdvancedChatAppGeneratorValidation: with pytest.raises(ValueError, match="inputs is required"): generator.single_iteration_generate( - app_model=SimpleNamespace(), - workflow=SimpleNamespace(), + app_model=_make_app(), + workflow=_make_workflow(), node_id="node", - user=SimpleNamespace(), + user=_make_account(), args={}, streaming=False, session=unbound_session, @@ -86,10 +169,10 @@ class TestAdvancedChatAppGeneratorValidation: with pytest.raises(ValueError, match="node_id is required"): generator.single_loop_generate( - app_model=SimpleNamespace(), - workflow=SimpleNamespace(), + app_model=_make_app(), + workflow=_make_workflow(), node_id="", - user=SimpleNamespace(), + user=_make_account(), args=SimpleNamespace(inputs={}), streaming=False, session=unbound_session, @@ -97,10 +180,10 @@ class TestAdvancedChatAppGeneratorValidation: with pytest.raises(ValueError, match="inputs is required"): generator.single_loop_generate( - app_model=SimpleNamespace(), - workflow=SimpleNamespace(), + app_model=_make_app(), + workflow=_make_workflow(), node_id="node", - user=SimpleNamespace(), + user=_make_account(), args=SimpleNamespace(inputs=None), streaming=False, session=unbound_session, @@ -119,11 +202,13 @@ class TestAdvancedChatAppGeneratorInternals: workflow_id="workflow-id", ) - def test_generate_loads_conversation_and_files(self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session): + def test_generate_loads_conversation_and_files( + self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session, sqlite_engine: Engine + ): generator = AdvancedChatAppGenerator() app_config = self._build_app_config() - conversation = SimpleNamespace(id="conversation-id") + conversation = _make_conversation() built_files: list[object] = [] build_files_called = {"called": False} captured: dict[str, object] = {} @@ -156,10 +241,7 @@ class TestAdvancedChatAppGeneratorInternals: ) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=lambda: SimpleNamespace(close=lambda: None)), - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.sessionmaker", lambda **kwargs: SimpleNamespace() + SimpleNamespace(engine=sqlite_engine, session=unbound_session), ) monkeypatch.setattr(generator, "_prepare_user_inputs", lambda **kwargs: kwargs["user_inputs"]) @@ -186,8 +268,8 @@ class TestAdvancedChatAppGeneratorInternals: user.id = "user-id" result = generator.generate( - app_model=SimpleNamespace(id="app", tenant_id="tenant"), - workflow=SimpleNamespace(features_dict={}), + app_model=_make_app(), + workflow=_make_workflow(), user=user, args={ "query": "hello", @@ -237,11 +319,11 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr(generator, "_generate", _fake_generate) result = generator.resume( - app_model=SimpleNamespace(id="app-id"), - workflow=SimpleNamespace(), - user=SimpleNamespace(id="end-user-id", session_id="session-id"), - conversation=SimpleNamespace(id="conversation-id"), - message=SimpleNamespace(id="message-id"), + app_model=_make_app(app_id="app-id"), + workflow=_make_workflow(), + user=_make_end_user(), + conversation=_make_conversation(), + message=_make_message(), session=unbound_session, application_generate_entity=application_generate_entity, workflow_execution_repository=SimpleNamespace(), @@ -256,7 +338,7 @@ class TestAdvancedChatAppGeneratorInternals: assert captured_graph_runtime_state is not None def test_single_iteration_generate_builds_debug_task( - self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session + self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session, sqlite_engine: Engine ): generator = AdvancedChatAppGenerator() app_config = self._build_app_config() @@ -264,7 +346,7 @@ class TestAdvancedChatAppGeneratorInternals: prefill_calls: list[object] = [] draft_sessions: list[object] = [] var_loader = SimpleNamespace(loader="draft") - workflow = SimpleNamespace(id="workflow-id") + workflow = _make_workflow() session = unbound_session monkeypatch.setattr( @@ -280,12 +362,9 @@ class TestAdvancedChatAppGeneratorInternals: lambda **kwargs: SimpleNamespace(repo="node"), ) monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.DraftVarLoader", lambda **kwargs: var_loader) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.sessionmaker", lambda **kwargs: SimpleNamespace() - ) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=lambda: SimpleNamespace()), + SimpleNamespace(engine=sqlite_engine, session=unbound_session), ) class _DraftVarService: @@ -304,10 +383,10 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr(generator, "_generate", _fake_generate) result = generator.single_iteration_generate( - app_model=SimpleNamespace(id="app", tenant_id="tenant"), + app_model=_make_app(), workflow=workflow, node_id="node-1", - user=SimpleNamespace(id="user-id"), + user=_make_account(), args={"inputs": {"foo": "bar"}, "trace_session_id": "session-1"}, streaming=False, session=session, @@ -321,14 +400,16 @@ class TestAdvancedChatAppGeneratorInternals: assert captured["application_generate_entity"].single_iteration_run.node_id == "node-1" assert captured["application_generate_entity"].extras["trace_session_id"] == "session-1" - def test_single_loop_generate_builds_debug_task(self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session): + def test_single_loop_generate_builds_debug_task( + self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session, sqlite_engine: Engine + ): generator = AdvancedChatAppGenerator() app_config = self._build_app_config() captured: dict[str, object] = {} prefill_calls: list[object] = [] draft_sessions: list[object] = [] var_loader = SimpleNamespace(loader="draft") - workflow = SimpleNamespace(id="workflow-id") + workflow = _make_workflow() session = unbound_session monkeypatch.setattr( @@ -344,12 +425,9 @@ class TestAdvancedChatAppGeneratorInternals: lambda **kwargs: SimpleNamespace(repo="node"), ) monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.DraftVarLoader", lambda **kwargs: var_loader) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.sessionmaker", lambda **kwargs: SimpleNamespace() - ) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=lambda: SimpleNamespace()), + SimpleNamespace(engine=sqlite_engine, session=unbound_session), ) class _DraftVarService: @@ -368,10 +446,10 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr(generator, "_generate", _fake_generate) result = generator.single_loop_generate( - app_model=SimpleNamespace(id="app", tenant_id="tenant"), + app_model=_make_app(), workflow=workflow, node_id="node-2", - user=SimpleNamespace(id="user-id"), + user=_make_account(), args=SimpleNamespace(inputs={"foo": "bar"}, trace_session_id="session-1"), streaming=False, session=session, @@ -385,7 +463,9 @@ class TestAdvancedChatAppGeneratorInternals: assert captured["application_generate_entity"].single_loop_run.node_id == "node-2" assert captured["application_generate_entity"].extras["trace_session_id"] == "session-1" - def test_generate_internal_flow_initial_conversation_with_pause_layer(self, monkeypatch: pytest.MonkeyPatch): + def test_generate_internal_flow_initial_conversation_with_pause_layer( + self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + ): generator = AdvancedChatAppGenerator() generator._dialogue_count = 0 app_config = self._build_app_config() @@ -404,16 +484,19 @@ class TestAdvancedChatAppGeneratorInternals: workflow_run_id="run-id", ) - workflow = SimpleNamespace(id="wf-1", tenant_id="tenant", features={"feature": True}, features_dict={}) - conversation = SimpleNamespace(id="conv-1", mode=AppMode.ADVANCED_CHAT, override_model_configs=None) - message = SimpleNamespace( - id="msg-1", - query="hello", - created_at=naive_utc_now(), - status=MessageStatus.NORMAL, - answer="", - ) - db_session = SimpleNamespace(commit=MagicMock(), refresh=MagicMock(), close=MagicMock()) + app = _make_app() + workflow = _make_workflow(workflow_id="wf-1", features={"feature": True}) + conversation = _make_conversation(conversation_id="conv-1") + message = _make_message(message_id="msg-1", conversation_id=conversation.id) + sqlite_session.add_all([app, workflow, conversation, message]) + sqlite_session.commit() + commit_count = 0 + + def _record_commit(session: Session) -> None: + nonlocal commit_count + commit_count += 1 + + event.listen(sqlite_session, "after_commit", _record_commit) captured: dict[str, object] = {} thread_data: dict[str, object] = {} init_records = MagicMock(return_value=(conversation, message)) @@ -454,7 +537,8 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.threading.Thread", _Thread) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.db", SimpleNamespace(engine=object(), session=db_session) + "core.app.apps.advanced_chat.app_generator.db", + SimpleNamespace(engine=sqlite_engine, session=sqlite_session), ) monkeypatch.setattr(generator, "_get_draft_var_saver_factory", lambda *args, **kwargs: "draft-factory") monkeypatch.setattr( @@ -471,10 +555,10 @@ class TestAdvancedChatAppGeneratorInternals: response = generator._generate( workflow=workflow, - user=SimpleNamespace(id="user"), + user=_make_account(account_id="user"), invoke_from=InvokeFrom.WEB_APP, application_generate_entity=application_generate_entity, - session=db_session, + session=sqlite_session, workflow_execution_repository=SimpleNamespace(), workflow_node_execution_repository=SimpleNamespace(), conversation=None, @@ -489,17 +573,18 @@ class TestAdvancedChatAppGeneratorInternals: assert thread_data["join_timeout"] == 300 assert "pause-layer" in thread_data["kwargs"]["graph_engine_layers"] assert generator._dialogue_count == 3 - assert init_records.call_args.kwargs["session"] is db_session - get_thread_messages_length.assert_called_once_with(conversation.id, session=db_session) - db_session.commit.assert_called_once() - db_session.refresh.assert_called_once_with(conversation) - db_session.close.assert_called_once() + assert init_records.call_args.kwargs["session"] is sqlite_session + get_thread_messages_length.assert_called_once_with(conversation.id, session=sqlite_session) + assert commit_count == 1 + assert json.loads(conversation.override_model_configs) == {"feature": True} assert captured["draft_var_saver_factory"] == "draft-factory" assert isinstance(captured["workflow"], WorkflowSnapshot) assert isinstance(captured["conversation"], ConversationSnapshot) assert isinstance(captured["message"], MessageSnapshot) - def test_generate_internal_flow_with_existing_records_skips_init(self, monkeypatch: pytest.MonkeyPatch): + def test_generate_internal_flow_with_existing_records_skips_init( + self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + ): generator = AdvancedChatAppGenerator() generator._dialogue_count = 0 app_config = self._build_app_config() @@ -518,16 +603,19 @@ class TestAdvancedChatAppGeneratorInternals: workflow_run_id="run-id", ) - workflow = SimpleNamespace(id="wf-2", tenant_id="tenant", features={}, features_dict={}) - conversation = SimpleNamespace(id="conv-2", mode=AppMode.ADVANCED_CHAT, override_model_configs=None) - message = SimpleNamespace( - id="msg-2", - query="hello", - created_at=naive_utc_now(), - status=MessageStatus.NORMAL, - answer="", - ) - db_session = SimpleNamespace(close=MagicMock(), commit=MagicMock(), refresh=MagicMock()) + app = _make_app() + workflow = _make_workflow(workflow_id="wf-2") + conversation = _make_conversation(conversation_id="conv-2") + message = _make_message(message_id="msg-2", conversation_id=conversation.id) + sqlite_session.add_all([app, workflow, conversation, message]) + sqlite_session.commit() + commit_count = 0 + + def _record_commit(session: Session) -> None: + nonlocal commit_count + commit_count += 1 + + event.listen(sqlite_session, "after_commit", _record_commit) init_records = MagicMock() get_thread_messages_length = MagicMock(return_value=0) thread_data: dict[str, object] = {} @@ -563,7 +651,8 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.threading.Thread", _Thread) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.db", SimpleNamespace(engine=object(), session=db_session) + "core.app.apps.advanced_chat.app_generator.db", + SimpleNamespace(engine=sqlite_engine, session=sqlite_session), ) monkeypatch.setattr(generator, "_get_draft_var_saver_factory", lambda *args, **kwargs: "draft-factory") monkeypatch.setattr( @@ -578,10 +667,10 @@ class TestAdvancedChatAppGeneratorInternals: response = generator._generate( workflow=workflow, - user=SimpleNamespace(id="user"), + user=_make_account(account_id="user"), invoke_from=InvokeFrom.WEB_APP, application_generate_entity=application_generate_entity, - session=db_session, + session=sqlite_session, workflow_execution_repository=SimpleNamespace(), workflow_node_execution_repository=SimpleNamespace(), conversation=conversation, @@ -591,15 +680,15 @@ class TestAdvancedChatAppGeneratorInternals: assert response == {"raw": True} init_records.assert_not_called() - get_thread_messages_length.assert_called_once_with(conversation.id, session=db_session) + get_thread_messages_length.assert_called_once_with(conversation.id, session=sqlite_session) assert thread_data["started"] is True assert thread_data["joined"] is True assert thread_data["join_timeout"] == 300 - db_session.commit.assert_not_called() - db_session.refresh.assert_not_called() - db_session.close.assert_called_once() + assert commit_count == 0 - def test_generate_worker_raises_when_workflow_not_found(self, monkeypatch: pytest.MonkeyPatch): + def test_generate_worker_raises_when_workflow_not_found( + self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + ): generator = AdvancedChatAppGenerator() generator._dialogue_count = 1 app_config = self._build_app_config() @@ -618,8 +707,8 @@ class TestAdvancedChatAppGeneratorInternals: workflow_run_id="run-id", ) - generator._get_conversation = MagicMock(return_value=SimpleNamespace(id="conv")) - generator._get_message = MagicMock(return_value=SimpleNamespace(id="msg")) + generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) + generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) @contextmanager def _fake_context(*args, **kwargs): @@ -627,20 +716,9 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.preserve_flask_contexts", _fake_context) - class _Session: - def __init__(self, *args, **kwargs): - self.scalar = MagicMock(return_value=None) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.Session", _Session) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=SimpleNamespace(close=lambda: None)), + SimpleNamespace(engine=sqlite_engine, session=sqlite_session), ) with pytest.raises(ValueError, match="Workflow not found"): @@ -658,7 +736,9 @@ class TestAdvancedChatAppGeneratorInternals: graph_runtime_state=None, ) - def test_generate_worker_raises_when_app_not_found_for_internal_call(self, monkeypatch: pytest.MonkeyPatch): + def test_generate_worker_raises_when_app_not_found_for_internal_call( + self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + ): generator = AdvancedChatAppGenerator() generator._dialogue_count = 1 app_config = self._build_app_config() @@ -677,8 +757,10 @@ class TestAdvancedChatAppGeneratorInternals: workflow_run_id="run-id", ) - generator._get_conversation = MagicMock(return_value=SimpleNamespace(id="conv")) - generator._get_message = MagicMock(return_value=SimpleNamespace(id="msg")) + generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) + generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) + sqlite_session.add(_make_workflow()) + sqlite_session.commit() @contextmanager def _fake_context(*args, **kwargs): @@ -686,25 +768,9 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.preserve_flask_contexts", _fake_context) - class _Session: - def __init__(self, *args, **kwargs): - self.scalar = MagicMock( - side_effect=[ - SimpleNamespace(id="workflow-id", tenant_id="tenant", app_id="app"), - None, - ] - ) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.Session", _Session) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=SimpleNamespace(close=lambda: None)), + SimpleNamespace(engine=sqlite_engine, session=sqlite_session), ) with pytest.raises(ValueError, match="App not found"): @@ -722,7 +788,9 @@ class TestAdvancedChatAppGeneratorInternals: graph_runtime_state=None, ) - def test_generate_worker_handles_stopped_error(self, monkeypatch: pytest.MonkeyPatch): + def test_generate_worker_handles_stopped_error( + self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + ): generator = AdvancedChatAppGenerator() generator._dialogue_count = 1 app_config = self._build_app_config() @@ -742,8 +810,8 @@ class TestAdvancedChatAppGeneratorInternals: ) queue_manager = MagicMock() - generator._get_conversation = MagicMock(return_value=SimpleNamespace(id="conv")) - generator._get_message = MagicMock(return_value=SimpleNamespace(id="msg")) + generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) + generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) @contextmanager def _fake_context(*args, **kwargs): @@ -751,22 +819,8 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.preserve_flask_contexts", _fake_context) - workflow = SimpleNamespace(id="workflow-id", tenant_id="tenant", app_id="app") - - class _Session: - def __init__(self, *args, **kwargs): - self.scalar = MagicMock( - side_effect=[ - workflow, - SimpleNamespace(id="app"), - ] - ) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False + sqlite_session.add_all([_make_app(), _make_workflow()]) + sqlite_session.commit() class _Runner: def __init__(self, **kwargs): @@ -775,13 +829,12 @@ class TestAdvancedChatAppGeneratorInternals: def run(self): raise GenerateTaskStoppedError() - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.Session", _Session) monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.AdvancedChatAppRunner", _Runner) restore_workflow_run_graph = MagicMock() monkeypatch.setattr(generator, "_restore_workflow_run_graph", restore_workflow_run_graph) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=SimpleNamespace(close=lambda: None)), + SimpleNamespace(engine=sqlite_engine, session=sqlite_session), ) generator._generate_worker( @@ -799,10 +852,12 @@ class TestAdvancedChatAppGeneratorInternals: ) queue_manager.publish_error.assert_not_called() - assert restore_workflow_run_graph.call_args.kwargs["workflow"] is workflow + assert restore_workflow_run_graph.call_args.kwargs["workflow"].id == "workflow-id" assert restore_workflow_run_graph.call_args.kwargs["workflow_run_id"] == "run-id" - def test_generate_worker_handles_validation_error(self, monkeypatch: pytest.MonkeyPatch): + def test_generate_worker_handles_validation_error( + self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + ): generator = AdvancedChatAppGenerator() generator._dialogue_count = 1 app_config = self._build_app_config() @@ -832,8 +887,10 @@ class TestAdvancedChatAppGeneratorInternals: raise AssertionError("validation error should be created") queue_manager = MagicMock() - generator._get_conversation = MagicMock(return_value=SimpleNamespace(id="conv")) - generator._get_message = MagicMock(return_value=SimpleNamespace(id="msg")) + generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) + generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) + sqlite_session.add_all([_make_app(), _make_workflow()]) + sqlite_session.commit() @contextmanager def _fake_context(*args, **kwargs): @@ -841,21 +898,6 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.preserve_flask_contexts", _fake_context) - class _Session: - def __init__(self, *args, **kwargs): - self.scalar = MagicMock( - side_effect=[ - SimpleNamespace(id="workflow-id", tenant_id="tenant", app_id="app"), - SimpleNamespace(id="app"), - ] - ) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - class _Runner: def __init__(self, **kwargs): _ = kwargs @@ -863,11 +905,10 @@ class TestAdvancedChatAppGeneratorInternals: def run(self): raise validation_error - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.Session", _Session) monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.AdvancedChatAppRunner", _Runner) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=SimpleNamespace(close=lambda: None)), + SimpleNamespace(engine=sqlite_engine, session=sqlite_session), ) generator._generate_worker( @@ -886,8 +927,12 @@ class TestAdvancedChatAppGeneratorInternals: queue_manager.publish_error.assert_called_once() - def test_generate_worker_handles_value_and_unknown_errors(self, monkeypatch: pytest.MonkeyPatch): + def test_generate_worker_handles_value_and_unknown_errors( + self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + ): app_config = self._build_app_config() + sqlite_session.add_all([_make_app(), _make_workflow()]) + sqlite_session.commit() @contextmanager def _fake_context(*args, **kwargs): @@ -921,34 +966,18 @@ class TestAdvancedChatAppGeneratorInternals: ) queue_manager = MagicMock() - generator._get_conversation = MagicMock(return_value=SimpleNamespace(id="conv")) - generator._get_message = MagicMock(return_value=SimpleNamespace(id="msg")) - - class _Session: - def __init__(self, *args, **kwargs): - self.scalar = MagicMock( - side_effect=[ - SimpleNamespace(id="workflow-id", tenant_id="tenant", app_id="app"), - SimpleNamespace(id="app"), - ] - ) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False + generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) + generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.preserve_flask_contexts", _fake_context) - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.Session", _Session) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.AdvancedChatAppRunner", _make_runner(raised_error), ) - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.dify_config", SimpleNamespace(DEBUG=True)) + apply_config_overrides(monkeypatch, DEBUG=True) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=SimpleNamespace(close=lambda: None)), + SimpleNamespace(engine=sqlite_engine, session=sqlite_session), ) generator._generate_worker( @@ -1018,7 +1047,7 @@ class TestAdvancedChatAppGeneratorInternals: status=MessageStatus.NORMAL, answer="", ), - user=SimpleNamespace(), + user=_make_account(), draft_var_saver_factory=lambda **kwargs: None, stream=False, ) @@ -1066,14 +1095,16 @@ class TestAdvancedChatAppGeneratorInternals: status=MessageStatus.NORMAL, answer="", ), - user=SimpleNamespace(), + user=_make_account(), draft_var_saver_factory=lambda **kwargs: None, stream=False, ) assert "Failed to process generate task pipeline, conversation_id: conv" in caplog.messages - def test_generate_worker_handles_invoke_auth_error(self, monkeypatch: pytest.MonkeyPatch): + def test_generate_worker_handles_invoke_auth_error( + self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + ): generator = AdvancedChatAppGenerator() generator._dialogue_count = 1 @@ -1101,8 +1132,10 @@ class TestAdvancedChatAppGeneratorInternals: queue_manager = MagicMock() - generator._get_conversation = MagicMock(return_value=SimpleNamespace(id="conv", mode=AppMode.ADVANCED_CHAT)) - generator._get_message = MagicMock(return_value=SimpleNamespace(id="msg")) + generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) + generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) + sqlite_session.add_all([_make_app(), _make_workflow(), _make_end_user()]) + sqlite_session.commit() class _Runner: def __init__(self, **kwargs) -> None: @@ -1121,26 +1154,9 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.preserve_flask_contexts", _fake_context) - class _Session: - def __init__(self, *args, **kwargs): - self.scalar = MagicMock( - side_effect=[ - SimpleNamespace(id="workflow-id", tenant_id="tenant", app_id="app"), - SimpleNamespace(id="end-user-id", session_id="session-id"), - SimpleNamespace(id="app"), - ] - ) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.Session", _Session) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=SimpleNamespace(close=lambda: None)), + SimpleNamespace(engine=sqlite_engine, session=sqlite_session), ) generator._generate_worker( @@ -1159,88 +1175,8 @@ class TestAdvancedChatAppGeneratorInternals: assert queue_manager.publish_error.called - def test_generate_debugger_enables_retrieve_source(self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session): - generator = AdvancedChatAppGenerator() - - app_config = WorkflowUIBasedAppConfig( - tenant_id="tenant", - app_id="app", - app_mode=AppMode.ADVANCED_CHAT, - additional_features=AppAdditionalFeatures(), - variables=[], - workflow_id="workflow-id", - ) - - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.AdvancedChatAppConfigManager.get_app_config", - lambda app_model, workflow: app_config, - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.FileUploadConfigManager.convert", - lambda features_dict, is_vision=False: None, - ) - DummyTraceQueueManager = type( - "_DummyTraceQueueManager", - (TraceQueueManager,), - { - "__init__": lambda self, app_id=None, user_id=None: ( - setattr(self, "app_id", app_id) or setattr(self, "user_id", user_id) - ) - }, - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.TraceQueueManager", - DummyTraceQueueManager, - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository", - lambda **kwargs: SimpleNamespace(), - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", - lambda **kwargs: SimpleNamespace(), - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=SimpleNamespace(close=lambda: None)), - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.sessionmaker", - lambda **kwargs: SimpleNamespace(), - ) - - captured = {} - - def _fake_generate(**kwargs): - captured.update(kwargs) - return {"ok": True} - - monkeypatch.setattr(generator, "_generate", _fake_generate) - - app_model = SimpleNamespace(id="app", tenant_id="tenant") - workflow = SimpleNamespace(features_dict={}) - from models import Account - - user = Account(name="Tester", email="tester@example.com") - user.id = "user" - - result = generator.generate( - app_model=app_model, - workflow=workflow, - user=user, - args={"query": "hello\x00", "inputs": {}}, - invoke_from=InvokeFrom.DEBUGGER, - workflow_run_id="run-id", - streaming=False, - session=unbound_session, - ) - - assert result == {"ok": True} - assert app_config.additional_features.show_retrieve_source is True - assert captured["application_generate_entity"].query == "hello" - - def test_generate_service_api_sets_parent_message_id( - self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session + def test_generate_debugger_enables_retrieve_source( + self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session, sqlite_engine: Engine ): generator = AdvancedChatAppGenerator() @@ -1284,11 +1220,7 @@ class TestAdvancedChatAppGeneratorInternals: ) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=SimpleNamespace(close=lambda: None)), - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.sessionmaker", - lambda **kwargs: SimpleNamespace(), + SimpleNamespace(engine=sqlite_engine, session=unbound_session), ) captured = {} @@ -1299,12 +1231,84 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr(generator, "_generate", _fake_generate) - app_model = SimpleNamespace(id="app", tenant_id="tenant") - workflow = SimpleNamespace(features_dict={}) - from models.model import EndUser + app_model = _make_app() + workflow = _make_workflow() + user = _make_account(account_id="user") - user = EndUser(tenant_id="tenant", type="session", name="tester", session_id="session") - user.id = "end-user" + result = generator.generate( + app_model=app_model, + workflow=workflow, + user=user, + args={"query": "hello\x00", "inputs": {}}, + invoke_from=InvokeFrom.DEBUGGER, + workflow_run_id="run-id", + streaming=False, + session=unbound_session, + ) + + assert result == {"ok": True} + assert app_config.additional_features.show_retrieve_source is True + assert captured["application_generate_entity"].query == "hello" + + def test_generate_service_api_sets_parent_message_id( + self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session, sqlite_engine: Engine + ): + generator = AdvancedChatAppGenerator() + + app_config = WorkflowUIBasedAppConfig( + tenant_id="tenant", + app_id="app", + app_mode=AppMode.ADVANCED_CHAT, + additional_features=AppAdditionalFeatures(), + variables=[], + workflow_id="workflow-id", + ) + + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.AdvancedChatAppConfigManager.get_app_config", + lambda app_model, workflow: app_config, + ) + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.FileUploadConfigManager.convert", + lambda features_dict, is_vision=False: None, + ) + DummyTraceQueueManager = type( + "_DummyTraceQueueManager", + (TraceQueueManager,), + { + "__init__": lambda self, app_id=None, user_id=None: ( + setattr(self, "app_id", app_id) or setattr(self, "user_id", user_id) + ) + }, + ) + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.TraceQueueManager", + DummyTraceQueueManager, + ) + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository", + lambda **kwargs: SimpleNamespace(), + ) + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", + lambda **kwargs: SimpleNamespace(), + ) + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.db", + SimpleNamespace(engine=sqlite_engine, session=unbound_session), + ) + + captured = {} + + def _fake_generate(**kwargs): + captured.update(kwargs) + return {"ok": True} + + monkeypatch.setattr(generator, "_generate", _fake_generate) + + app_model = _make_app() + workflow = _make_workflow() + user = _make_end_user(end_user_id="end-user", session_id="session") generator.generate( app_model=app_model, @@ -1375,11 +1379,11 @@ class TestAdvancedChatAppGeneratorResume: monkeypatch.setattr(generator, "_generate", _fake_generate) result = generator.resume( - app_model=SimpleNamespace(id="app-id"), - workflow=SimpleNamespace(), - user=SimpleNamespace(id="end-user-id", session_id="session-id"), - conversation=SimpleNamespace(id="conversation-id"), - message=SimpleNamespace(id="message-id"), + app_model=_make_app(app_id="app-id"), + workflow=_make_workflow(), + user=_make_end_user(), + conversation=_make_conversation(), + message=_make_message(), session=unbound_session, application_generate_entity=application_generate_entity, workflow_execution_repository=SimpleNamespace(), @@ -1423,11 +1427,11 @@ class TestAdvancedChatAppGeneratorResume: monkeypatch.setattr(generator, "_generate", _fake_generate) result = generator.resume( - app_model=SimpleNamespace(id="app-id"), - workflow=SimpleNamespace(), - user=SimpleNamespace(id="end-user-id", session_id="session-id"), - conversation=SimpleNamespace(id="conversation-id"), - message=SimpleNamespace(id="message-id"), + app_model=_make_app(app_id="app-id"), + workflow=_make_workflow(), + user=_make_end_user(), + conversation=_make_conversation(), + message=_make_message(), session=unbound_session, application_generate_entity=application_generate_entity, workflow_execution_repository=SimpleNamespace(), diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py index ca0e989d9d1..f4a0c4e90f3 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py @@ -21,6 +21,7 @@ from core.app.apps.agent_app.app_generator import ( AgentAppGenerator, AgentAppGeneratorError, ) +from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.app.entities.queue_entities import QueueAnnotationReplyEvent @@ -427,6 +428,23 @@ class TestGenerateWorker: self._call(generator, mocker, queue_manager) assert queue_manager.publish_error.called + def test_session_configuration_change_is_published_without_unknown_error_log( + self, + generator: AgentAppGenerator, + mocker: MockerFixture, + ) -> None: + error = AgentSessionSnapshotIncompatibleError() + self._wire(generator, mocker, run_side_effect=error) + queue_manager = mocker.MagicMock() + info_log = mocker.patch(f"{MODULE}.logger.info") + exception_log = mocker.patch(f"{MODULE}.logger.exception") + + self._call(generator, mocker, queue_manager) + + queue_manager.publish_error.assert_called_once_with(error, module.PublishFrom.APPLICATION_MANAGER) + info_log.assert_called_once() + exception_log.assert_not_called() + class TestResumeAfterFormSubmission: """ENG-638: a resume turn re-sends the paused turn's original query so the diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py index a9c27c61d47..1099a252dea 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py @@ -12,7 +12,8 @@ from typing import Any, override from unittest.mock import MagicMock import pytest -from agenton.compositor import CompositorSessionSnapshot +from agenton.compositor import CompositorSessionSnapshot, LayerSessionSnapshot +from agenton.layers import LifecycleState from dify_agent.layers.ask_human import AskHumanToolResult from dify_agent.protocol import ( AgentRunUsage, @@ -52,7 +53,8 @@ from clients.agent_backend import ( ) from core.app.apps.agent_app import app_runner as app_runner_module from core.app.apps.agent_app.app_runner import AgentAppRunner -from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder +from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError +from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeBuildContext, AgentAppRuntimeRequestBuilder from core.app.apps.agent_app.session_store import AgentAppSessionScope, StoredAgentAppSession from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom @@ -628,6 +630,34 @@ def _dify_ctx() -> DifyRunContext: ) +def _compatible_session_snapshot() -> CompositorSessionSnapshot: + request = ( + AgentAppRuntimeRequestBuilder( + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + .build( + AgentAppRuntimeBuildContext( + dify_context=_dify_ctx(), + agent_id="agent-1", + agent_config_snapshot_id="snap-1", + agent_soul=_soul(), + conversation_id="conv-1", + user_query="hello", + idempotency_key="msg-1", + binding_id="binding-1", + backend_binding_ref="backend-binding-1", + ) + ) + .request + ) + return CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot(name=layer.name, lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}) + for layer in request.composition.layers + ] + ) + + def _runner( client: FakeAgentBackendRunClient, store: _FakeSessionStore, @@ -1314,7 +1344,7 @@ def test_repeated_tool_calls_with_placeholder_call_id_and_reused_index_create_di def test_prior_session_snapshot_is_threaded_into_request() -> None: - prior = CompositorSessionSnapshot(layers=[]) + prior = _compatible_session_snapshot() client = FakeAgentBackendRunClient() store = _FakeSessionStore(loaded=prior) qm = _FakeQueueManager() @@ -1325,8 +1355,23 @@ def test_prior_session_snapshot_is_threaded_into_request() -> None: assert client.request.session_snapshot is prior +def test_incompatible_session_snapshot_is_rejected_before_backend_invocation() -> None: + compatible = _compatible_session_snapshot() + stale = CompositorSessionSnapshot( + layers=[layer for layer in compatible.layers if layer.name != "agent_soul_prompt"] + ) + client = FakeAgentBackendRunClient() + store = _FakeSessionStore(loaded=stale) + + with pytest.raises(AgentSessionSnapshotIncompatibleError, match="Start a new conversation"): + _run(_runner(client, store), _FakeQueueManager()) + + assert client.request is None + assert store.saved == [] + + def test_debug_session_scope_can_reuse_conversation_across_config_snapshots() -> None: - prior = CompositorSessionSnapshot(layers=[]) + prior = _compatible_session_snapshot() client = FakeAgentBackendRunClient() store = _FakeSessionStore(loaded=prior) qm = _FakeQueueManager() @@ -1599,7 +1644,7 @@ def test_ask_human_pauses_turn_creates_form_and_persists_correlation() -> None: def test_submitted_form_resumes_turn_with_deferred_tool_results(monkeypatch: pytest.MonkeyPatch) -> None: # ENG-638: a turn that runs while a pending form is answered threads the # human's reply into the request as deferred_tool_results. - snapshot = CompositorSessionSnapshot(layers=[]) + snapshot = _compatible_session_snapshot() stored = StoredAgentAppSession( scope=AgentAppSessionScope( tenant_id="tenant-1", diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py index f59e07195a7..9608379120a 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py @@ -6,6 +6,8 @@ from __future__ import annotations from types import SimpleNamespace import pytest +from agenton.compositor import CompositorSessionSnapshot, LayerSessionSnapshot +from agenton.layers import LifecycleState from dify_agent.layers.config import DifyConfigSkillConfig from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolsLayerConfig from dify_agent.layers.dify_plugin import DifyPluginToolConfig, DifyPluginToolsLayerConfig @@ -20,6 +22,7 @@ from clients.agent_backend import ( AgentBackendRunRequestBuilder, ) from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID +from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError from core.app.apps.agent_app.runtime_request_builder import ( AgentAppRuntimeBuildContext, AgentAppRuntimeRequestBuilder, @@ -27,6 +30,7 @@ from core.app.apps.agent_app.runtime_request_builder import ( ) from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from models.agent_config_entities import AgentSoulConfig +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture(autouse=True) @@ -163,6 +167,7 @@ def _ctx( *, query: str = "hello", agent_config_version_kind: str = "snapshot", + session_snapshot: CompositorSessionSnapshot | None = None, ) -> AgentAppRuntimeBuildContext: dify_context = SimpleNamespace( tenant_id="tenant-1", @@ -183,6 +188,7 @@ def _ctx( binding_id="binding-1", backend_binding_ref="binding-ref-1", agent_config_version_kind=agent_config_version_kind, # type: ignore[arg-type] + session_snapshot=session_snapshot, ) @@ -199,6 +205,15 @@ def _soul_with_model() -> AgentSoulConfig: ) +def _snapshot_for_layer_names(layer_names: list[str]) -> CompositorSessionSnapshot: + return CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot(name=name, lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}) + for name in layer_names + ] + ) + + class TestAgentAppRuntimeRequestBuilder: def test_build_maps_soul_to_run_request(self, model_context_window_calls: list[tuple[object, str, str]]): builder = AgentAppRuntimeRequestBuilder( @@ -238,6 +253,57 @@ class TestAgentAppRuntimeRequestBuilder: assert "credentials" not in result.redacted_request["composition"]["layers"][-1]["config"] assert result.metadata["conversation_id"] == "conv-1" + @pytest.mark.parametrize( + ("previous_prompt", "current_prompt"), + [("", "You are Iris."), ("You are Iris.", "")], + ) + def test_build_rejects_session_snapshot_after_layer_topology_changes( + self, + previous_prompt: str, + current_prompt: str, + ) -> None: + builder = AgentAppRuntimeRequestBuilder( + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + previous_soul = _soul_with_model() + previous_soul.prompt.system_prompt = previous_prompt + previous_request = builder.build(_ctx(previous_soul, agent_config_version_kind="draft")).request + snapshot = _snapshot_for_layer_names([layer.name for layer in previous_request.composition.layers]) + current_soul = _soul_with_model() + current_soul.prompt.system_prompt = current_prompt + + with pytest.raises(AgentSessionSnapshotIncompatibleError) as exc_info: + builder.build( + _ctx( + current_soul, + agent_config_version_kind="draft", + session_snapshot=snapshot, + ) + ) + + assert exc_info.value.error_code == "agent_session_configuration_changed" + assert exc_info.value.status_code == 409 + assert "Start a new conversation" in str(exc_info.value) + + def test_build_reuses_session_snapshot_when_config_changes_without_changing_layers(self) -> None: + builder = AgentAppRuntimeRequestBuilder( + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + previous_request = builder.build(_ctx(_soul_with_model(), agent_config_version_kind="draft")).request + snapshot = _snapshot_for_layer_names([layer.name for layer in previous_request.composition.layers]) + current_soul = _soul_with_model() + current_soul.prompt.system_prompt = "You are Ada." + + result = builder.build( + _ctx( + current_soul, + agent_config_version_kind="draft", + session_snapshot=snapshot, + ) + ) + + assert result.request.session_snapshot is snapshot + def test_build_wraps_agent_soul_prompt_for_build_draft(self): builder = AgentAppRuntimeRequestBuilder( dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] @@ -405,7 +471,7 @@ class TestAgentAppRuntimeRequestBuilder: assert exc.value.error_code == "agent_model_not_configured" def test_build_maps_agent_soul_shell_settings_to_shell_layer(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr("core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True) + apply_config_overrides(monkeypatch, AGENT_SHELL_ENABLED=True) soul = AgentSoulConfig.model_validate( { "model": { @@ -484,7 +550,7 @@ class TestAgentAppConfigLayer: assert names.index(DIFY_CONFIG_LAYER_ID) == names.index(DIFY_SHELL_LAYER_ID) + 1 def test_config_layer_present_when_agent_soul_has_no_config_assets(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr("core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True) + apply_config_overrides(monkeypatch, AGENT_SHELL_ENABLED=True) builder = AgentAppRuntimeRequestBuilder( dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] ) diff --git a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py index 29bfe2b4bb3..b64cb1c645a 100644 --- a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py @@ -11,6 +11,7 @@ from core.app.apps.agent_chat.app_generator import AgentChatAppGenerator from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom from graphon.model_runtime.errors.invoke import InvokeAuthorizationError +from tests.unit_tests.config_override import apply_config_overrides class DummyAccount: @@ -328,6 +329,7 @@ class TestAgentChatAppGeneratorWorker: self, generator, mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, sqlite_session_factory: sessionmaker[Session], ): @@ -343,7 +345,7 @@ class TestAgentChatAppGeneratorWorker: side_effect=sqlite_session_factory, ) - mocker.patch("core.app.apps.agent_chat.app_generator.dify_config", new=mocker.MagicMock(DEBUG=True)) + apply_config_overrides(monkeypatch, DEBUG=True) with caplog.at_level(logging.ERROR, logger="core.app.apps.agent_chat.app_generator"): generator._generate_worker( diff --git a/api/tests/unit_tests/core/app/apps/chat/test_base_app_runner_multimodal.py b/api/tests/unit_tests/core/app/apps/chat/test_base_app_runner_multimodal.py index 6d90aa7e53b..c41c33487eb 100644 --- a/api/tests/unit_tests/core/app/apps/chat/test_base_app_runner_multimodal.py +++ b/api/tests/unit_tests/core/app/apps/chat/test_base_app_runner_multimodal.py @@ -4,12 +4,16 @@ from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest +from sqlalchemy import func, select +from sqlalchemy.orm import Session from core.app.apps.base_app_runner import AppRunner from core.app.entities.app_invoke_entities import InvokeFrom from graphon.file import FileTransferMethod, FileType from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent from models.enums import CreatorUserRole +from models.model import MessageFile +from models.tools import ToolFile class TestBaseAppRunnerMultimodal: @@ -38,18 +42,18 @@ class TestBaseAppRunnerMultimodal: return manager @pytest.fixture - def mock_tool_file(self): - """Create a mock tool file.""" - tool_file = MagicMock() - tool_file.id = str(uuid4()) - return tool_file - - @pytest.fixture - def mock_message_file(self): - """Create a mock message file.""" - message_file = MagicMock() - message_file.id = str(uuid4()) - return message_file + def tool_file(self, mock_user_id: str, mock_tenant_id: str) -> ToolFile: + """Create a real transient tool-file model returned by the external file manager.""" + return ToolFile( + user_id=mock_user_id, + tenant_id=mock_tenant_id, + conversation_id=None, + file_key="generated/image.png", + mimetype="image/png", + original_url="http://example.com/image.png", + name="image.png", + size=68, + ) def test_handle_multimodal_image_content_with_url( self, @@ -57,8 +61,8 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, - mock_tool_file, - mock_message_file, + tool_file, + sqlite_session: Session, ): """Test handling image from URL.""" # Arrange @@ -72,48 +76,33 @@ class TestBaseAppRunnerMultimodal: with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: # Setup mock tool file manager mock_mgr = MagicMock() - mock_mgr.create_file_by_url.return_value = mock_tool_file + mock_mgr.create_file_by_url.return_value = tool_file mock_mgr_class.return_value = mock_mgr - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - # Setup mock message file - mock_msg_file_class.return_value = mock_message_file + message_file_id = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - file_session = MagicMock() - # Act - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) - - message_file_id = runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - # Assert - mock_mgr.create_file_by_url.assert_called_once_with( - user_id=mock_user_id, - tenant_id=mock_tenant_id, - file_url=image_url, - conversation_id=None, - ) - - mock_msg_file_class.assert_called_once() - call_kwargs = mock_msg_file_class.call_args[1] - assert call_kwargs["message_id"] == mock_message_id - assert call_kwargs["type"] == FileType.IMAGE - assert call_kwargs["transfer_method"] == FileTransferMethod.TOOL_FILE - assert call_kwargs["belongs_to"] == "assistant" - assert call_kwargs["created_by"] == mock_user_id - - file_session.add.assert_called_once_with(mock_message_file) - file_session.flush.assert_called_once() - assert message_file_id == mock_message_file.id - mock_queue_manager.publish.assert_not_called() + mock_mgr.create_file_by_url.assert_called_once_with( + user_id=mock_user_id, + tenant_id=mock_tenant_id, + file_url=image_url, + conversation_id=None, + ) + message_file = sqlite_session.get(MessageFile, message_file_id) + assert message_file is not None + assert message_file.message_id == mock_message_id + assert message_file.type == FileType.IMAGE + assert message_file.transfer_method == FileTransferMethod.TOOL_FILE + assert message_file.belongs_to == "assistant" + assert message_file.created_by == mock_user_id + assert message_file.upload_file_id == tool_file.id + mock_queue_manager.publish.assert_not_called() def test_handle_multimodal_image_content_with_base64( self, @@ -121,8 +110,8 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, - mock_tool_file, - mock_message_file, + tool_file, + sqlite_session: Session, ): """Test handling image from base64 data.""" # Arrange @@ -141,41 +130,29 @@ class TestBaseAppRunnerMultimodal: with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: # Setup mock tool file manager mock_mgr = MagicMock() - mock_mgr.create_file_by_raw.return_value = mock_tool_file + mock_mgr.create_file_by_raw.return_value = tool_file mock_mgr_class.return_value = mock_mgr - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - mock_msg_file_class.return_value = mock_message_file + message_file_id = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - file_session = MagicMock() - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) - - message_file_id = runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - mock_mgr.create_file_by_raw.assert_called_once() - call_kwargs = mock_mgr.create_file_by_raw.call_args[1] - assert call_kwargs["user_id"] == mock_user_id - assert call_kwargs["tenant_id"] == mock_tenant_id - assert call_kwargs["conversation_id"] is None - assert "file_binary" in call_kwargs - assert call_kwargs["mimetype"] == "image/png" - assert call_kwargs["filename"].startswith("generated_image") - assert call_kwargs["filename"].endswith(".png") - - mock_msg_file_class.assert_called_once() - file_session.add.assert_called_once() - file_session.flush.assert_called_once() - assert message_file_id == mock_message_file.id - mock_queue_manager.publish.assert_not_called() + mock_mgr.create_file_by_raw.assert_called_once() + call_kwargs = mock_mgr.create_file_by_raw.call_args[1] + assert call_kwargs["user_id"] == mock_user_id + assert call_kwargs["tenant_id"] == mock_tenant_id + assert call_kwargs["conversation_id"] is None + assert "file_binary" in call_kwargs + assert call_kwargs["mimetype"] == "image/png" + assert call_kwargs["filename"].startswith("generated_image") + assert call_kwargs["filename"].endswith(".png") + assert sqlite_session.get(MessageFile, message_file_id) is not None + mock_queue_manager.publish.assert_not_called() def test_handle_multimodal_image_content_with_base64_data_uri( self, @@ -183,8 +160,8 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, - mock_tool_file, - mock_message_file, + tool_file, + sqlite_session: Session, ): """Test handling image from base64 data with URI prefix.""" # Arrange @@ -201,29 +178,22 @@ class TestBaseAppRunnerMultimodal: with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: # Setup mock tool file manager mock_mgr = MagicMock() - mock_mgr.create_file_by_raw.return_value = mock_tool_file + mock_mgr.create_file_by_raw.return_value = tool_file mock_mgr_class.return_value = mock_mgr - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - mock_msg_file_class.return_value = mock_message_file + message_file_id = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - file_session = MagicMock() - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) - - runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - mock_mgr.create_file_by_raw.assert_called_once() - call_kwargs = mock_mgr.create_file_by_raw.call_args[1] - assert "file_binary" in call_kwargs + mock_mgr.create_file_by_raw.assert_called_once() + call_kwargs = mock_mgr.create_file_by_raw.call_args[1] + assert "file_binary" in call_kwargs + assert sqlite_session.get(MessageFile, message_file_id) is not None def test_handle_multimodal_image_content_without_url_or_base64( self, @@ -231,6 +201,7 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, + sqlite_session: Session, ): """Test handling image content without URL or base64 data.""" # Arrange @@ -242,24 +213,19 @@ class TestBaseAppRunnerMultimodal: ) with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - file_session = MagicMock() - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) + result = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - mock_mgr_class.assert_not_called() - mock_msg_file_class.assert_not_called() - mock_queue_manager.publish.assert_not_called() + assert result is None + assert sqlite_session.scalar(select(func.count()).select_from(MessageFile)) == 0 + mock_mgr_class.assert_not_called() + mock_queue_manager.publish.assert_not_called() def test_handle_multimodal_image_content_with_error( self, @@ -267,6 +233,7 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, + sqlite_session: Session, ): """Test handling image content when an error occurs.""" # Arrange @@ -282,23 +249,18 @@ class TestBaseAppRunnerMultimodal: mock_mgr.create_file_by_url.side_effect = Exception("Network error") mock_mgr_class.return_value = mock_mgr - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - file_session = MagicMock() - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) + result = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - mock_msg_file_class.assert_not_called() - mock_queue_manager.publish.assert_not_called() + assert result is None + assert sqlite_session.scalar(select(func.count()).select_from(MessageFile)) == 0 + mock_queue_manager.publish.assert_not_called() def test_handle_multimodal_image_content_debugger_mode( self, @@ -306,8 +268,8 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, - mock_tool_file, - mock_message_file, + tool_file, + sqlite_session: Session, ): """Test that debugger mode sets correct created_by_role.""" # Arrange @@ -321,28 +283,21 @@ class TestBaseAppRunnerMultimodal: with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: mock_mgr = MagicMock() - mock_mgr.create_file_by_url.return_value = mock_tool_file + mock_mgr.create_file_by_url.return_value = tool_file mock_mgr_class.return_value = mock_mgr - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - mock_msg_file_class.return_value = mock_message_file + message_file_id = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - file_session = MagicMock() - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) - - runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - call_kwargs = mock_msg_file_class.call_args[1] - assert call_kwargs["created_by_role"] == CreatorUserRole.ACCOUNT + message_file = sqlite_session.get(MessageFile, message_file_id) + assert message_file is not None + assert message_file.created_by_role == CreatorUserRole.ACCOUNT def test_handle_multimodal_image_content_service_api_mode( self, @@ -350,8 +305,8 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, - mock_tool_file, - mock_message_file, + tool_file, + sqlite_session: Session, ): """Test that service API mode sets correct created_by_role.""" # Arrange @@ -365,25 +320,18 @@ class TestBaseAppRunnerMultimodal: with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: mock_mgr = MagicMock() - mock_mgr.create_file_by_url.return_value = mock_tool_file + mock_mgr.create_file_by_url.return_value = tool_file mock_mgr_class.return_value = mock_mgr - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - mock_msg_file_class.return_value = mock_message_file + message_file_id = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - file_session = MagicMock() - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) - - runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - call_kwargs = mock_msg_file_class.call_args[1] - assert call_kwargs["created_by_role"] == CreatorUserRole.END_USER + message_file = sqlite_session.get(MessageFile, message_file_id) + assert message_file is not None + assert message_file.created_by_role == CreatorUserRole.END_USER diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_app_queue_manager.py b/api/tests/unit_tests/core/app/apps/workflow/test_app_queue_manager.py index eeabb51c27a..fb92e098702 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_app_queue_manager.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_app_queue_manager.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable from unittest.mock import Mock, patch from core.app.apps.base_app_queue_manager import PublishFrom @@ -79,12 +80,12 @@ class TestWorkflowAppQueueManager: graph_engine_manager.return_value.send_stop_command.assert_not_called() manager._execution_coordinator.mark_terminal() - def test_execution_timeout_aborts_graph_before_stop_event(self): + def test_execution_timeout_aborts_graph_before_stop_event(self, config_overrides: Callable[..., None]): + config_overrides(APP_MAX_EXECUTION_TIME=0) with ( patch("core.app.apps.base_app_queue_manager.redis_client") as queue_redis, patch("core.app.apps.execution_coordinator.redis_client") as execution_redis, patch("core.app.apps.execution_coordinator.GraphEngineManager") as graph_engine_manager, - patch("core.app.apps.execution_coordinator.dify_config.APP_MAX_EXECUTION_TIME", 0), ): queue_redis.get.return_value = None manager = WorkflowAppQueueManager( diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_based_generate_task_pipeline.py b/api/tests/unit_tests/core/app/task_pipeline/test_based_generate_task_pipeline.py index 18c2fdd97a2..26b3f521a3e 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_based_generate_task_pipeline.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_based_generate_task_pipeline.py @@ -7,6 +7,7 @@ from dify_agent.protocol import RunFailureType from sqlalchemy.orm import Session from clients.agent_backend.errors import AgentBackendRunFailedError +from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.queue_entities import QueueErrorEvent @@ -168,6 +169,17 @@ class TestBasedGenerateTaskPipeline: "message": "run limit reached (agent_run_id=run-1)", } + def test_stream_converter_preserves_agent_session_configuration_error(self): + data = AppGenerateResponseConverter._error_to_stream_response(AgentSessionSnapshotIncompatibleError()) + + assert data == { + "code": "agent_session_configuration_changed", + "status": 409, + "message": ( + "The Agent configuration changed after this conversation started. Start a new conversation to continue." + ), + } + def test_handle_output_moderation_when_flagged(self, pipeline): handler = Mock() handler.moderation_completion.return_value = ("filtered", True) diff --git a/api/tests/unit_tests/core/app/workflow/test_observability_layer_extra.py b/api/tests/unit_tests/core/app/workflow/test_observability_layer_extra.py index b283f8a211d..d13dea0c7b7 100644 --- a/api/tests/unit_tests/core/app/workflow/test_observability_layer_extra.py +++ b/api/tests/unit_tests/core/app/workflow/test_observability_layer_extra.py @@ -6,12 +6,13 @@ import pytest from core.app.workflow.layers.observability import ObservabilityLayer from graphon.enums import BuiltinNodeTypes +from tests.unit_tests.config_override import apply_config_overrides class TestObservabilityLayerExtras: def test_init_tracer_enabled_sets_tracer(self, monkeypatch: pytest.MonkeyPatch): tracer = object() - monkeypatch.setattr("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True) + apply_config_overrides(monkeypatch, ENABLE_OTEL=True) monkeypatch.setattr("core.app.workflow.layers.observability.is_instrument_flag_enabled", lambda: False) monkeypatch.setattr("core.app.workflow.layers.observability.get_tracer", lambda _: tracer) @@ -23,7 +24,7 @@ class TestObservabilityLayerExtras: def test_init_tracer_disables_when_get_tracer_fails( self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ): - monkeypatch.setattr("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True) + apply_config_overrides(monkeypatch, ENABLE_OTEL=True) monkeypatch.setattr("core.app.workflow.layers.observability.is_instrument_flag_enabled", lambda: False) def _raise(*_args, **_kwargs): @@ -38,7 +39,7 @@ class TestObservabilityLayerExtras: assert "Failed to get OpenTelemetry tracer" in caplog.text def test_init_tracer_disables_when_otel_disabled(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", False) + apply_config_overrides(monkeypatch, ENABLE_OTEL=False) monkeypatch.setattr("core.app.workflow.layers.observability.is_instrument_flag_enabled", lambda: False) layer = ObservabilityLayer() diff --git a/api/tests/unit_tests/core/callback_handler/test_agent_tool_callback_handler.py b/api/tests/unit_tests/core/callback_handler/test_agent_tool_callback_handler.py index f9b3b1864e0..9d22e2f633f 100644 --- a/api/tests/unit_tests/core/callback_handler/test_agent_tool_callback_handler.py +++ b/api/tests/unit_tests/core/callback_handler/test_agent_tool_callback_handler.py @@ -5,6 +5,7 @@ from pytest_mock import MockerFixture import core.callback_handler.agent_tool_callback_handler as module from core.callback_handler.agent_tool_callback_handler import DifyAgentCallbackHandler +from tests.unit_tests.config_override import apply_config_overrides # ----------------------------- # Fixtures @@ -12,13 +13,13 @@ from core.callback_handler.agent_tool_callback_handler import DifyAgentCallbackH @pytest.fixture -def enable_debug(mocker: MockerFixture): - mocker.patch.object(module.dify_config, "DEBUG", True) +def enable_debug(monkeypatch: pytest.MonkeyPatch) -> None: + apply_config_overrides(monkeypatch, DEBUG=True) @pytest.fixture -def disable_debug(mocker: MockerFixture): - mocker.patch.object(module.dify_config, "DEBUG", False) +def disable_debug(monkeypatch: pytest.MonkeyPatch) -> None: + apply_config_overrides(monkeypatch, DEBUG=False) @pytest.fixture diff --git a/api/tests/unit_tests/core/callback_handler/test_workflow_tool_callback_handler.py b/api/tests/unit_tests/core/callback_handler/test_workflow_tool_callback_handler.py index 81ac48b2036..ea6451dc785 100644 --- a/api/tests/unit_tests/core/callback_handler/test_workflow_tool_callback_handler.py +++ b/api/tests/unit_tests/core/callback_handler/test_workflow_tool_callback_handler.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from unittest.mock import MagicMock, call import pytest @@ -33,9 +34,9 @@ def mock_print_text(mocker: MockerFixture): @pytest.fixture -def enable_debug(mocker: MockerFixture): +def enable_debug(config_overrides: Callable[..., None]): """Force DEBUG on so the handler emits its verbose stdout traces.""" - mocker.patch("core.callback_handler.workflow_tool_callback_handler.dify_config.DEBUG", True) + config_overrides(DEBUG=True) class TestDifyWorkflowCallbackHandler: @@ -112,12 +113,15 @@ class TestDifyWorkflowCallbackHandler: mock_print_text.assert_not_called() def test_on_tool_execution_skips_print_when_debug_disabled( - self, handler: DifyWorkflowCallbackHandler, mock_print_text, mocker: MockerFixture + self, + handler: DifyWorkflowCallbackHandler, + mock_print_text, + config_overrides: Callable[..., None], ): """When DEBUG is off, outputs are still yielded but nothing is printed and model_dump_json() is never invoked.""" # Arrange - mocker.patch("core.callback_handler.workflow_tool_callback_handler.dify_config.DEBUG", False) + config_overrides(DEBUG=False) message = MagicMock() # Act diff --git a/api/tests/unit_tests/core/datasource/__base/test_datasource_plugin.py b/api/tests/unit_tests/core/datasource/__base/test_datasource_plugin.py index 5482b4db525..15f3d7e43ce 100644 --- a/api/tests/unit_tests/core/datasource/__base/test_datasource_plugin.py +++ b/api/tests/unit_tests/core/datasource/__base/test_datasource_plugin.py @@ -1,6 +1,6 @@ -from unittest.mock import MagicMock, patch +from collections.abc import Callable +from unittest.mock import MagicMock -from configs import dify_config from core.datasource.__base.datasource_plugin import DatasourcePlugin from core.datasource.__base.datasource_runtime import DatasourceRuntime from core.datasource.entities.datasource_entities import DatasourceEntity, DatasourceProviderType @@ -69,7 +69,8 @@ class TestDatasourcePlugin: assert new_plugin.icon == icon mock_entity.model_copy.assert_called_once() - def test_get_icon_url(self): + def test_get_icon_url(self, config_overrides: Callable[..., None]): + config_overrides(CONSOLE_API_URL="https://api.dify.ai") # Arrange entity = MagicMock(spec=DatasourceEntity) runtime = MagicMock(spec=DatasourceRuntime) @@ -78,13 +79,9 @@ class TestDatasourcePlugin: plugin = ConcreteDatasourcePlugin(entity=entity, runtime=runtime, icon=icon) - # Mocking dify_config.CONSOLE_API_URL - with patch.object(dify_config, "CONSOLE_API_URL", "https://api.dify.ai"): - # Act - icon_url = plugin.get_icon_url(tenant_id) + icon_url = plugin.get_icon_url(tenant_id) - # Assert - expected_url = ( - f"https://api.dify.ai/console/api/workspaces/current/plugin/icon?tenant_id={tenant_id}&filename={icon}" - ) - assert icon_url == expected_url + expected_url = ( + f"https://api.dify.ai/console/api/workspaces/current/plugin/icon?tenant_id={tenant_id}&filename={icon}" + ) + assert icon_url == expected_url diff --git a/api/tests/unit_tests/core/entities/test_entities_mcp_provider.py b/api/tests/unit_tests/core/entities/test_entities_mcp_provider.py index 4ebf265193d..cc48adfb5b0 100644 --- a/api/tests/unit_tests/core/entities/test_entities_mcp_provider.py +++ b/api/tests/unit_tests/core/entities/test_entities_mcp_provider.py @@ -3,7 +3,6 @@ from unittest.mock import Mock, patch import pytest -from core.entities import mcp_provider as mcp_provider_module from core.entities.mcp_provider import ( DEFAULT_EXPIRES_IN, DEFAULT_TOKEN_TYPE, @@ -69,7 +68,9 @@ def test_from_db_model_maps_fields() -> None: def test_redirect_url_uses_console_api_url(monkeypatch: pytest.MonkeyPatch) -> None: # Arrange entity = _build_mcp_provider_entity() - monkeypatch.setattr(mcp_provider_module.dify_config, "CONSOLE_API_URL", "https://console.example.com") + from tests.unit_tests.config_override import apply_config_overrides + + apply_config_overrides(monkeypatch, CONSOLE_API_URL="https://console.example.com") # Act redirect_url = entity.redirect_url diff --git a/api/tests/unit_tests/core/extension/test_api_based_extension_requestor.py b/api/tests/unit_tests/core/extension/test_api_based_extension_requestor.py index 5ecc9fc5967..a967f28b4d1 100644 --- a/api/tests/unit_tests/core/extension/test_api_based_extension_requestor.py +++ b/api/tests/unit_tests/core/extension/test_api_based_extension_requestor.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + import httpx import pytest from pytest_mock import MockerFixture @@ -29,10 +31,8 @@ def test_request_success(mocker: MockerFixture): ) -def test_request_with_ssrf_proxy(mocker: MockerFixture): - # Mock dify_config - mocker.patch("configs.dify_config.SSRF_PROXY_HTTP_URL", "http://proxy:8080") - mocker.patch("configs.dify_config.SSRF_PROXY_HTTPS_URL", "https://proxy:8081") +def test_request_with_ssrf_proxy(mocker: MockerFixture, config_overrides: Callable[..., None]): + config_overrides(SSRF_PROXY_HTTP_URL="http://proxy:8080", SSRF_PROXY_HTTPS_URL="https://proxy:8081") # Mock httpx.Client mock_client = mocker.MagicMock() @@ -60,10 +60,8 @@ def test_request_with_ssrf_proxy(mocker: MockerFixture): assert mock_transport.call_count == 2 -def test_request_with_only_one_proxy_config(mocker: MockerFixture): - # Mock dify_config with only one proxy - mocker.patch("configs.dify_config.SSRF_PROXY_HTTP_URL", "http://proxy:8080") - mocker.patch("configs.dify_config.SSRF_PROXY_HTTPS_URL", None) +def test_request_with_only_one_proxy_config(mocker: MockerFixture, config_overrides: Callable[..., None]): + config_overrides(SSRF_PROXY_HTTP_URL="http://proxy:8080", SSRF_PROXY_HTTPS_URL=None) # Mock httpx.Client mock_client = mocker.MagicMock() diff --git a/api/tests/unit_tests/core/helper/test_marketplace.py b/api/tests/unit_tests/core/helper/test_marketplace.py index 6d9d37f4c93..a587584ae19 100644 --- a/api/tests/unit_tests/core/helper/test_marketplace.py +++ b/api/tests/unit_tests/core/helper/test_marketplace.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from types import SimpleNamespace from unittest.mock import MagicMock @@ -21,9 +22,11 @@ def test_get_plugin_pkg_url_contains_unique_identifier() -> None: assert "unique_identifier=langgenius%2Fopenai%3A0.4.2%40checksum" in url -def test_download_plugin_pkg_delegates_with_configured_size(mocker: MockerFixture) -> None: +def test_download_plugin_pkg_delegates_with_configured_size( + mocker: MockerFixture, config_overrides: Callable[..., None] +) -> None: mocked_download = mocker.patch("core.helper.marketplace.download_with_size_limit", return_value=b"pkg") - mocker.patch("core.helper.marketplace.dify_config.PLUGIN_MAX_PACKAGE_SIZE", 1234) + config_overrides(PLUGIN_MAX_PACKAGE_SIZE=1234) result = download_plugin_pkg("langgenius/openai:0.4.2@checksum") diff --git a/api/tests/unit_tests/core/helper/test_ssrf_proxy.py b/api/tests/unit_tests/core/helper/test_ssrf_proxy.py index 458f2efd05b..824db04c56d 100644 --- a/api/tests/unit_tests/core/helper/test_ssrf_proxy.py +++ b/api/tests/unit_tests/core/helper/test_ssrf_proxy.py @@ -1,4 +1,5 @@ import gzip +from collections.abc import Callable from typing import override from unittest.mock import ANY, MagicMock, call, patch @@ -141,15 +142,19 @@ def test_force_list_response_returns_when_retries_disabled(mock_get_client): mock_client.send.assert_called_once() -def test_build_ssrf_client_passes_ssl_verify_to_proxy_mount_transports(): +def test_build_ssrf_client_passes_ssl_verify_to_proxy_mount_transports( + config_overrides: Callable[..., None], +): + config_overrides( + SSRF_PROXY_ALL_URL=None, + SSRF_PROXY_HTTP_URL="http://proxy.example.com:8080", + SSRF_PROXY_HTTPS_URL="http://proxy.example.com:8443", + ) mock_client = MagicMock() http_transport = MagicMock() https_transport = MagicMock() with ( - patch("core.helper.ssrf_proxy.dify_config.SSRF_PROXY_ALL_URL", None), - patch("core.helper.ssrf_proxy.dify_config.SSRF_PROXY_HTTP_URL", "http://proxy.example.com:8080"), - patch("core.helper.ssrf_proxy.dify_config.SSRF_PROXY_HTTPS_URL", "http://proxy.example.com:8443"), patch("core.helper.ssrf_proxy.httpx.HTTPTransport", side_effect=[http_transport, https_transport]) as transport, patch("core.helper.ssrf_proxy.httpx.Client", return_value=mock_client) as client, ): diff --git a/api/tests/unit_tests/core/llm_generator/test_llm_generator.py b/api/tests/unit_tests/core/llm_generator/test_llm_generator.py index 2efef0b8fff..003514450dc 100644 --- a/api/tests/unit_tests/core/llm_generator/test_llm_generator.py +++ b/api/tests/unit_tests/core/llm_generator/test_llm_generator.py @@ -649,6 +649,7 @@ class TestLLMGenerator: model_config_entity, "ideal", workflow_service, + session=database, ) def test_instruction_modify_workflow_rejects_app_from_another_tenant( @@ -670,6 +671,7 @@ class TestLLMGenerator: model_config_entity, "ideal", workflow_service, + session=database, ) def test_instruction_modify_workflow_requires_draft_workflow( @@ -691,6 +693,7 @@ class TestLLMGenerator: model_config_entity, "ideal", workflow_service, + session=database, ) def test_instruction_modify_workflow_uses_last_run( @@ -720,6 +723,7 @@ class TestLLMGenerator: model_config_entity, "ideal", workflow_service, + session=database, ) assert result == {"modified": "workflow"} @@ -748,6 +752,7 @@ class TestLLMGenerator: model_config_entity, "ideal", workflow_service, + session=database, ) assert result == {"modified": "workflow"} @@ -783,6 +788,7 @@ class TestLLMGenerator: model_config_entity, "ideal", workflow_service, + session=database, ) assert result == {"modified": "fallback"} @@ -807,6 +813,7 @@ class TestLLMGenerator: model_config_entity, "ideal", workflow_service, + session=database, ) assert result == {"modified": "workflow"} diff --git a/api/tests/unit_tests/core/mcp/server/test_streamable_http.py b/api/tests/unit_tests/core/mcp/server/test_streamable_http.py index 42a9df3538a..1878fe08413 100644 --- a/api/tests/unit_tests/core/mcp/server/test_streamable_http.py +++ b/api/tests/unit_tests/core/mcp/server/test_streamable_http.py @@ -22,6 +22,7 @@ from core.mcp.server.streamable_http import ( ) from graphon.variables.input_entities import VariableEntity, VariableEntityType from models.model import App, AppMCPServer, AppMode, EndUser +from services.errors.app import TriggerWorkflowServiceModeUnavailableError class TestHandleMCPRequest: @@ -157,6 +158,29 @@ class TestHandleMCPRequest: # Verify AppGenerateService was called mock_app_generate.generate.assert_called_once() + @patch("core.mcp.server.streamable_http.AppGenerateService") + def test_handle_call_tool_returns_trigger_workflow_business_error(self, mock_app_generate): + mock_call_request = Mock(spec=types.CallToolRequest) + mock_call_request.params = Mock() + mock_call_request.params.arguments = {"query": "test question"} + mock_call_request.id = 123 + self.mock_request.root = mock_call_request + mock_app_generate.generate.side_effect = TriggerWorkflowServiceModeUnavailableError() + + result = handle_mcp_request( + Mock(), + self.app, + self.mock_request, + self.user_input_form, + self.mcp_server, + self.end_user, + 123, + ) + + assert isinstance(result, types.JSONRPCError) + assert result.error.code == types.INVALID_REQUEST + assert result.error.data == {"code": "trigger_workflow_service_mode_unavailable"} + @patch("core.mcp.server.streamable_http.AppGenerateService") def test_handle_call_tool_request_threads_protocol_version(self, mock_app_generate): """The negotiated version reaches handle_call_tool through the dispatcher.""" diff --git a/api/tests/unit_tests/core/ops/test_ops_trace_manager.py b/api/tests/unit_tests/core/ops/test_ops_trace_manager.py index 3bb3827d629..f4bebc64c21 100644 --- a/api/tests/unit_tests/core/ops/test_ops_trace_manager.py +++ b/api/tests/unit_tests/core/ops/test_ops_trace_manager.py @@ -17,7 +17,6 @@ from sqlalchemy import Engine from sqlalchemy.orm import Session, sessionmaker import core.ops.ops_trace_manager as module -from configs import dify_config from core.ops.ops_trace_manager import OpsTraceManager, TraceQueueManager, TraceTask, TraceTaskName from core.rag.models.document import Document as RetrievalDocument from graphon.enums import WorkflowExecutionStatus @@ -26,6 +25,7 @@ from models.enums import ConversationFromSource, CreatorUserRole, MessageStatus, from models.model import App, AppMode, AppModelConfig, Conversation, Message, MessageFile, TraceAppConfig from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowRun, WorkflowType from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository +from tests.unit_tests.config_override import apply_config_overrides class DummyConfig: @@ -155,7 +155,7 @@ def database(sqlite_engine: Engine, sqlite_session: Session) -> Iterator[Session def trace_environment(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: monkeypatch.setattr(module, "provider_config_map", FakeProviderMap({"dummy": PROVIDER_ENTRY})) monkeypatch.setattr(module, "unified_provider_config_map", FakeProviderMap({})) - monkeypatch.setattr(dify_config, "OPS_TRACE_UNIFIED_ENABLED", False) + apply_config_overrides(monkeypatch, OPS_TRACE_UNIFIED_ENABLED=False) OpsTraceManager.ops_trace_instances_cache.clear() OpsTraceManager.decrypted_configs_cache.clear() monkeypatch.setattr(module.threading, "Timer", DummyTimer) @@ -386,7 +386,7 @@ def test_ops_trace_instance_routes_by_unified_switch( app = _app(database, tracing=json.dumps({"enabled": True, "tracing_provider": "dummy"})) database.add(TraceAppConfig(app_id=app.id, tracing_provider="dummy", tracing_config={})) database.commit() - monkeypatch.setattr(dify_config, "OPS_TRACE_UNIFIED_ENABLED", enabled) + apply_config_overrides(monkeypatch, OPS_TRACE_UNIFIED_ENABLED=enabled) entries = {"dummy": UNIFIED_PROVIDER_ENTRY} if registered else {} monkeypatch.setattr(module, "unified_provider_config_map", FakeProviderMap(entries)) @@ -404,7 +404,7 @@ def test_registered_unified_provider_does_not_fallback_when_construction_fails( app = _app(database, tracing=json.dumps({"enabled": True, "tracing_provider": "dummy"})) database.add(TraceAppConfig(app_id=app.id, tracing_provider="dummy", tracing_config={})) database.commit() - monkeypatch.setattr(dify_config, "OPS_TRACE_UNIFIED_ENABLED", True) + apply_config_overrides(monkeypatch, OPS_TRACE_UNIFIED_ENABLED=True) monkeypatch.setattr( module, "unified_provider_config_map", @@ -433,9 +433,9 @@ def test_unified_and_legacy_instances_have_separate_cache_entries( database.commit() monkeypatch.setattr(module, "unified_provider_config_map", FakeProviderMap({"dummy": UNIFIED_PROVIDER_ENTRY})) - monkeypatch.setattr(dify_config, "OPS_TRACE_UNIFIED_ENABLED", False) + apply_config_overrides(monkeypatch, OPS_TRACE_UNIFIED_ENABLED=False) legacy = OpsTraceManager.get_ops_trace_instance(app.id) - monkeypatch.setattr(dify_config, "OPS_TRACE_UNIFIED_ENABLED", True) + apply_config_overrides(monkeypatch, OPS_TRACE_UNIFIED_ENABLED=True) unified = OpsTraceManager.get_ops_trace_instance(app.id) assert type(legacy) is DummyTraceInstance diff --git a/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py b/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py index f173c84849b..1c06e1000c8 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py +++ b/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py @@ -1,4 +1,5 @@ import json +from collections.abc import Callable from urllib.parse import quote import pytest @@ -42,9 +43,9 @@ class _StreamContext: class TestBasePluginClientImpl: - def test_inject_trace_headers(self, mocker: MockerFixture): + def test_inject_trace_headers(self, mocker: MockerFixture, config_overrides: Callable[..., None]): client = BasePluginClient() - mocker.patch("core.plugin.impl.base.dify_config.ENABLE_OTEL", True) + config_overrides(ENABLE_OTEL=True) trace_header = "00-abc-xyz-01" mocker.patch("core.helper.trace_id_helper.generate_traceparent_header", return_value=trace_header) diff --git a/api/tests/unit_tests/core/plugin/test_endpoint_client.py b/api/tests/unit_tests/core/plugin/test_endpoint_client.py index ff9deb918af..c8042275dfa 100644 --- a/api/tests/unit_tests/core/plugin/test_endpoint_client.py +++ b/api/tests/unit_tests/core/plugin/test_endpoint_client.py @@ -8,6 +8,7 @@ This test module covers the endpoint client operations including: Tests follow the Arrange-Act-Assert pattern for clarity. """ +from collections.abc import Callable from unittest.mock import MagicMock, patch import httpx @@ -42,13 +43,9 @@ class TestPluginEndpointClientDelete: return PluginEndpointClient() @pytest.fixture - def mock_config(self): + def mock_config(self, config_overrides: Callable[..., None]): """Mock plugin daemon configuration.""" - with ( - patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"), - patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-api-key"), - ): - yield + config_overrides(PLUGIN_DAEMON_URL="http://127.0.0.1:5002", PLUGIN_DAEMON_KEY="test-api-key") def test_delete_endpoint_success(self, endpoint_client, mock_config): """Test successful endpoint deletion. diff --git a/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py b/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py index a436116f838..0a2646a7c7a 100644 --- a/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py +++ b/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py @@ -2,6 +2,7 @@ import datetime import uuid +from collections.abc import Callable from types import SimpleNamespace from unittest.mock import MagicMock, Mock, patch, sentinel @@ -484,7 +485,9 @@ class TestPluginModelRuntime: voice="alloy", ) - def test_fetch_model_providers_does_not_keep_bound_runtime_cache(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_fetch_model_providers_does_not_keep_bound_runtime_cache( + self, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] + ) -> None: client = Mock(spec=PluginModelClient) client.fetch_model_providers.return_value = [] from core.plugin import plugin_service as plugin_service_module @@ -500,7 +503,7 @@ class TestPluginModelRuntime: lock=Mock(return_value=MagicMock()), ), ) - monkeypatch.setattr(plugin_service_module.dify_config, "PLUGIN_MODEL_PROVIDERS_CACHE_TTL", 0) + config_overrides(PLUGIN_MODEL_PROVIDERS_CACHE_TTL=0) runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService) runtime.fetch_model_providers() @@ -509,13 +512,13 @@ class TestPluginModelRuntime: assert client.fetch_model_providers.call_count == 2 def test_fetch_model_providers_uses_tenant_ttl_cache_across_runtime_instances( - self, monkeypatch: pytest.MonkeyPatch + self, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] ) -> None: + config_overrides(PLUGIN_MODEL_PROVIDERS_CACHE_TTL=300) redis = _FakeRedis() from core.plugin import plugin_service as plugin_service_module monkeypatch.setattr(plugin_service_module, "redis_client", redis) - monkeypatch.setattr(plugin_service_module.dify_config, "PLUGIN_MODEL_PROVIDERS_CACHE_TTL", 300) first_client = Mock(spec=PluginModelClient) first_client.fetch_model_providers.return_value = [_build_plugin_model_provider(tenant_id="tenant")] second_client = Mock(spec=PluginModelClient) @@ -535,12 +538,14 @@ class TestPluginModelRuntime: second_client.fetch_model_providers.assert_not_called() assert redis.setex_calls[0][1] == 300 - def test_fetch_model_providers_cache_is_tenant_isolated(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_fetch_model_providers_cache_is_tenant_isolated( + self, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] + ) -> None: + config_overrides(PLUGIN_MODEL_PROVIDERS_CACHE_TTL=300) redis = _FakeRedis() from core.plugin import plugin_service as plugin_service_module monkeypatch.setattr(plugin_service_module, "redis_client", redis) - monkeypatch.setattr(plugin_service_module.dify_config, "PLUGIN_MODEL_PROVIDERS_CACHE_TTL", 300) first_client = Mock(spec=PluginModelClient) first_client.fetch_model_providers.return_value = [_build_plugin_model_provider(tenant_id="tenant-a")] second_client = Mock(spec=PluginModelClient) @@ -758,7 +763,10 @@ def test_invoke_llm_with_structured_output_raises_when_model_schema_is_missing() ) -def test_get_model_schema_deletes_invalid_cache_and_refetches(monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_model_schema_deletes_invalid_cache_and_refetches( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides(PLUGIN_MODEL_SCHEMA_CACHE_TTL=300) client = Mock(spec=PluginModelClient) schema = _build_model_schema() delete = Mock() @@ -772,7 +780,6 @@ def test_get_model_schema_deletes_invalid_cache_and_refetches(monkeypatch: pytes setex=setex, ), ) - monkeypatch.setattr(model_runtime_module.dify_config, "PLUGIN_MODEL_SCHEMA_CACHE_TTL", 300) client.get_model_schema.return_value = schema runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService) @@ -797,9 +804,11 @@ def test_get_model_schema_deletes_invalid_cache_and_refetches(monkeypatch: pytes setex.assert_called_once() -def test_get_llm_num_tokens_returns_zero_when_plugin_counting_is_disabled(monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_llm_num_tokens_returns_zero_when_plugin_counting_is_disabled( + config_overrides: Callable[..., None], +) -> None: client = Mock(spec=PluginModelClient) - monkeypatch.setattr(model_runtime_module.dify_config, "PLUGIN_BASED_TOKEN_COUNTING_ENABLED", False) + config_overrides(PLUGIN_BASED_TOKEN_COUNTING_ENABLED=False) runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService) assert ( diff --git a/api/tests/unit_tests/core/plugin/test_plugin_entities.py b/api/tests/unit_tests/core/plugin/test_plugin_entities.py index 5b9d8c3c636..a0ca44ef092 100644 --- a/api/tests/unit_tests/core/plugin/test_plugin_entities.py +++ b/api/tests/unit_tests/core/plugin/test_plugin_entities.py @@ -1,11 +1,11 @@ import binascii import datetime +from collections.abc import Callable from enum import StrEnum import pytest from flask import Response from pydantic import ValidationError -from pytest_mock import MockerFixture from core.plugin.entities.endpoint import EndpointEntityWithInstance from core.plugin.entities.marketplace import MarketplacePluginDeclaration, MarketplacePluginSnapshot @@ -35,8 +35,8 @@ from graphon.model_runtime.entities.message_entities import ( class TestEndpointEntity: - def test_endpoint_entity_with_instance_renders_url(self, mocker: MockerFixture): - mocker.patch("core.plugin.entities.endpoint.dify_config.ENDPOINT_URL_TEMPLATE", "https://dify.test/{hook_id}") + def test_endpoint_entity_with_instance_renders_url(self, config_overrides: Callable[..., None]): + config_overrides(ENDPOINT_URL_TEMPLATE="https://dify.test/{hook_id}") now = datetime.datetime.now(datetime.UTC) entity = EndpointEntityWithInstance.model_validate( diff --git a/api/tests/unit_tests/core/prompt/test_advanced_prompt_transform.py b/api/tests/unit_tests/core/prompt/test_advanced_prompt_transform.py index e536c0831fd..6a5046154e8 100644 --- a/api/tests/unit_tests/core/prompt/test_advanced_prompt_transform.py +++ b/api/tests/unit_tests/core/prompt/test_advanced_prompt_transform.py @@ -3,7 +3,6 @@ from unittest.mock import MagicMock, patch import pytest -from configs import dify_config from core.app.app_config.entities import ModelConfigEntity from core.memory.token_buffer_memory import TokenBufferMemory from core.prompt.advanced_prompt_transform import AdvancedPromptTransform @@ -19,6 +18,7 @@ from graphon.model_runtime.entities.message_entities import ( UserPromptMessage, ) from models.model import Conversation +from tests.unit_tests.config_override import apply_config_overrides def test__get_completion_model_prompt_messages(): @@ -128,9 +128,9 @@ def test__get_chat_model_prompt_messages_no_memory(get_chat_model_args): ) -def test__get_chat_model_prompt_messages_with_files_no_memory(get_chat_model_args): +def test__get_chat_model_prompt_messages_with_files_no_memory(get_chat_model_args, monkeypatch: pytest.MonkeyPatch): model_config_mock, _, messages, inputs, context = get_chat_model_args - dify_config.MULTIMODAL_SEND_FORMAT = "url" + apply_config_overrides(monkeypatch, MULTIMODAL_SEND_FORMAT="url") files = [ File( diff --git a/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba.py b/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba.py index eb96d33989b..835e6b54e87 100644 --- a/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba.py +++ b/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba.py @@ -289,7 +289,9 @@ def test_get_dataset_keyword_table_returns_existing_table_data(patched_runtime): def test_get_dataset_keyword_table_creates_table_when_missing(monkeypatch: pytest.MonkeyPatch, patched_runtime): keyword = Jieba(_dataset(dataset_keyword_table=None)) - monkeypatch.setattr(jieba_module.dify_config, "KEYWORD_DATA_SOURCE_TYPE", "database") + from tests.unit_tests.config_override import apply_config_overrides + + apply_config_overrides(monkeypatch, KEYWORD_DATA_SOURCE_TYPE="database") result = keyword._get_dataset_keyword_table(patched_runtime.session) assert result == {} diff --git a/api/tests/unit_tests/core/rag/datasource/keyword/test_keyword_factory.py b/api/tests/unit_tests/core/rag/datasource/keyword/test_keyword_factory.py index 9fa76dd9737..9b392493eda 100644 --- a/api/tests/unit_tests/core/rag/datasource/keyword/test_keyword_factory.py +++ b/api/tests/unit_tests/core/rag/datasource/keyword/test_keyword_factory.py @@ -9,6 +9,7 @@ from core.rag.datasource.keyword.keyword_factory import Keyword from core.rag.datasource.keyword.keyword_type import KeyWordType from core.rag.models.document import Document from models.dataset import Dataset +from tests.unit_tests.config_override import apply_config_overrides def test_get_keyword_factory_returns_jieba_factory(monkeypatch: pytest.MonkeyPatch): @@ -38,7 +39,7 @@ def test_keyword_initialization_uses_configured_factory(monkeypatch: pytest.Monk ) fake_processor = MagicMock() - monkeypatch.setattr("core.rag.datasource.keyword.keyword_factory.dify_config.KEYWORD_STORE", KeyWordType.JIEBA) + apply_config_overrides(monkeypatch, KEYWORD_STORE=KeyWordType.JIEBA) monkeypatch.setattr(Keyword, "get_keyword_factory", staticmethod(lambda keyword_type: lambda _: fake_processor)) keyword = Keyword(dataset) diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py b/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py index 47b917c86d5..dd78ae7fd14 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py +++ b/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py @@ -14,6 +14,7 @@ from extensions.storage.storage_type import StorageType from models.dataset import Whitelist from models.enums import CreatorUserRole from models.model import UploadFile +from tests.unit_tests.config_override import apply_config_overrides def _register_fake_factory_module(monkeypatch: pytest.MonkeyPatch, module_path: str, class_name: str): @@ -268,8 +269,11 @@ def test_init_vector_uses_whitelist_override( tenant_id = str(uuid4()) sqlite_session.add(Whitelist(tenant_id=tenant_id, category="vector_db")) sqlite_session.commit() - monkeypatch.setattr(vector_factory_module.dify_config, "VECTOR_STORE", vector_factory_module.VectorType.CHROMA) - monkeypatch.setattr(vector_factory_module.dify_config, "VECTOR_STORE_WHITELIST_ENABLE", True) + apply_config_overrides( + monkeypatch, + VECTOR_STORE=vector_factory_module.VectorType.CHROMA, + VECTOR_STORE_WHITELIST_ENABLE=True, + ) monkeypatch.setattr( vector_factory_module.Vector, "get_vector_factory", @@ -290,8 +294,7 @@ def test_init_vector_uses_whitelist_override( def test_init_vector_raises_when_vector_store_missing( vector_factory_module, monkeypatch: pytest.MonkeyPatch, unbound_session: Session ): - monkeypatch.setattr(vector_factory_module.dify_config, "VECTOR_STORE", None) - monkeypatch.setattr(vector_factory_module.dify_config, "VECTOR_STORE_WHITELIST_ENABLE", False) + apply_config_overrides(monkeypatch, VECTOR_STORE=None, VECTOR_STORE_WHITELIST_ENABLE=False) vector = vector_factory_module.Vector.__new__(vector_factory_module.Vector) vector._dataset = SimpleNamespace(index_struct_dict=None, tenant_id="tenant-1") diff --git a/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py index af12b8780f6..2797d55d39f 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py @@ -9,6 +9,7 @@ import core.rag.extractor.excel_extractor as excel_module from core.rag.extractor.excel_extractor import ExcelExtractor from models.base import TypeBase from models.model import UploadFile +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture @@ -77,8 +78,7 @@ def _patch_image_persistence(monkeypatch: pytest.MonkeyPatch): saves.append((key, data)) monkeypatch.setattr(excel_module.storage, "save", save) - monkeypatch.setattr(excel_module.dify_config, "FILES_URL", "http://files.local", raising=False) - monkeypatch.setattr(excel_module.dify_config, "STORAGE_TYPE", "local", raising=False) + apply_config_overrides(monkeypatch, FILES_URL="http://files.local", STORAGE_TYPE="local") return saves diff --git a/api/tests/unit_tests/core/rag/extractor/test_extract_processor.py b/api/tests/unit_tests/core/rag/extractor/test_extract_processor.py index 369e63e57e3..49c2cce919f 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_extract_processor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_extract_processor.py @@ -12,6 +12,7 @@ from core.rag.models.document import Document from extensions.storage.storage_type import StorageType from models.enums import CreatorUserRole from models.model import UploadFile +from tests.unit_tests.config_override import apply_config_overrides def _upload_file(*, key: str, file_id: str = "upload-file-1") -> UploadFile: @@ -145,7 +146,7 @@ class TestExtractProcessorLoaders: content = "a" * 100_000 response = SimpleNamespace(headers={"Content-Type": "text/plain"}, content=content.encode()) monkeypatch.setattr(processor_module.remote_fetcher, "make_request", lambda *args, **kwargs: response) - monkeypatch.setattr(processor_module.dify_config, "ETL_TYPE", "SelfHosted") + apply_config_overrides(monkeypatch, ETL_TYPE="SelfHosted") text = ExtractProcessor.load_from_url("https://example.com/response.txt", return_text=True) @@ -155,8 +156,11 @@ class TestExtractProcessorLoaders: class TestExtractProcessorFileRouting: @pytest.fixture(autouse=True) def _set_unstructured_config(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(processor_module.dify_config, "UNSTRUCTURED_API_URL", "https://unstructured") - monkeypatch.setattr(processor_module.dify_config, "UNSTRUCTURED_API_KEY", "key") + apply_config_overrides( + monkeypatch, + UNSTRUCTURED_API_URL="https://unstructured", + UNSTRUCTURED_API_KEY="key", + ) def _run_extract_for_extension( self, @@ -167,7 +171,7 @@ class TestExtractProcessorFileRouting: session: object | None = None, ): factory = _patch_all_extractors(monkeypatch) - monkeypatch.setattr(processor_module.dify_config, "ETL_TYPE", etl_type) + apply_config_overrides(monkeypatch, ETL_TYPE=etl_type) def fake_download(key: str, local_path: str): Path(local_path).write_text("content", encoding="utf-8") diff --git a/api/tests/unit_tests/core/rag/extractor/test_notion_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_notion_extractor.py index 8f49647fe7c..1f313659fd8 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_notion_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_notion_extractor.py @@ -17,6 +17,7 @@ from core.rag.index_processor.constant.index_type import IndexStructureType from models.base import TypeBase from models.dataset import Document as DocumentModel from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture @@ -74,7 +75,7 @@ class TestNotionExtractorInitAndPublicMethods: "_get_access_token", classmethod(lambda cls, tenant_id, credential_id: (_ for _ in ()).throw(Exception("credential error"))), ) - monkeypatch.setattr(notion_extractor.dify_config, "NOTION_INTEGRATION_TOKEN", "env-token", raising=False) + apply_config_overrides(monkeypatch, NOTION_INTEGRATION_TOKEN="env-token") extractor = notion_extractor.NotionExtractor( notion_workspace_id="ws", @@ -92,7 +93,7 @@ class TestNotionExtractorInitAndPublicMethods: "_get_access_token", classmethod(lambda cls, tenant_id, credential_id: (_ for _ in ()).throw(Exception("credential error"))), ) - monkeypatch.setattr(notion_extractor.dify_config, "NOTION_INTEGRATION_TOKEN", None, raising=False) + apply_config_overrides(monkeypatch, NOTION_INTEGRATION_TOKEN=None) with pytest.raises(ValueError, match="Must specify `integration_token`"): notion_extractor.NotionExtractor( diff --git a/api/tests/unit_tests/core/rag/extractor/test_pdf_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_pdf_extractor.py index 3f5cf0d37cb..bc35f5fde03 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_pdf_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_pdf_extractor.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session import core.rag.extractor.pdf_extractor as pe from models.model import UploadFile +from tests.unit_tests.config_override import apply_config_overrides TENANT_ID = str(uuid4()) USER_ID = str(uuid4()) @@ -41,9 +42,12 @@ def mock_dependencies(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) storage = _Storage() monkeypatch.setattr(pe, "storage", storage) monkeypatch.setattr(pe, "db", _DatabaseBinding(sqlite_session)) - monkeypatch.setattr(pe.dify_config, "FILES_URL", "http://files.local") - monkeypatch.setattr(pe.dify_config, "INTERNAL_FILES_URL", None) - monkeypatch.setattr(pe.dify_config, "STORAGE_TYPE", "local") + apply_config_overrides( + monkeypatch, + FILES_URL="http://files.local", + INTERNAL_FILES_URL=None, + STORAGE_TYPE="local", + ) return _Dependencies(storage=storage, session=sqlite_session) diff --git a/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py index 830e95c1721..211feb8dd13 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py @@ -21,6 +21,7 @@ from sqlalchemy.orm import Session import core.rag.extractor.word_extractor as we from core.rag.extractor.word_extractor import WordExtractor from models.model import UploadFile +from tests.unit_tests.config_override import apply_config_overrides class _TextOxmlElement(Protocol): @@ -131,8 +132,7 @@ def test_extract_images_from_docx(monkeypatch: pytest.MonkeyPatch, inject_sessio monkeypatch.setattr(we, "db", db_stub) # Patch config values used for URL composition and storage type - monkeypatch.setattr(we.dify_config, "FILES_URL", "http://files.local", raising=False) - monkeypatch.setattr(we.dify_config, "STORAGE_TYPE", "local", raising=False) + apply_config_overrides(monkeypatch, FILES_URL="http://files.local", STORAGE_TYPE="local") # Patch external image fetcher def fake_make_request(method: str, url: str, **kwargs): @@ -208,8 +208,7 @@ def test_extract_images_does_not_stage_partial_files_on_storage_failure( ) save = MagicMock(side_effect=[None, RuntimeError("storage failure")]) monkeypatch.setattr(we, "storage", SimpleNamespace(save=save)) - monkeypatch.setattr(we.dify_config, "FILES_URL", "http://files.local", raising=False) - monkeypatch.setattr(we.dify_config, "STORAGE_TYPE", "local", raising=False) + apply_config_overrides(monkeypatch, FILES_URL="http://files.local", STORAGE_TYPE="local") extractor = object.__new__(WordExtractor) extractor.tenant_id = "00000000-0000-0000-0000-000000000001" @@ -222,35 +221,24 @@ def test_extract_images_does_not_stage_partial_files_on_storage_failure( assert sqlite_session.scalars(select(UploadFile)).all() == [] -def test_extract_images_from_docx_uses_internal_files_url(): +def test_extract_images_from_docx_uses_internal_files_url(monkeypatch: pytest.MonkeyPatch): """Test that INTERNAL_FILES_URL takes precedence over FILES_URL for plugin access.""" # Test the URL generation logic directly from configs import dify_config - # Mock the configuration values - original_files_url = dify_config.FILES_URL - original_internal_files_url = dify_config.INTERNAL_FILES_URL + apply_config_overrides( + monkeypatch, + FILES_URL="http://external.example.com", + INTERNAL_FILES_URL="http://internal.docker:5001", + ) - try: - # Set both URLs - INTERNAL should take precedence - dify_config.FILES_URL = "http://external.example.com" - dify_config.INTERNAL_FILES_URL = "http://internal.docker:5001" + upload_file_id = "test_file_id" - # Test the URL generation logic (same as in word_extractor.py) - upload_file_id = "test_file_id" + base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL + generated_url = f"{base_url}/files/{upload_file_id}/file-preview" - # This is the pattern we fixed in the word extractor - base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL - generated_url = f"{base_url}/files/{upload_file_id}/file-preview" - - # Verify that INTERNAL_FILES_URL is used instead of FILES_URL - assert "http://internal.docker:5001" in generated_url, f"Expected internal URL, got: {generated_url}" - assert "http://external.example.com" not in generated_url, f"Should not use external URL, got: {generated_url}" - - finally: - # Restore original values - dify_config.FILES_URL = original_files_url - dify_config.INTERNAL_FILES_URL = original_internal_files_url + assert "http://internal.docker:5001" in generated_url, f"Expected internal URL, got: {generated_url}" + assert "http://external.example.com" not in generated_url, f"Should not use external URL, got: {generated_url}" def test_extract_hyperlinks(monkeypatch: pytest.MonkeyPatch, unbound_session: Session): @@ -258,8 +246,7 @@ def test_extract_hyperlinks(monkeypatch: pytest.MonkeyPatch, unbound_session: Se monkeypatch.setattr(we, "storage", SimpleNamespace(save=lambda k, d: None)) db_stub = SimpleNamespace(session=unbound_session) monkeypatch.setattr(we, "db", db_stub) - monkeypatch.setattr(we.dify_config, "FILES_URL", "http://files.local", raising=False) - monkeypatch.setattr(we.dify_config, "STORAGE_TYPE", "local", raising=False) + apply_config_overrides(monkeypatch, FILES_URL="http://files.local", STORAGE_TYPE="local") doc = Document() p = doc.add_paragraph("Visit ") @@ -303,8 +290,7 @@ def test_extract_legacy_hyperlinks(monkeypatch: pytest.MonkeyPatch, unbound_sess monkeypatch.setattr(we, "storage", SimpleNamespace(save=lambda k, d: None)) db_stub = SimpleNamespace(session=unbound_session) monkeypatch.setattr(we, "db", db_stub) - monkeypatch.setattr(we.dify_config, "FILES_URL", "http://files.local", raising=False) - monkeypatch.setattr(we.dify_config, "STORAGE_TYPE", "local", raising=False) + apply_config_overrides(monkeypatch, FILES_URL="http://files.local", STORAGE_TYPE="local") doc = Document() p = doc.add_paragraph() @@ -476,7 +462,7 @@ def test_extract_images_handles_invalid_external_cases(monkeypatch: pytest.Monke db_stub = SimpleNamespace(session=sqlite_session) monkeypatch.setattr(we, "db", db_stub) monkeypatch.setattr(we, "storage", SimpleNamespace(save=lambda key, data: None)) - monkeypatch.setattr(we.dify_config, "FILES_URL", "http://files.local", raising=False) + apply_config_overrides(monkeypatch, FILES_URL="http://files.local") extractor = object.__new__(WordExtractor) extractor.tenant_id = "tenant" diff --git a/api/tests/unit_tests/core/rag/indexing/processor/test_parent_child_index_processor.py b/api/tests/unit_tests/core/rag/indexing/processor/test_parent_child_index_processor.py index cb3dc7b23da..2919c71e0a0 100644 --- a/api/tests/unit_tests/core/rag/indexing/processor/test_parent_child_index_processor.py +++ b/api/tests/unit_tests/core/rag/indexing/processor/test_parent_child_index_processor.py @@ -14,6 +14,7 @@ from core.rag.models.document import AttachmentDocument, ChildDocument, Document from models.dataset import ChildChunk, Dataset, DatasetProcessRule, DocumentCreatedFrom, DocumentSegment from models.dataset import Document as DatasetDocument from models.enums import DataSourceType +from tests.unit_tests.config_override import config_overrides_context class TestParentChildIndexProcessor: @@ -206,10 +207,7 @@ class TestParentChildIndexProcessor: "core.rag.index_processor.processor.parent_child_index_processor.helper.generate_text_hash", return_value="hash", ), - patch( - "core.rag.index_processor.processor.parent_child_index_processor.dify_config.CHILD_CHUNKS_PREVIEW_NUMBER", - 2, - ), + config_overrides_context(CHILD_CHUNKS_PREVIEW_NUMBER=2), ): result = processor.transform( docs, diff --git a/api/tests/unit_tests/core/rag/indexing/test_index_processor_base.py b/api/tests/unit_tests/core/rag/indexing/test_index_processor_base.py index ad8fb37ea67..6dc66e5ff59 100644 --- a/api/tests/unit_tests/core/rag/indexing/test_index_processor_base.py +++ b/api/tests/unit_tests/core/rag/indexing/test_index_processor_base.py @@ -16,6 +16,7 @@ from extensions.storage.storage_type import StorageType from models.enums import CreatorUserRole from models.model import UploadFile from models.tools import ToolFile +from tests.unit_tests.config_override import config_overrides_context def _persist_upload(session: Session, *, upload_id: str, name: str) -> UploadFile: @@ -115,9 +116,7 @@ class TestBaseIndexProcessor: processor.format_preview([]) def test_get_splitter_validates_custom_length(self, processor: _ForwardingBaseIndexProcessor) -> None: - with patch( - "core.rag.index_processor.index_processor_base.dify_config.INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH", 1000 - ): + with config_overrides_context(INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=1000): with pytest.raises(ValueError, match="between 50 and 1000"): processor._get_splitter("custom", 49, 0, "", None) with pytest.raises(ValueError, match="between 50 and 1000"): diff --git a/api/tests/unit_tests/core/test_provider_manager.py b/api/tests/unit_tests/core/test_provider_manager.py index 935b983decd..bb40efc0ae9 100644 --- a/api/tests/unit_tests/core/test_provider_manager.py +++ b/api/tests/unit_tests/core/test_provider_manager.py @@ -36,6 +36,7 @@ from models.provider import ( TenantPreferredModelProvider, ) from models.provider_ids import ModelProviderID +from tests.unit_tests.config_override import config_overrides_context def _build_provider_manager() -> ProviderManager: @@ -302,7 +303,7 @@ def test_to_system_configuration_uses_owned_session_for_cloud_credit_pools() -> paid_pool = SimpleNamespace(quota_used=0, quota_limit=0) with ( - patch.object(provider_manager_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD), patch( "core.provider_manager.ext_hosting_provider.hosting_configuration.provider_map", {provider_entity.provider: _build_hosting_provider()}, diff --git a/api/tests/unit_tests/core/tools/test_tool_manager.py b/api/tests/unit_tests/core/tools/test_tool_manager.py index a2947dbd94a..a299fe465dc 100644 --- a/api/tests/unit_tests/core/tools/test_tool_manager.py +++ b/api/tests/unit_tests/core/tools/test_tool_manager.py @@ -4,7 +4,7 @@ from __future__ import annotations import json import threading -from collections.abc import Iterator +from collections.abc import Callable, Iterator from dataclasses import dataclass from datetime import datetime from types import SimpleNamespace @@ -956,10 +956,10 @@ def test_get_mcp_provider_controller_missing_raises(monkeypatch: pytest.MonkeyPa ToolManager.get_mcp_provider_controller("tenant-1", "mcp-1") -def test_generate_tool_icon_urls_for_builtin_and_plugin(): - with patch("core.tools.tool_manager.dify_config.CONSOLE_API_URL", "https://console.example.com"): - builtin_url = ToolManager.generate_builtin_tool_icon_url("time") - plugin_url = ToolManager.generate_plugin_tool_icon_url("tenant-1", "icon.svg") +def test_generate_tool_icon_urls_for_builtin_and_plugin(config_overrides: Callable[..., None]): + config_overrides(CONSOLE_API_URL="https://console.example.com") + builtin_url = ToolManager.generate_builtin_tool_icon_url("time") + plugin_url = ToolManager.generate_plugin_tool_icon_url("tenant-1", "icon.svg") assert builtin_url.endswith("/tool-provider/builtin/time/icon") assert "/plugin/icon" in plugin_url diff --git a/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py b/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py index 081b1897455..d715a2633e5 100644 --- a/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py +++ b/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py @@ -4,6 +4,7 @@ import pytest from core.tools.utils import system_encryption as encryption from core.tools.utils.system_encryption import EncryptionError, SystemEncrypter +from tests.unit_tests.config_override import apply_config_overrides def test_system_encrypter_roundtrip(): @@ -36,7 +37,7 @@ def test_system_encrypter_raises_error_for_invalid_ciphertext(): def test_system_helpers_use_global_cached_instance(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(encryption, "_encrypter", None) - monkeypatch.setattr("core.tools.utils.system_encryption.dify_config.SECRET_KEY", "global-secret") + apply_config_overrides(monkeypatch, SECRET_KEY="global-secret") first = encryption.get_system_encrypter() second = encryption.get_system_encrypter() diff --git a/api/tests/unit_tests/core/workflow/generator/test_runner.py b/api/tests/unit_tests/core/workflow/generator/test_runner.py index 35ed9f1ad5a..1a067ca2089 100644 --- a/api/tests/unit_tests/core/workflow/generator/test_runner.py +++ b/api/tests/unit_tests/core/workflow/generator/test_runner.py @@ -17,10 +17,10 @@ from unittest.mock import MagicMock, patch import pytest from jinja2 import Template -from configs import dify_config from core.workflow.generator.runner import WorkflowGenerator, _find_planned_tool_entry from core.workflow.generator.tool_catalogue import ToolCatalogueEntry from core.workflow.generator.types import GraphDict +from tests.unit_tests.config_override import apply_config_overrides def _llm_result(text: str) -> MagicMock: @@ -456,7 +456,7 @@ class _ParallelBuilderModel: class TestParallelNodeBuilder: def test_builder_concurrency_caps_at_configured_workers(self, monkeypatch): - monkeypatch.setattr(dify_config, "WORKFLOW_GENERATOR_NODE_BUILDER_MAX_WORKERS", 2) + apply_config_overrides(monkeypatch, WORKFLOW_GENERATOR_NODE_BUILDER_MAX_WORKERS=2) planner = { "title": "URL Summarizer", "description": "Summarize a URL.", @@ -512,7 +512,7 @@ class TestParallelNodeBuilder: assert [edge["source"] for edge in result["graph"]["edges"]] == ["node1", "node2"] def test_higher_worker_config_runs_all_builders_in_one_wave(self, monkeypatch): - monkeypatch.setattr(dify_config, "WORKFLOW_GENERATOR_NODE_BUILDER_MAX_WORKERS", 5) + apply_config_overrides(monkeypatch, WORKFLOW_GENERATOR_NODE_BUILDER_MAX_WORKERS=5) planner = { "title": "URL Summarizer", "description": "Summarize a URL.", @@ -561,7 +561,7 @@ class TestParallelNodeBuilder: # One worker: node1's builder fails immediately, node2's blocks the # worker briefly, node3 sits in the queue. The failure must cancel # node3 before the worker frees up — no LLM call for it at all. - monkeypatch.setattr(dify_config, "WORKFLOW_GENERATOR_NODE_BUILDER_MAX_WORKERS", 1) + apply_config_overrides(monkeypatch, WORKFLOW_GENERATOR_NODE_BUILDER_MAX_WORKERS=1) planner = { "title": "x", "description": "x", diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index e79e89c14f0..b60f69c46ed 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -39,6 +39,7 @@ from models.agent_config_entities import ( DeclaredOutputType, WorkflowNodeJobConfig, ) +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture(autouse=True) @@ -465,7 +466,7 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada def test_build_maps_agent_soul_shell_settings_to_shell_layer(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr("core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True) + apply_config_overrides(monkeypatch, AGENT_SHELL_ENABLED=True) context = _context() snapshot = AgentConfigSnapshot( id="snapshot-1", @@ -674,7 +675,7 @@ def test_build_shell_layer_config_maps_cli_tool_inline_secret_value_to_env(): def test_builds_workflow_run_request_with_dify_plugin_tools_layer(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr("core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True) + apply_config_overrides(monkeypatch, AGENT_SHELL_ENABLED=True) context = _context() snapshot = AgentConfigSnapshot( id="snapshot-1", @@ -1472,7 +1473,7 @@ def test_build_config_layer_config_returns_empty_config_for_empty_agent_soul(): def test_workflow_run_request_has_config_layer_with_empty_agent_soul(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr("core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True) + apply_config_overrides(monkeypatch, AGENT_SHELL_ENABLED=True) result = WorkflowAgentRuntimeRequestBuilder().build(_context()) diff --git a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py index a23a6236487..5feb62d45f4 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py @@ -22,6 +22,7 @@ from graphon.nodes import BuiltinNodeTypes from graphon.runtime import VariablePool from graphon.variables.variables import StringVariable from models.workflow import Workflow, WorkflowType +from tests.unit_tests.config_override import config_overrides_context def _build_typed_node_config(node_type: NodeType): @@ -99,8 +100,7 @@ class TestWorkflowEntryInit: observability_layer = sentinel.observability_layer with ( - patch.object(workflow_entry.dify_config, "DEBUG", True), - patch.object(workflow_entry.dify_config, "ENABLE_OTEL", False), + config_overrides_context(DEBUG=True, ENABLE_OTEL=False), patch.object(workflow_entry, "is_instrument_flag_enabled", return_value=True), patch.object(workflow_entry, "capture_current_context", return_value=sentinel.execution_context), patch.object(workflow_entry, "GraphEngine", return_value=graph_engine) as graph_engine_cls, diff --git a/api/tests/unit_tests/events/event_handlers/test_queue_default_plugin_install_when_tenant_created.py b/api/tests/unit_tests/events/event_handlers/test_queue_default_plugin_install_when_tenant_created.py index 67c8c8e827f..14303aef5d9 100644 --- a/api/tests/unit_tests/events/event_handlers/test_queue_default_plugin_install_when_tenant_created.py +++ b/api/tests/unit_tests/events/event_handlers/test_queue_default_plugin_install_when_tenant_created.py @@ -5,6 +5,7 @@ import pytest from events.event_handlers import queue_default_plugin_install_when_tenant_created as handler_module from models.account import Tenant +from tests.unit_tests.config_override import apply_config_overrides def _tenant() -> Tenant: @@ -15,7 +16,7 @@ def _tenant() -> Tenant: def test_handle_skips_when_no_default_plugins_are_configured(monkeypatch: pytest.MonkeyPatch) -> None: delay = MagicMock() - monkeypatch.setattr(handler_module.dify_config, "NEW_USER_DEFAULT_PLUGIN_IDS", "") + apply_config_overrides(monkeypatch, NEW_USER_DEFAULT_PLUGIN_IDS="") monkeypatch.setattr(handler_module.install_default_plugins_task, "delay", delay) handler_module.handle(_tenant()) @@ -29,7 +30,7 @@ def test_handle_queues_configured_plugins(monkeypatch: pytest.MonkeyPatch) -> No "langgenius/openai", "langgenius/gemini", ] - monkeypatch.setattr(handler_module.dify_config, "NEW_USER_DEFAULT_PLUGIN_IDS", ",".join(plugins)) + apply_config_overrides(monkeypatch, NEW_USER_DEFAULT_PLUGIN_IDS=",".join(plugins)) monkeypatch.setattr(handler_module.install_default_plugins_task, "delay", delay) handler_module.handle(_tenant()) @@ -41,11 +42,7 @@ def test_handle_does_not_fail_tenant_creation_when_queue_is_unavailable( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: - monkeypatch.setattr( - handler_module.dify_config, - "NEW_USER_DEFAULT_PLUGIN_IDS", - "langgenius/openai", - ) + apply_config_overrides(monkeypatch, NEW_USER_DEFAULT_PLUGIN_IDS="langgenius/openai") monkeypatch.setattr( handler_module.install_default_plugins_task, "delay", diff --git a/api/tests/unit_tests/extensions/otel/decorators/handlers/test_generate_handler.py b/api/tests/unit_tests/extensions/otel/decorators/handlers/test_generate_handler.py index b83602e98cf..c00fbaae36c 100644 --- a/api/tests/unit_tests/extensions/otel/decorators/handlers/test_generate_handler.py +++ b/api/tests/unit_tests/extensions/otel/decorators/handlers/test_generate_handler.py @@ -6,17 +6,18 @@ Test objectives: 2. Verify span attribute mapping correctness """ -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from core.app.entities.app_invoke_entities import InvokeFrom from extensions.otel.decorators.handlers.generate_handler import AppGenerateHandler from extensions.otel.semconv import DifySpanAttributes, GenAIAttributes +from tests.unit_tests.config_override import config_overrides_context class TestAppGenerateHandler: """Core tests for AppGenerateHandler""" - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) + @config_overrides_context(ENABLE_OTEL=True) def test_compatible_with_real_function_signature( self, tracer_provider_with_memory_exporter, mock_app_model, mock_account_user ): @@ -48,7 +49,7 @@ class TestAppGenerateHandler: assert "args" in arguments, "Handler uses args but parameter is missing" assert "streaming" in arguments, "Handler uses streaming but parameter is missing" - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) + @config_overrides_context(ENABLE_OTEL=True) def test_all_span_attributes_set_correctly( self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_app_model, mock_account_user ): diff --git a/api/tests/unit_tests/extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py b/api/tests/unit_tests/extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py index 842e7f55e2f..3e312a665eb 100644 --- a/api/tests/unit_tests/extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py +++ b/api/tests/unit_tests/extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py @@ -6,10 +6,9 @@ Test objectives: 2. Verify span attribute mapping correctness """ -from unittest.mock import patch - from extensions.otel.decorators.handlers.workflow_app_runner_handler import WorkflowAppRunnerHandler from extensions.otel.semconv import DifySpanAttributes, GenAIAttributes +from tests.unit_tests.config_override import config_overrides_context class TestWorkflowAppRunnerHandler: @@ -41,7 +40,7 @@ class TestWorkflowAppRunnerHandler: for field in required_config_fields: assert field in config_fields, f"Handler expects app_config.{field} but field is missing" - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) + @config_overrides_context(ENABLE_OTEL=True) def test_all_span_attributes_set_correctly( self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_workflow_runner ): diff --git a/api/tests/unit_tests/extensions/otel/decorators/test_base.py b/api/tests/unit_tests/extensions/otel/decorators/test_base.py index a42f861bb7a..5d8c8620ebd 100644 --- a/api/tests/unit_tests/extensions/otel/decorators/test_base.py +++ b/api/tests/unit_tests/extensions/otel/decorators/test_base.py @@ -8,7 +8,7 @@ Test coverage: - Integration with OpenTelemetry SDK """ -from unittest.mock import patch +from collections.abc import Callable import pytest from opentelemetry.trace import StatusCode @@ -16,10 +16,14 @@ from opentelemetry.trace import StatusCode from extensions.otel.decorators.base import trace_span +@pytest.fixture(autouse=True) +def _otel_enabled(config_overrides: Callable[..., None]) -> None: + config_overrides(ENABLE_OTEL=True) + + class TestTraceSpanDecorator: """Test trace_span decorator basic functionality.""" - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) def test_decorated_function_executes_normally(self, tracer_provider_with_memory_exporter): """Test that decorated function executes and returns correct value.""" @@ -30,7 +34,6 @@ class TestTraceSpanDecorator: result = test_func(2, 3) assert result == 5 - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) def test_decorator_with_args_and_kwargs(self, tracer_provider_with_memory_exporter): """Test that decorator correctly handles args and kwargs.""" @@ -45,7 +48,6 @@ class TestTraceSpanDecorator: class TestTraceSpanWithMemoryExporter: """Test trace_span with MemorySpanExporter to verify span creation.""" - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) def test_span_is_created_and_exported(self, tracer_provider_with_memory_exporter, memory_span_exporter): """Test that span is created and exported to memory exporter.""" @@ -58,7 +60,6 @@ class TestTraceSpanWithMemoryExporter: spans = memory_span_exporter.get_finished_spans() assert len(spans) == 1 - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) def test_span_name_matches_function(self, tracer_provider_with_memory_exporter, memory_span_exporter): """Test that span name matches the decorated function.""" @@ -72,7 +73,6 @@ class TestTraceSpanWithMemoryExporter: assert len(spans) == 1 assert "my_test_function" in spans[0].name - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) def test_span_status_is_ok_on_success(self, tracer_provider_with_memory_exporter, memory_span_exporter): """Test that span status is OK when function succeeds.""" @@ -86,7 +86,6 @@ class TestTraceSpanWithMemoryExporter: assert len(spans) == 1 assert spans[0].status.status_code == StatusCode.OK - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) def test_span_status_is_error_on_exception(self, tracer_provider_with_memory_exporter, memory_span_exporter): """Test that span status is ERROR when function raises exception.""" @@ -101,7 +100,6 @@ class TestTraceSpanWithMemoryExporter: assert len(spans) == 1 assert spans[0].status.status_code == StatusCode.ERROR - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) def test_exception_is_recorded_in_span(self, tracer_provider_with_memory_exporter, memory_span_exporter): """Test that exception details are recorded in span events.""" diff --git a/api/tests/unit_tests/extensions/otel/decorators/test_handler.py b/api/tests/unit_tests/extensions/otel/decorators/test_handler.py index bf861e3ef7b..e59f71deb9a 100644 --- a/api/tests/unit_tests/extensions/otel/decorators/test_handler.py +++ b/api/tests/unit_tests/extensions/otel/decorators/test_handler.py @@ -8,7 +8,7 @@ Test coverage: - Signature caching """ -from unittest.mock import patch +from collections.abc import Callable import pytest from opentelemetry.trace import StatusCode @@ -16,6 +16,11 @@ from opentelemetry.trace import StatusCode from extensions.otel.decorators.handler import SpanHandler +@pytest.fixture(autouse=True) +def _otel_enabled(config_overrides: Callable[..., None]) -> None: + config_overrides(ENABLE_OTEL=True) + + class TestSpanHandlerExtractArguments: """Test SpanHandler._extract_arguments method.""" @@ -133,7 +138,6 @@ class TestSpanHandlerExtractArguments: class TestSpanHandlerWrapper: """Test SpanHandler.wrapper default implementation.""" - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) def test_wrapper_creates_span(self, tracer_provider_with_memory_exporter, memory_span_exporter): """Test that wrapper creates a span.""" handler = SpanHandler() @@ -148,7 +152,6 @@ class TestSpanHandlerWrapper: spans = memory_span_exporter.get_finished_spans() assert len(spans) == 1 - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) def test_wrapper_sets_span_kind_internal(self, tracer_provider_with_memory_exporter, memory_span_exporter): """Test that wrapper sets SpanKind to INTERNAL.""" from opentelemetry.trace import SpanKind @@ -165,7 +168,6 @@ class TestSpanHandlerWrapper: assert len(spans) == 1 assert spans[0].kind == SpanKind.INTERNAL - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) def test_wrapper_sets_status_ok_on_success(self, tracer_provider_with_memory_exporter, memory_span_exporter): """Test that wrapper sets status to OK when function succeeds.""" handler = SpanHandler() @@ -180,7 +182,6 @@ class TestSpanHandlerWrapper: assert len(spans) == 1 assert spans[0].status.status_code == StatusCode.OK - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) def test_wrapper_records_exception_on_error(self, tracer_provider_with_memory_exporter, memory_span_exporter): """Test that wrapper records exception when function raises.""" handler = SpanHandler() @@ -198,7 +199,6 @@ class TestSpanHandlerWrapper: assert len(events) > 0 assert any("exception" in event.name.lower() for event in events) - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) def test_wrapper_sets_status_error_on_exception(self, tracer_provider_with_memory_exporter, memory_span_exporter): """Test that wrapper sets status to ERROR when function raises exception.""" handler = SpanHandler() @@ -215,7 +215,6 @@ class TestSpanHandlerWrapper: assert spans[0].status.status_code == StatusCode.ERROR assert "test error" in spans[0].status.description - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) def test_wrapper_re_raises_exception(self, tracer_provider_with_memory_exporter): """Test that wrapper re-raises exception after recording it.""" handler = SpanHandler() @@ -227,7 +226,6 @@ class TestSpanHandlerWrapper: with pytest.raises(ValueError, match="test error"): handler.wrapper(tracer, test_func) - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) def test_wrapper_passes_arguments_correctly(self, tracer_provider_with_memory_exporter, memory_span_exporter): """Test that wrapper correctly passes arguments to wrapped function.""" handler = SpanHandler() @@ -240,7 +238,6 @@ class TestSpanHandlerWrapper: assert result == 6 - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) def test_wrapper_with_memory_exporter(self, tracer_provider_with_memory_exporter, memory_span_exporter): """Test wrapper end-to-end with memory exporter.""" handler = SpanHandler() diff --git a/api/tests/unit_tests/extensions/otel/test_retrieval_tracing.py b/api/tests/unit_tests/extensions/otel/test_retrieval_tracing.py index 824028316fe..bdd418e47e4 100644 --- a/api/tests/unit_tests/extensions/otel/test_retrieval_tracing.py +++ b/api/tests/unit_tests/extensions/otel/test_retrieval_tracing.py @@ -1,8 +1,11 @@ import threading -from unittest.mock import MagicMock, patch +from collections.abc import Callable +from unittest.mock import patch from uuid import uuid4 +import pytest from opentelemetry.trace import StatusCode, get_current_span, get_tracer +from sqlalchemy.orm import Session from core.rag.rerank.rerank_type import RerankMode from core.rag.retrieval.dataset_retrieval import DatasetRetrieval @@ -10,9 +13,15 @@ from core.workflow.nodes.knowledge_retrieval.retrieval import KnowledgeRetrieval from models.dataset import Dataset +@pytest.fixture(autouse=True) +def _otel_enabled(config_overrides: Callable[..., None]) -> None: + config_overrides(ENABLE_OTEL=True) + + def test_knowledge_retrieval_creates_a_child_otel_span( memory_span_exporter, tracer_provider_with_memory_exporter, + sqlite_session: Session, ) -> None: """The retrieval entry point must be visible beneath its workflow node span.""" request = KnowledgeRetrievalRequest( @@ -27,12 +36,11 @@ def test_knowledge_retrieval_creates_a_child_otel_span( retrieval = DatasetRetrieval() with ( - patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True), patch.object(retrieval, "_check_knowledge_rate_limit"), patch.object(retrieval, "_get_available_datasets", return_value=[]), get_tracer(__name__).start_as_current_span("knowledge-retrieval-node") as node_span, ): - assert retrieval.knowledge_retrieval(MagicMock(), request) == [] + assert retrieval.knowledge_retrieval(sqlite_session, request) == [] retrieval_span = next( span @@ -64,7 +72,6 @@ def test_multiple_retrieve_preserves_otel_context_in_dataset_thread( with ( app.app_context(), - patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True), patch.object(retrieval, "_multiple_retrieve_thread", side_effect=record_active_trace), patch.object(retrieval, "_on_query"), get_tracer(__name__).start_as_current_span("knowledge-retrieval-node") as node_span, @@ -96,8 +103,6 @@ def test_retriever_thread_exception_sets_error_span_and_is_collected( expected_error = RuntimeError("retrieval failed") with ( - patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True), - patch("core.rag.retrieval.dataset_retrieval.session_factory.create_session"), patch.object(retrieval, "_retriever", side_effect=expected_error), ): retrieval._run_retriever_thread_safely( @@ -135,8 +140,6 @@ def test_retriever_thread_exception_emits_skip_event_when_requested( dataset_id = str(uuid4()) with ( - patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True), - patch("core.rag.retrieval.dataset_retrieval.session_factory.create_session"), patch.object(retrieval, "_retriever", side_effect=expected_error), get_tracer(__name__).start_as_current_span("dataset-retrieval-parent") as parent_span, ): diff --git a/api/tests/unit_tests/extensions/otel/test_runtime.py b/api/tests/unit_tests/extensions/otel/test_runtime.py index d1038d30eb9..1bf4aff293a 100644 --- a/api/tests/unit_tests/extensions/otel/test_runtime.py +++ b/api/tests/unit_tests/extensions/otel/test_runtime.py @@ -6,6 +6,7 @@ from opentelemetry.sdk.trace import TracerProvider from core.logging.context import clear_request_context from models import Account +from tests.unit_tests.config_override import config_overrides_context def _user() -> Account: @@ -29,7 +30,7 @@ def test_on_user_loaded_does_not_write_to_non_recording_span() -> None: user = _user() with ( - mock.patch.object(runtime.dify_config, "ENABLE_OTEL", True), + config_overrides_context(ENABLE_OTEL=True), mock.patch("opentelemetry.trace.get_current_span", return_value=span), mock.patch.object(runtime, "extract_tenant_id", return_value="tenant-id"), ): @@ -49,7 +50,7 @@ def test_on_user_loaded_sets_attributes_on_recording_span() -> None: user = _user() with ( - mock.patch.object(runtime.dify_config, "ENABLE_OTEL", True), + config_overrides_context(ENABLE_OTEL=True), mock.patch("opentelemetry.trace.get_current_span", return_value=span), mock.patch.object(runtime, "extract_tenant_id", return_value="tenant-id"), ): @@ -73,7 +74,7 @@ def test_on_user_loaded_ignores_ended_sdk_span(caplog) -> None: with ( trace.use_span(span, end_on_exit=False), - mock.patch.object(runtime.dify_config, "ENABLE_OTEL", True), + config_overrides_context(ENABLE_OTEL=True), mock.patch.object(runtime, "extract_tenant_id", return_value="tenant-id"), caplog.at_level("WARNING", logger="opentelemetry.sdk.trace"), ): diff --git a/api/tests/unit_tests/extensions/test_ext_application_services.py b/api/tests/unit_tests/extensions/test_ext_application_services.py index e5cf0b3b82c..191f13cf7e3 100644 --- a/api/tests/unit_tests/extensions/test_ext_application_services.py +++ b/api/tests/unit_tests/extensions/test_ext_application_services.py @@ -32,6 +32,12 @@ from services.account_activation_adapters import ( RegisterServiceInvitationTokenStore, ) from services.account_avatar_file_gateway import SQLAlchemyAccountAvatarFileGateway +from services.account_email_registration_adapters import ( + AccountServiceRegistrationGateway, + BillingAccountRegistrationPolicyGateway, + RedisEmailRegistrationSecurityGateway, + TokenManagerEmailRegistrationTokenGateway, +) from services.app_site_service import AppSiteService from services.auth.data_source_api_key_auth_service import DataSourceApiKeyAuthService from services.billing_portal_service import BillingPortalService @@ -46,6 +52,7 @@ from services.retention.workflow_run.archive_log_service import WorkflowRunArchi from services.tag_application_service import TagApplicationService from services.webapp_access_query_service import WebAppAccessUnavailableError from services.workflow_statistic_query_service import WorkflowStatisticQueryService +from tests.unit_tests.config_override import apply_config_overrides @pytest.mark.parametrize( @@ -103,12 +110,11 @@ def test_init_app_registers_services_for_the_current_app( ) -> None: app = Flask(__name__) monkeypatch.setattr(ext_application_services, "get_session_maker", lambda: sqlite_session_factory) - monkeypatch.setattr( - ext_application_services.dify_config, - "DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, + apply_config_overrides( + monkeypatch, + DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, + INIT_PASSWORD="expected", ) - monkeypatch.setattr(ext_application_services.dify_config, "INIT_PASSWORD", "expected") ext_application_services.init_app(app) @@ -367,8 +373,17 @@ def test_build_application_services_wires_account_profile_repository( assert services.accounts.initialization._accounts is accounts assert not services.accounts.initialization._invitation_required assert services.accounts.change_email._accounts is accounts + email_registration = services.accounts.email_registration + assert email_registration._accounts is accounts + assert isinstance(email_registration._tokens, TokenManagerEmailRegistrationTokenGateway) + assert isinstance(email_registration._security, RedisEmailRegistrationSecurityGateway) + assert isinstance(email_registration._account_policy, BillingAccountRegistrationPolicyGateway) + assert isinstance(email_registration._registration, AccountServiceRegistrationGateway) + assert email_registration._registration._session_factory is sqlite_session_factory assert services.accounts.education._accounts is accounts assert services.accounts.deletion._accounts is accounts + assert services.notifications._accounts is accounts + assert services.step_by_step_tour._accounts is accounts assert services.accounts.deletion._memberships is services.workspace_queries._workspaces integrations = services.accounts.integrations._integrations assert isinstance(integrations, SQLAlchemyAccountIntegrationRepository) @@ -618,7 +633,7 @@ def test_build_application_services_wires_dynamic_recommended_catalog( sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(ext_application_services.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "builtin") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="builtin") services = ext_application_services.build_application_services( database_client=sqlite_session_factory, deployment_edition=DeploymentEdition.COMMUNITY, @@ -643,7 +658,7 @@ def test_build_application_services_wires_dynamic_recommended_catalog( ) assert result.recommended_apps - monkeypatch.setattr(ext_application_services.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "invalid") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="invalid") with pytest.raises(ValueError, match="invalid fetch recommended apps mode: invalid"): services.recommended_app_queries.list_recommended( requested_language="en-US", diff --git a/api/tests/unit_tests/extensions/test_ext_blueprints_openapi.py b/api/tests/unit_tests/extensions/test_ext_blueprints_openapi.py index 2a009573569..6b49f837e69 100644 --- a/api/tests/unit_tests/extensions/test_ext_blueprints_openapi.py +++ b/api/tests/unit_tests/extensions/test_ext_blueprints_openapi.py @@ -32,9 +32,9 @@ from collections.abc import Iterator import pytest from flask import Blueprint, Response -from configs import dify_config from dify_app import DifyApp from extensions import ext_blueprints +from tests.unit_tests.config_override import apply_config_overrides # Modules whose `bp` attribute is consumed by `ext_blueprints.init_app`. # Keep in sync with the imports inside `init_app`. @@ -98,7 +98,7 @@ def test_openapi_blueprint_registered_with_cors_when_enabled( fresh_blueprints: dict[str, Blueprint], ) -> None: """Enabled gate: blueprint mounted, CORS wired, `/openapi/v1/*` rules live.""" - monkeypatch.setattr(dify_config, "OPENAPI_ENABLED", True) + apply_config_overrides(monkeypatch, OPENAPI_ENABLED=True) app = _build_app() ext_blueprints.init_app(app) @@ -119,7 +119,7 @@ def test_openapi_blueprint_absent_when_disabled( fresh_blueprints: dict[str, Blueprint], ) -> None: """Disabled gate: no blueprint, no CORS, no `/openapi/v1/*` URL rules.""" - monkeypatch.setattr(dify_config, "OPENAPI_ENABLED", False) + apply_config_overrides(monkeypatch, OPENAPI_ENABLED=False) app = _build_app() ext_blueprints.init_app(app) diff --git a/api/tests/unit_tests/extensions/test_ext_login.py b/api/tests/unit_tests/extensions/test_ext_login.py index d58e7f52e56..1125d2311ad 100644 --- a/api/tests/unit_tests/extensions/test_ext_login.py +++ b/api/tests/unit_tests/extensions/test_ext_login.py @@ -1,4 +1,5 @@ import json +from collections.abc import Callable from typing import cast from unittest import mock @@ -89,7 +90,7 @@ def test_on_user_logged_in_logs_unsupported_user_type(caplog: pytest.LogCaptureF def test_admin_api_key_header_takes_precedence_over_console_cookie( - monkeypatch: pytest.MonkeyPatch, sqlite_session: Session + sqlite_session: Session, config_overrides: Callable[..., None] ) -> None: app = Flask(__name__) tenant = ext_login.Tenant(name="Test Tenant") @@ -104,11 +105,13 @@ def test_admin_api_key_header_takes_precedence_over_console_cookie( ) sqlite_session.add(tenant_account_join) sqlite_session.commit() - monkeypatch.setattr(ext_login.dify_config, "ADMIN_API_KEY_ENABLE", True) - monkeypatch.setattr(ext_login.dify_config, "ADMIN_API_KEY", "admin-key") - monkeypatch.setattr(ext_login.dify_config, "CONSOLE_WEB_URL", "http://console.example.com") - monkeypatch.setattr(ext_login.dify_config, "CONSOLE_API_URL", "http://api.example.com") - monkeypatch.setattr(ext_login.dify_config, "COOKIE_DOMAIN", "") + config_overrides( + ADMIN_API_KEY_ENABLE=True, + ADMIN_API_KEY="admin-key", + CONSOLE_WEB_URL="http://console.example.com", + CONSOLE_API_URL="http://api.example.com", + COOKIE_DOMAIN="", + ) with app.test_request_context( "/console/api/test", diff --git a/api/tests/unit_tests/extensions/test_ext_request_logging.py b/api/tests/unit_tests/extensions/test_ext_request_logging.py index 664de8cbd8b..38787ac63a2 100644 --- a/api/tests/unit_tests/extensions/test_ext_request_logging.py +++ b/api/tests/unit_tests/extensions/test_ext_request_logging.py @@ -6,9 +6,9 @@ from unittest.mock import MagicMock import pytest from flask import Flask, Response -from configs import dify_config from extensions import ext_request_logging from extensions.ext_request_logging import _is_content_type_json, _log_request_finished, init_app +from tests.unit_tests.config_override import apply_config_overrides def test_is_content_type_json(): @@ -59,7 +59,7 @@ def mock_response_receiver(monkeypatch: pytest.MonkeyPatch) -> mock.Mock: @pytest.fixture def enable_request_logging(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(dify_config, "ENABLE_REQUEST_LOGGING", True) + apply_config_overrides(monkeypatch, ENABLE_REQUEST_LOGGING=True) def _captured_records(caplog: pytest.LogCaptureFixture, level: int) -> list[logging.LogRecord]: @@ -77,7 +77,7 @@ class TestRequestLoggingExtension: mock_request_receiver: MagicMock, mock_response_receiver: MagicMock, ): - monkeypatch.setattr(dify_config, "ENABLE_REQUEST_LOGGING", False) + apply_config_overrides(monkeypatch, ENABLE_REQUEST_LOGGING=False) app = _get_test_app() init_app(app) diff --git a/api/tests/unit_tests/extensions/test_ext_socketio.py b/api/tests/unit_tests/extensions/test_ext_socketio.py index a5851b80932..d61d520fb6f 100644 --- a/api/tests/unit_tests/extensions/test_ext_socketio.py +++ b/api/tests/unit_tests/extensions/test_ext_socketio.py @@ -1,6 +1,6 @@ import ssl +from collections.abc import Callable -import pytest import socketio from configs import dify_config @@ -11,9 +11,10 @@ def test_socketio_server_uses_redis_manager() -> None: assert isinstance(ext_socketio.sio.manager, socketio.RedisManager) -def test_create_socketio_client_manager_uses_pubsub_url_and_prefixed_channel(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(ext_socketio.dify_config, "PUBSUB_REDIS_URL", "redis://redis.example.com:6380/3") - monkeypatch.setattr(ext_socketio.dify_config, "REDIS_KEY_PREFIX", "tenant-a") +def test_create_socketio_client_manager_uses_pubsub_url_and_prefixed_channel( + config_overrides: Callable[..., None], +) -> None: + config_overrides(PUBSUB_REDIS_URL="redis://redis.example.com:6380/3", REDIS_KEY_PREFIX="tenant-a") manager = ext_socketio.create_socketio_client_manager() @@ -21,11 +22,13 @@ def test_create_socketio_client_manager_uses_pubsub_url_and_prefixed_channel(mon assert manager.channel == "tenant-a:socketio" -def test_build_redis_options_includes_tls_options_for_rediss(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(ext_socketio.dify_config, "REDIS_SSL_CERT_REQS", "CERT_REQUIRED") - monkeypatch.setattr(ext_socketio.dify_config, "REDIS_SSL_CA_CERTS", "/ca.pem") - monkeypatch.setattr(ext_socketio.dify_config, "REDIS_SSL_CERTFILE", "/cert.pem") - monkeypatch.setattr(ext_socketio.dify_config, "REDIS_SSL_KEYFILE", "/key.pem") +def test_build_redis_options_includes_tls_options_for_rediss(config_overrides: Callable[..., None]) -> None: + config_overrides( + REDIS_SSL_CERT_REQS="CERT_REQUIRED", + REDIS_SSL_CA_CERTS="/ca.pem", + REDIS_SSL_CERTFILE="/cert.pem", + REDIS_SSL_KEYFILE="/key.pem", + ) options = ext_socketio._build_redis_options("rediss://redis.example.com:6380/3") @@ -35,11 +38,11 @@ def test_build_redis_options_includes_tls_options_for_rediss(monkeypatch: pytest assert options["ssl_keyfile"] == "/key.pem" -def test_build_redis_options_omits_socket_timeout(monkeypatch: pytest.MonkeyPatch) -> None: +def test_build_redis_options_omits_socket_timeout(config_overrides: Callable[..., None]) -> None: # socket_timeout must not be passed to RedisManager because the pub/sub # listen loop blocks indefinitely between messages; a read timeout there # triggers an infinite reconnect storm (issue #39423). - monkeypatch.setattr(ext_socketio.dify_config, "REDIS_SOCKET_TIMEOUT", 5.0) + config_overrides(REDIS_SOCKET_TIMEOUT=5.0) options = ext_socketio._build_redis_options("redis://redis.example.com:6380/3") diff --git a/api/tests/unit_tests/extensions/test_pubsub_channel.py b/api/tests/unit_tests/extensions/test_pubsub_channel.py index 2884509d22b..c63e29c3cbd 100644 --- a/api/tests/unit_tests/extensions/test_pubsub_channel.py +++ b/api/tests/unit_tests/extensions/test_pubsub_channel.py @@ -1,13 +1,13 @@ import pytest -from configs import dify_config from extensions import ext_redis from libs.broadcast_channel.redis.pubsub_channel import BroadcastChannel as RedisBroadcastChannel from libs.broadcast_channel.redis.sharded_channel import ShardedRedisBroadcastChannel +from tests.unit_tests.config_override import apply_config_overrides def test_get_pubsub_broadcast_channel_defaults_to_pubsub(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "pubsub") + apply_config_overrides(monkeypatch, PUBSUB_REDIS_CHANNEL_TYPE="pubsub") monkeypatch.setattr(ext_redis, "_pubsub_redis_client", object()) channel = ext_redis.get_pubsub_broadcast_channel() @@ -16,7 +16,7 @@ def test_get_pubsub_broadcast_channel_defaults_to_pubsub(monkeypatch: pytest.Mon def test_get_pubsub_broadcast_channel_sharded(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "sharded") + apply_config_overrides(monkeypatch, PUBSUB_REDIS_CHANNEL_TYPE="sharded") monkeypatch.setattr(ext_redis, "_pubsub_redis_client", object()) channel = ext_redis.get_pubsub_broadcast_channel() diff --git a/api/tests/unit_tests/extensions/test_set_secretkey.py b/api/tests/unit_tests/extensions/test_set_secretkey.py index 8a8e4e2b190..b49ff7107b8 100644 --- a/api/tests/unit_tests/extensions/test_set_secretkey.py +++ b/api/tests/unit_tests/extensions/test_set_secretkey.py @@ -4,6 +4,7 @@ import pytest from flask import Flask from extensions import ext_set_secretkey +from tests.unit_tests.config_override import apply_config_overrides class InMemoryStorage: @@ -25,7 +26,7 @@ class InMemoryStorage: def test_init_app_uses_configured_secret_key(monkeypatch: pytest.MonkeyPatch) -> None: secret_key = "configured-secret-key" storage = InMemoryStorage() - monkeypatch.setattr("extensions.ext_set_secretkey.dify_config.SECRET_KEY", secret_key) + apply_config_overrides(monkeypatch, SECRET_KEY=secret_key) monkeypatch.setattr("configs.secret_key.storage", storage) app = Flask(__name__) app.config["SECRET_KEY"] = secret_key @@ -41,7 +42,7 @@ def test_init_app_generates_and_persists_secret_key_when_missing( monkeypatch: pytest.MonkeyPatch, ) -> None: storage = InMemoryStorage() - monkeypatch.setattr("extensions.ext_set_secretkey.dify_config.SECRET_KEY", "") + apply_config_overrides(monkeypatch, SECRET_KEY="") monkeypatch.setattr("configs.secret_key.storage", storage) app = Flask(__name__) app.config["SECRET_KEY"] = "" @@ -61,7 +62,7 @@ def test_init_app_reuses_persisted_secret_key_when_missing( ) -> None: persisted_key = "persisted-secret-key" storage = InMemoryStorage({".dify_secret_key": f"{persisted_key}\n".encode()}) - monkeypatch.setattr("extensions.ext_set_secretkey.dify_config.SECRET_KEY", "") + apply_config_overrides(monkeypatch, SECRET_KEY="") monkeypatch.setattr("configs.secret_key.storage", storage) app = Flask(__name__) app.config["SECRET_KEY"] = "" diff --git a/api/tests/unit_tests/libs/key_providers/test_azure_keyvault_key_provider.py b/api/tests/unit_tests/libs/key_providers/test_azure_keyvault_key_provider.py index 748785371f7..cd8962e5b4f 100644 --- a/api/tests/unit_tests/libs/key_providers/test_azure_keyvault_key_provider.py +++ b/api/tests/unit_tests/libs/key_providers/test_azure_keyvault_key_provider.py @@ -8,6 +8,7 @@ decryption of tokens encrypted before the rotation. import datetime import json +from collections.abc import Callable from types import SimpleNamespace from unittest.mock import MagicMock @@ -15,7 +16,6 @@ import pytest from azure.core.exceptions import HttpResponseError from azure.keyvault.keys import KeyRotationPolicy -from configs import dify_config from libs.key_providers.azure_keyvault_key_provider import AzureKeyVaultKeyProvider @@ -120,15 +120,17 @@ def fake_key_client(monkeypatch: pytest.MonkeyPatch) -> FakeKeyClient: @pytest.fixture(autouse=True) -def azure_keyvault_config(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(dify_config, "AZURE_KEYVAULT_VAULT_URL", "https://fake-vault.vault.azure.net") - monkeypatch.setattr(dify_config, "AZURE_KEYVAULT_KEY_SIZE", 2048) - monkeypatch.setattr(dify_config, "AZURE_KEYVAULT_ROTATION_INTERVAL_DAYS", None) +def azure_keyvault_config(config_overrides: Callable[..., None]) -> None: + config_overrides( + AZURE_KEYVAULT_VAULT_URL="https://fake-vault.vault.azure.net", + AZURE_KEYVAULT_KEY_SIZE=2048, + AZURE_KEYVAULT_ROTATION_INTERVAL_DAYS=None, + ) @pytest.mark.usefixtures("fake_key_client") -def test_missing_vault_url_raises(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(dify_config, "AZURE_KEYVAULT_VAULT_URL", None) +def test_missing_vault_url_raises(config_overrides: Callable[..., None]) -> None: + config_overrides(AZURE_KEYVAULT_VAULT_URL=None) with pytest.raises(ValueError, match="AZURE_KEYVAULT_VAULT_URL"): AzureKeyVaultKeyProvider() @@ -182,9 +184,9 @@ def test_generate_key_pair_without_rotation_interval_does_not_set_policy(fake_ke def test_generate_key_pair_with_rotation_interval_sets_time_after_create_only( - monkeypatch: pytest.MonkeyPatch, fake_key_client: FakeKeyClient + config_overrides: Callable[..., None], fake_key_client: FakeKeyClient ) -> None: - monkeypatch.setattr(dify_config, "AZURE_KEYVAULT_ROTATION_INTERVAL_DAYS", 30) + config_overrides(AZURE_KEYVAULT_ROTATION_INTERVAL_DAYS=30) provider = AzureKeyVaultKeyProvider() provider.generate_key_pair("tenant-1") diff --git a/api/tests/unit_tests/libs/test_archive_storage.py b/api/tests/unit_tests/libs/test_archive_storage.py index f42bc63d5f4..4c0d51a1907 100644 --- a/api/tests/unit_tests/libs/test_archive_storage.py +++ b/api/tests/unit_tests/libs/test_archive_storage.py @@ -12,6 +12,7 @@ from libs.archive_storage import ( ArchiveStorageError, ArchiveStorageNotConfiguredError, ) +from tests.unit_tests.config_override import apply_config_overrides BUCKET_NAME = "archive-bucket" @@ -26,8 +27,7 @@ def _configure_storage(monkeypatch: pytest.MonkeyPatch, **overrides): "ARCHIVE_STORAGE_REGION": "auto", } defaults.update(overrides) - for key, value in defaults.items(): - monkeypatch.setattr(storage_module.dify_config, key, value, raising=False) + apply_config_overrides(monkeypatch, **defaults) def _client_error(code: str) -> ClientError: diff --git a/api/tests/unit_tests/libs/test_login.py b/api/tests/unit_tests/libs/test_login.py index 8155dbd4c9d..420b640eb0c 100644 --- a/api/tests/unit_tests/libs/test_login.py +++ b/api/tests/unit_tests/libs/test_login.py @@ -10,6 +10,7 @@ import libs.login as login_module from extensions.ext_login import DifyLoginManager from libs.login import current_user from models.account import Account, Tenant +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture @@ -72,7 +73,7 @@ def login_app(mocker: MockerFixture) -> Flask: @pytest.fixture(autouse=True) def reset_login_disabled(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(login_module.dify_config, "LOGIN_DISABLED", False) + apply_config_overrides(monkeypatch, LOGIN_DISABLED=False) @pytest.fixture @@ -184,7 +185,7 @@ class TestLoginRequired: """Test that bypass conditions skip auth lookup, CSRF, and unauthorized handling.""" resolve_user = resolve_current_user(MockUser("test_user")) - monkeypatch.setattr(login_module.dify_config, "LOGIN_DISABLED", login_disabled) + apply_config_overrides(monkeypatch, LOGIN_DISABLED=login_disabled) with login_app.test_request_context(method=method): result = protected_view() diff --git a/api/tests/unit_tests/libs/test_workspace_member_helper.py b/api/tests/unit_tests/libs/test_workspace_member_helper.py index d35a83e6430..6a202e79fa4 100644 --- a/api/tests/unit_tests/libs/test_workspace_member_helper.py +++ b/api/tests/unit_tests/libs/test_workspace_member_helper.py @@ -16,6 +16,7 @@ from enums import DeploymentEdition from libs import oauth_bearer from libs.oauth_bearer import AuthContext, Scope, SubjectType, TokenType, require_workspace_member from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole +from tests.unit_tests.config_override import apply_config_overrides pytestmark = pytest.mark.usefixtures("community_edition") @@ -47,7 +48,7 @@ def database(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Iterat @pytest.fixture def community_edition(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(oauth_bearer.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) def _ctx( @@ -100,7 +101,7 @@ def _account(account_id: uuid.UUID, *, status: AccountStatus = AccountStatus.ACT def test_skips_for_enterprise_edition(database: Database, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(oauth_bearer.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) before = len(database.statements) require_workspace_member(_ctx(), "tenant-1") diff --git a/api/tests/unit_tests/models/test_dataset_models.py b/api/tests/unit_tests/models/test_dataset_models.py index e724b6e0e86..5c058c42b10 100644 --- a/api/tests/unit_tests/models/test_dataset_models.py +++ b/api/tests/unit_tests/models/test_dataset_models.py @@ -47,6 +47,7 @@ from models.enums import ( SegmentStatus, ) from models.model import App, AppMode, IconType, UploadFile +from tests.unit_tests.config_override import apply_config_overrides def _make_dataset( @@ -1171,9 +1172,12 @@ class TestDocumentSegmentIndexing: monkeypatch.setattr("models.dataset.time.time", lambda: 1700000000) monkeypatch.setattr("models.dataset.os.urandom", lambda _: b"\x01" * 16) - monkeypatch.setattr("models.dataset.dify_config.SECRET_KEY", "unit-secret") - monkeypatch.setattr("models.dataset.dify_config.FILES_URL", "https://files.example.com") - monkeypatch.setattr("models.dataset.dify_config.CONSOLE_API_URL", "https://console.example.com") + apply_config_overrides( + monkeypatch, + SECRET_KEY="unit-secret", + FILES_URL="https://files.example.com", + CONSOLE_API_URL="https://console.example.com", + ) # Act attachments = segment.get_attachments(session=sqlite_session) diff --git a/api/tests/unit_tests/models/test_workflow.py b/api/tests/unit_tests/models/test_workflow.py index ed6d4ed88c7..4e77e3e80cb 100644 --- a/api/tests/unit_tests/models/test_workflow.py +++ b/api/tests/unit_tests/models/test_workflow.py @@ -175,7 +175,7 @@ def test_to_dict(): @pytest.mark.parametrize("sqlite_session", [(Workflow, Account)], indirect=True) -def test_workflow_account_getters_use_caller_session(sqlite_session: Session): +def test_workflow_account_accessors_use_caller_session(sqlite_session: Session): created_account = Account(name="Created Account", email="created@example.com") created_account.id = "created-account-id" updated_account = Account(name="Updated Account", email="updated@example.com") @@ -197,12 +197,12 @@ def test_workflow_account_getters_use_caller_session(sqlite_session: Session): sqlite_session.add_all([decoy_account, updated_account, workflow, created_account]) sqlite_session.flush() - assert workflow.get_created_by_account(session=sqlite_session) is created_account - assert workflow.get_updated_by_account(session=sqlite_session) is updated_account + assert workflow.created_by_account(sqlite_session) is created_account + assert workflow.updated_by_account(sqlite_session) is updated_account @pytest.mark.parametrize("sqlite_session", [(Workflow, WorkflowToolProvider)], indirect=True) -def test_workflow_tool_published_getter_uses_caller_session(sqlite_session: Session): +def test_workflow_tool_published_accessor_uses_caller_session(sqlite_session: Session): workflow = Workflow( tenant_id="tenant_id", app_id="app_id", @@ -237,7 +237,8 @@ def test_workflow_tool_published_getter_uses_caller_session(sqlite_session: Sess sqlite_session.add_all([decoy_provider, workflow, matching_provider]) sqlite_session.flush() - assert workflow.get_tool_published(session=sqlite_session) is True + with pytest.warns(DeprecationWarning, match="not accurate"): + assert workflow.tool_published(sqlite_session) is True def test_normalize_environment_variable_mappings_converts_full_mask_to_hidden_value(): diff --git a/api/tests/unit_tests/models/test_workflow_app_log_models.py b/api/tests/unit_tests/models/test_workflow_app_log_models.py new file mode 100644 index 00000000000..9e06e069190 --- /dev/null +++ b/api/tests/unit_tests/models/test_workflow_app_log_models.py @@ -0,0 +1,80 @@ +"""Regression coverage for ``models.workflow.WorkflowAppLog`` account accessors. + +Ensures the ``@property``→session-parameter refactor preserves the role-based dispatch: +``created_by_account`` looks up an Account only when role is ACCOUNT; ``created_by_end_user`` +looks up an EndUser only when role is END_USER. + +Both accessors are exercised against the real ``sqlite_session`` fixture (a genuine +SQLAlchemy ``Session`` bound to a pristine full-schema SQLite database) so the assertions +cover actual query behaviour rather than a mock's recorded call. +""" + +from sqlalchemy.orm import Session + +from models.account import Account +from models.enums import CreatorUserRole, EndUserType +from models.model import EndUser +from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom + + +def _log(role: CreatorUserRole, created_by: str) -> WorkflowAppLog: + """Construct a WorkflowAppLog without touching the database.""" + return WorkflowAppLog( + tenant_id="00000000-0000-0000-0000-000000000001", + app_id="00000000-0000-0000-0000-000000000002", + workflow_id="00000000-0000-0000-0000-000000000003", + workflow_run_id="00000000-0000-0000-0000-000000000004", + created_from=WorkflowAppLogCreatedFrom.WEB_APP, + created_by_role=role, + created_by=created_by, + ) + + +class TestCreatedByAccount: + def test_returns_account_lookup_when_role_is_account(self, sqlite_session: Session) -> None: + account = Account(name="Test Account", email="test@example.com") + sqlite_session.add(account) + sqlite_session.flush() + log = _log(CreatorUserRole.ACCOUNT, created_by=account.id) + + result = log.created_by_account(session=sqlite_session) + + assert result is not None + assert result.id == account.id + + def test_returns_none_when_role_is_end_user(self, sqlite_session: Session) -> None: + account = Account(name="Test Account", email="test@example.com") + sqlite_session.add(account) + sqlite_session.flush() + log = _log(CreatorUserRole.END_USER, created_by=account.id) + + assert log.created_by_account(session=sqlite_session) is None + + +class TestCreatedByEndUser: + def test_returns_end_user_lookup_when_role_is_end_user(self, sqlite_session: Session) -> None: + end_user = EndUser( + tenant_id="00000000-0000-0000-0000-000000000001", + type=EndUserType.BROWSER, + session_id="session-1", + ) + sqlite_session.add(end_user) + sqlite_session.flush() + log = _log(CreatorUserRole.END_USER, created_by=end_user.id) + + result = log.created_by_end_user(session=sqlite_session) + + assert result is not None + assert result.id == end_user.id + + def test_returns_none_when_role_is_account(self, sqlite_session: Session) -> None: + end_user = EndUser( + tenant_id="00000000-0000-0000-0000-000000000001", + type=EndUserType.BROWSER, + session_id="session-1", + ) + sqlite_session.add(end_user) + sqlite_session.flush() + log = _log(CreatorUserRole.ACCOUNT, created_by=end_user.id) + + assert log.created_by_end_user(session=sqlite_session) is None diff --git a/api/tests/unit_tests/repositories/test_account_repository.py b/api/tests/unit_tests/repositories/test_account_repository.py index daab5e6e7c3..945d2c7ddd6 100644 --- a/api/tests/unit_tests/repositories/test_account_repository.py +++ b/api/tests/unit_tests/repositories/test_account_repository.py @@ -97,6 +97,20 @@ def test_account_repository_updates_password( assert persisted.password_salt == "new-salt" +def test_account_repository_finds_email_with_lowercase_fallback( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + _persist_account(sqlite_session) + repository = SQLAlchemyAccountRepository(sqlite_session_factory) + + account = repository.find_by_email("Account@Example.com") + + assert account is not None + assert account.id == "account-1" + assert account.email == "account@example.com" + + def test_account_integration_repository_lists_integrations( sqlite_session: Session, sqlite_session_factory: sessionmaker[Session], diff --git a/api/tests/unit_tests/repositories/test_step_by_step_tour_repository.py b/api/tests/unit_tests/repositories/test_step_by_step_tour_repository.py new file mode 100644 index 00000000000..a6439f58bc5 --- /dev/null +++ b/api/tests/unit_tests/repositories/test_step_by_step_tour_repository.py @@ -0,0 +1,171 @@ +from contextlib import nullcontext +from dataclasses import replace +from datetime import datetime +from typing import cast +from unittest.mock import MagicMock, Mock + +import pytest +from sqlalchemy.exc import IntegrityError, OperationalError +from sqlalchemy.orm import Session, sessionmaker + +from models.onboarding import AccountStepByStepTourState +from repositories.step_by_step_tour_repository import ( + SQLAlchemyStepByStepTourStateRepository, + _is_retryable_mysql_lock_error, +) + + +class _ErrnoOnlyError(Exception): + def __init__(self, errno: int | str) -> None: + super().__init__() + self.errno = errno + + +def test_mutate_creates_and_updates_state_in_repository_owned_transaction( + sqlite_session_factory: sessionmaker[Session], +) -> None: + repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory) + + saved = repository.mutate( + "account-1", + lambda state: replace(state, completed_task_ids=("home",)), + ) + reloaded = repository.get("account-1") + + assert saved.first_workspace_id is None + assert saved.completed_task_ids == ("home",) + assert saved.updated_at is not None + assert reloaded == saved + + +def test_initialize_creates_state_with_first_workspace_atomically( + sqlite_session_factory: sessionmaker[Session], +) -> None: + repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory) + + result = repository.initialize("account-1", "workspace-1") + + assert result.first_workspace_id == "workspace-1" + assert repository.get("account-1") == result + + +def test_initialize_claims_empty_state_once_without_overwriting_winner( + sqlite_session_factory: sessionmaker[Session], +) -> None: + repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory) + with sqlite_session_factory() as session: + session.add(AccountStepByStepTourState(account_id="account-1")) + session.commit() + + first = repository.initialize("account-1", "workspace-1") + second = repository.initialize("account-1", "workspace-2") + + assert first.first_workspace_id == "workspace-1" + assert second.first_workspace_id == "workspace-1" + + +def test_mutate_cannot_clear_or_overwrite_first_workspace( + sqlite_session_factory: sessionmaker[Session], +) -> None: + repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory) + repository.initialize("account-1", "workspace-1") + + result = repository.mutate( + "account-1", + lambda state: replace(state, first_workspace_id="workspace-2", skipped=True), + ) + + assert result.first_workspace_id == "workspace-1" + assert result.skipped is True + + +def test_sequential_mutations_replay_against_latest_state( + sqlite_session_factory: sessionmaker[Session], +) -> None: + repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory) + + repository.mutate("account-1", lambda state: replace(state, completed_task_ids=("home",))) + result = repository.mutate( + "account-1", + lambda state: replace(state, completed_task_ids=(*state.completed_task_ids, "studio")), + ) + + assert result.completed_task_ids == ("home", "studio") + + +def test_mutate_replays_after_concurrent_create_conflict() -> None: + concurrent_state = AccountStepByStepTourState(account_id="account-1") + concurrent_state.completed_task_ids = ["home"] + concurrent_state.updated_at = datetime(2026, 8, 13) + session = MagicMock(spec=Session) + session.execute.return_value.scalar_one_or_none.side_effect = [None, concurrent_state] + session.flush.side_effect = IntegrityError("insert", {}, Exception("duplicate")) + factory = cast(sessionmaker[Session], Mock(return_value=nullcontext(session))) + repository = SQLAlchemyStepByStepTourStateRepository(factory) + + result = repository.mutate( + "account-1", + lambda state: replace(state, completed_task_ids=(*state.completed_task_ids, "studio")), + ) + + assert result.completed_task_ids == ("home", "studio") + session.rollback.assert_called_once_with() + initial_probe = session.execute.call_args_list[0].args[0] + replay_statement = session.execute.call_args_list[1].args[0] + assert initial_probe._for_update_arg is None + assert replay_statement._for_update_arg is not None + + +def test_mutate_retries_mysql_deadlock_with_fresh_session() -> None: + concurrent_state = AccountStepByStepTourState(account_id="account-1") + concurrent_state.completed_task_ids = ["home"] + concurrent_state.updated_at = datetime(2026, 8, 13) + + deadlocked_session = MagicMock(spec=Session) + deadlocked_session.execute.return_value.scalar_one_or_none.return_value = None + deadlocked_session.flush.side_effect = OperationalError( + "INSERT", + {}, + Exception(1213, "Deadlock found when trying to get lock"), + ) + + retry_session = MagicMock(spec=Session) + retry_session.execute.return_value.scalar_one_or_none.side_effect = [concurrent_state, concurrent_state] + factory = Mock(side_effect=[nullcontext(deadlocked_session), nullcontext(retry_session)]) + repository = SQLAlchemyStepByStepTourStateRepository(cast(sessionmaker[Session], factory)) + + result = repository.mutate( + "account-1", + lambda state: replace(state, completed_task_ids=(*state.completed_task_ids, "studio")), + ) + + assert result.completed_task_ids == ("home", "studio") + assert factory.call_count == 2 + retry_lock_statement = retry_session.execute.call_args_list[1].args[0] + assert retry_lock_statement._for_update_arg is not None + + +@pytest.mark.parametrize( + ("orig", "expected"), + [ + pytest.param(_ErrnoOnlyError(1205), True, id="errno-attribute"), + pytest.param(Exception(1213, "deadlock"), True, id="integer-args-code"), + pytest.param(Exception("1213", "deadlock"), True, id="string-args-code"), + pytest.param(Exception(9999, "other error"), False, id="non-retryable-code"), + pytest.param(Exception(True), False, id="boolean-is-not-an-error-code"), + pytest.param(Exception(), False, id="missing-error-code"), + ], +) +def test_mysql_lock_error_detection_preserves_errno_and_args_coverage( + orig: BaseException, + expected: bool, +) -> None: + exc = OperationalError("statement", {}, orig) + + assert _is_retryable_mysql_lock_error(exc) is expected + + +def test_get_returns_none_for_unknown_account( + sqlite_session_factory: sessionmaker[Session], +) -> None: + assert SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory).get("missing") is None diff --git a/api/tests/unit_tests/services/agent/test_agent_dsl_service.py b/api/tests/unit_tests/services/agent/test_agent_dsl_service.py index 3cf2bcd6372..7c0d5ef5332 100644 --- a/api/tests/unit_tests/services/agent/test_agent_dsl_service.py +++ b/api/tests/unit_tests/services/agent/test_agent_dsl_service.py @@ -4,10 +4,14 @@ from unittest.mock import Mock import pytest from pydantic import ValidationError +from sqlalchemy import select +from sqlalchemy.orm import Session from graphon.enums import BuiltinNodeTypes from models.agent import ( Agent, + AgentConfigDraft, + AgentConfigDraftType, AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, @@ -191,7 +195,9 @@ def test_agent_package_rejects_null_file_id_for_available_assets(asset: dict) -> AgentPackage.model_validate(package) -def test_import_warnings_cover_runtime_setup_removed_from_package(monkeypatch: pytest.MonkeyPatch) -> None: +def test_import_warnings_cover_runtime_setup_removed_from_package( + monkeypatch: pytest.MonkeyPatch, unbound_session: Session +) -> None: soul = AgentSoulConfig.model_validate( { "tools": { @@ -211,7 +217,7 @@ def test_import_warnings_cover_runtime_setup_removed_from_package(monkeypatch: p ) monkeypatch.setattr("services.agent.dsl_service.get_tenant_knowledge_dataset_rows", Mock(return_value={})) - _, warnings = AgentDslService(Mock())._resolve_package_soul( + _, warnings = AgentDslService(unbound_session)._resolve_package_soul( tenant_id="tenant-1", package=make_portable_agent_package(_agent(), soul), package_path="agent_packages.agent_1", @@ -231,23 +237,29 @@ def test_agent_package_rejects_unknown_schema_version() -> None: AgentPackage.model_validate(package) -def test_export_agent_app_requires_backing_agent() -> None: - session = Mock() - session.scalar.return_value = None - +def test_export_agent_app_requires_backing_agent(sqlite_session: Session) -> None: with pytest.raises(ValueError, match="no active backing Agent"): - AgentDslService(session).export_agent_app(app=SimpleNamespace(tenant_id="tenant-1", id="app-1")) + AgentDslService(sqlite_session).export_agent_app(app=SimpleNamespace(tenant_id="tenant-1", id="app-1")) @pytest.mark.parametrize("use_draft", [True, False]) -def test_export_agent_app_uses_draft_or_active_snapshot(use_draft: bool) -> None: +def test_export_agent_app_uses_draft_or_active_snapshot(use_draft: bool, sqlite_session: Session) -> None: agent = _agent() + agent.app_id = "app-1" agent.active_config_snapshot_id = "snapshot-1" - draft = SimpleNamespace(config_snapshot_dict=AgentSoulConfig(config_note="draft").model_dump(mode="json")) - session = Mock() - session.scalar.side_effect = [agent, draft if use_draft else None] - session.execute.return_value = [] - service = AgentDslService(session) + sqlite_session.add(agent) + if use_draft: + sqlite_session.add( + AgentConfigDraft( + tenant_id="tenant-1", + agent_id=agent.id, + draft_type=AgentConfigDraftType.DRAFT, + draft_owner_key="", + config_snapshot=AgentSoulConfig(config_note="draft"), + ) + ) + sqlite_session.flush() + service = AgentDslService(sqlite_session) require_snapshot = Mock(return_value=_snapshot(soul=AgentSoulConfig(config_note="snapshot"))) service._require_snapshot = require_snapshot @@ -258,22 +270,26 @@ def test_export_agent_app_uses_draft_or_active_snapshot(use_draft: bool) -> None assert require_snapshot.call_count == (0 if use_draft else 1) -def test_export_workflow_packages_deduplicates_shared_agent() -> None: +def test_export_workflow_packages_deduplicates_shared_agent(sqlite_session: Session) -> None: graph = {"nodes": [_agent_node("node-1"), _agent_node("node-2")], "edges": []} bindings = [ - SimpleNamespace( + WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", node_id=node_id, agent_id="agent-1", current_snapshot_id="snapshot-1", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, - node_job_config_dict={"workflow_prompt": node_id}, + node_job_config={"workflow_prompt": node_id}, + created_by="account-1", ) for node_id in ("node-1", "node-2") ] - session = Mock() - session.scalars.return_value.all.return_value = bindings - session.execute.return_value = [] - service = AgentDslService(session) + sqlite_session.add_all(bindings) + sqlite_session.flush() + service = AgentDslService(sqlite_session) service._require_agent = Mock(return_value=_agent()) service._require_snapshot = Mock(return_value=_snapshot()) @@ -292,12 +308,9 @@ def test_export_workflow_packages_deduplicates_shared_agent() -> None: assert service._require_agent.call_count == 2 -def test_export_workflow_packages_rejects_incomplete_binding() -> None: - session = Mock() - session.scalars.return_value.all.return_value = [] - +def test_export_workflow_packages_rejects_incomplete_binding(sqlite_session: Session) -> None: with pytest.raises(ValueError, match="no complete persisted binding"): - AgentDslService(session).export_workflow_packages( + AgentDslService(sqlite_session).export_workflow_packages( workflow=SimpleNamespace(tenant_id="tenant-1", id="workflow-1", version="draft"), graph={"nodes": [_agent_node("node-1")], "edges": []}, ) @@ -328,9 +341,10 @@ def test_graph_without_package_bindings_removes_portable_fields() -> None: assert AGENT_NODE_JOB_DSL_KEY in graph["nodes"][0]["data"] -def test_import_agent_app_package_creates_config_and_unpublished_draft(monkeypatch: pytest.MonkeyPatch) -> None: - session = Mock() - service = AgentDslService(session) +def test_import_agent_app_package_creates_config_and_unpublished_draft( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: + service = AgentDslService(sqlite_session) soul = AgentSoulConfig(config_note="portable") warning = DslImportWarning(code="setup", path="agent.soul", message="setup required") service._resolve_package_soul = Mock(return_value=(soul, [warning])) @@ -361,11 +375,10 @@ def test_import_agent_app_package_creates_config_and_unpublished_draft(monkeypat assert agent.active_config_is_published is False assert app.name == "Portable Agent" assert app.description == "description" - assert session.add.call_count == 2 - assert session.flush.call_count == 2 + assert sqlite_session.scalar(select(AgentConfigDraft).where(AgentConfigDraft.agent_id == agent.id)) is not None -def test_import_workflow_packages_materializes_every_package_binding_as_inline() -> None: +def test_import_workflow_packages_materializes_every_package_binding_as_inline(sqlite_session: Session) -> None: package = make_portable_agent_package(_agent(), AgentSoulConfig()) graph = { "nodes": [ @@ -388,14 +401,22 @@ def test_import_workflow_packages_materializes_every_package_binding_as_inline() } for node in graph["nodes"][:3]: node["data"][AGENT_NODE_JOB_DSL_KEY] = {"workflow_prompt": node["id"]} - old_binding = SimpleNamespace( + old_binding = WorkflowAgentNodeBinding( id="old-binding", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="old-node", binding_type=WorkflowAgentBindingType.INLINE_AGENT, agent_id="old-inline-agent", + current_snapshot_id="old-snapshot", + node_job_config={}, + created_by="account-1", ) - session = Mock() - session.scalars.return_value.all.return_value = [old_binding] - service = AgentDslService(session) + sqlite_session.add(old_binding) + sqlite_session.flush() + service = AgentDslService(sqlite_session) imported_results = [ SimpleNamespace( agent=SimpleNamespace(id=f"inline-agent-{index}"), @@ -420,7 +441,7 @@ def test_import_workflow_packages_materializes_every_package_binding_as_inline() account=SimpleNamespace(id="account-1"), ) - session.delete.assert_called_once_with(old_binding) + assert sqlite_session.get(WorkflowAgentNodeBinding, "old-binding") is None assert retirement_candidates == {"old-inline-agent"} assert service._create_imported_inline_agent.call_count == 3 assert [call.kwargs["node_id"] for call in service._create_imported_inline_agent.call_args_list] == [ @@ -438,8 +459,10 @@ def test_import_workflow_packages_materializes_every_package_binding_as_inline() assert all(binding["binding_type"] == WorkflowAgentBindingType.INLINE_AGENT.value for binding in bindings) assert AGENT_NODE_JOB_DSL_KEY not in result["nodes"][0]["data"] assert json.loads(workflow.graph) == result - added_bindings = [item.args[0] for item in session.add.call_args_list] - assert all(isinstance(binding, WorkflowAgentNodeBinding) for binding in added_bindings) + added_bindings = sqlite_session.scalars( + select(WorkflowAgentNodeBinding).where(WorkflowAgentNodeBinding.workflow_id == "workflow-1") + ).all() + assert len(added_bindings) == 3 assert all(binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT for binding in added_bindings) @@ -453,13 +476,13 @@ def test_import_workflow_packages_materializes_every_package_binding_as_inline() ({"binding_type": "invalid", AGENT_PACKAGE_REF_KEY: "agent_1"}, "invalid binding type"), ], ) -def test_import_workflow_packages_rejects_invalid_package_binding(binding: dict, error: str) -> None: - session = Mock() - session.scalars.return_value.all.return_value = [] +def test_import_workflow_packages_rejects_invalid_package_binding( + binding: dict, error: str, sqlite_session: Session +) -> None: package = make_portable_agent_package(_agent(), AgentSoulConfig()) with pytest.raises(ValueError, match=error): - AgentDslService(session).import_workflow_packages( + AgentDslService(sqlite_session).import_workflow_packages( workflow=SimpleNamespace(tenant_id="tenant-1", app_id="app-1", id="workflow-1", version="draft"), portable_graph={"nodes": [_agent_node("node-1", binding)], "edges": []}, raw_packages={"agent_1": package.model_dump(mode="json")}, @@ -467,9 +490,8 @@ def test_import_workflow_packages_rejects_invalid_package_binding(binding: dict, ) -def test_clone_inline_binding_copies_soul() -> None: - session = Mock() - service = AgentDslService(session) +def test_clone_inline_binding_copies_soul(unbound_session: Session) -> None: + service = AgentDslService(unbound_session) target_agent = SimpleNamespace(id="target-agent") target_snapshot = SimpleNamespace(id="target-snapshot") service._create_workflow_only_agent = Mock(return_value=(target_agent, target_snapshot)) @@ -504,7 +526,9 @@ def test_clone_inline_binding_copies_soul() -> None: assert create_kwargs["source"] == AgentSource.WORKFLOW -def test_extract_package_dependencies_covers_model_tools_and_knowledge(monkeypatch: pytest.MonkeyPatch) -> None: +def test_extract_package_dependencies_covers_model_tools_and_knowledge( + monkeypatch: pytest.MonkeyPatch, unbound_session: Session +) -> None: model_dependency = Mock(side_effect=lambda provider: f"model:{provider}") tool_dependency = Mock(side_effect=lambda provider: f"tool:{provider}") monkeypatch.setattr( @@ -551,7 +575,7 @@ def test_extract_package_dependencies_covers_model_tools_and_knowledge(monkeypat } ) - dependencies = AgentDslService(Mock()).extract_package_dependencies( + dependencies = AgentDslService(unbound_session).extract_package_dependencies( {"agent_1": make_portable_agent_package(_agent(), soul)} ) @@ -564,8 +588,8 @@ def test_extract_package_dependencies_covers_model_tools_and_knowledge(monkeypat ] -def test_create_imported_inline_agent_uses_import_provenance() -> None: - service = AgentDslService(Mock()) +def test_create_imported_inline_agent_uses_import_provenance(unbound_session: Session) -> None: + service = AgentDslService(unbound_session) soul = AgentSoulConfig(config_note="inline") warning = DslImportWarning(code="setup", path="agent", message="setup") service._resolve_package_soul = Mock(return_value=(soul, [warning])) @@ -587,9 +611,10 @@ def test_create_imported_inline_agent_uses_import_provenance() -> None: ) -def test_create_workflow_only_agent_sets_backing_app_and_snapshot(monkeypatch: pytest.MonkeyPatch) -> None: - session = Mock() - service = AgentDslService(session) +def test_create_workflow_only_agent_sets_backing_app_and_snapshot( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: + service = AgentDslService(sqlite_session) roster_service = Mock() roster_service.create_hidden_backing_app_for_workflow_agent.return_value = SimpleNamespace(id="backing-app") monkeypatch.setattr("services.agent.dsl_service.AgentRosterService", Mock(return_value=roster_service)) @@ -613,11 +638,12 @@ def test_create_workflow_only_agent_sets_backing_app_and_snapshot(monkeypatch: p assert agent.active_config_snapshot_id == "snapshot-1" assert agent.active_config_has_model is True assert agent.active_config_is_published is True - session.add.assert_called_once_with(agent) - assert session.flush.call_count == 2 + assert sqlite_session.get(Agent, agent.id) is agent -def test_resolve_package_soul_preserves_existing_and_marks_missing_knowledge(monkeypatch: pytest.MonkeyPatch) -> None: +def test_resolve_package_soul_preserves_existing_and_marks_missing_knowledge( + monkeypatch: pytest.MonkeyPatch, unbound_session: Session +) -> None: soul = AgentSoulConfig.model_validate( { "config_skills": [{"name": "skill", "file_kind": "tool_file", "file_id": "skill-file"}], @@ -638,18 +664,17 @@ def test_resolve_package_soul_preserves_existing_and_marks_missing_knowledge(mon }, } ) - session = Mock() get_dataset_rows = Mock(return_value={"existing": SimpleNamespace(id="existing")}) monkeypatch.setattr("services.agent.dsl_service.get_tenant_knowledge_dataset_rows", get_dataset_rows) - resolved, warnings = AgentDslService(session)._resolve_package_soul( + resolved, warnings = AgentDslService(unbound_session)._resolve_package_soul( tenant_id="tenant-1", package=make_portable_agent_package(_agent(), soul), package_path="agent_packages.agent_1", ) get_dataset_rows.assert_called_once_with( - session=session, + session=unbound_session, tenant_id="tenant-1", dataset_ids=["existing", "missing"], ) @@ -683,14 +708,18 @@ def test_resolve_package_soul_preserves_existing_and_marks_missing_knowledge(mon } -def test_create_snapshot_increments_version_and_records_revision() -> None: - session = Mock() - session.scalar.return_value = 2 - service = AgentDslService(session) +def test_create_snapshot_increments_version_and_records_revision(sqlite_session: Session) -> None: + agent = _agent() + first = _snapshot(snapshot_id="snapshot-1") + second = _snapshot(snapshot_id="snapshot-2") + second.version = 2 + sqlite_session.add_all([agent, first, second]) + sqlite_session.flush() + service = AgentDslService(sqlite_session) snapshot = service._create_snapshot( tenant_id="tenant-1", - agent=_agent(), + agent=agent, account_id="account-1", soul=AgentSoulConfig(config_note="version 3"), operation=AgentConfigRevisionOperation.IMPORT_PACKAGE, @@ -698,28 +727,32 @@ def test_create_snapshot_increments_version_and_records_revision() -> None: assert snapshot.version == 3 assert snapshot.home_snapshot_id is None - assert isinstance(session.add.call_args_list[0].args[0], AgentConfigSnapshot) - revision = session.add.call_args_list[1].args[0] - assert isinstance(revision, AgentConfigRevision) + revision = sqlite_session.scalar( + select(AgentConfigRevision).where(AgentConfigRevision.current_snapshot_id == snapshot.id) + ) + assert revision is not None assert revision.operation == AgentConfigRevisionOperation.IMPORT_PACKAGE - assert session.flush.call_count == 2 -def test_unique_roster_name_uses_first_available_suffix() -> None: - session = Mock() - session.scalars.return_value.all.return_value = ["Agent", "Agent import"] +def test_unique_roster_name_uses_first_available_suffix(sqlite_session: Session) -> None: + for index, name in enumerate(("Agent", "Agent import"), start=1): + agent = _agent() + agent.id = f"agent-{index}" + agent.name = name + sqlite_session.add(agent) + sqlite_session.flush() - result = AgentDslService(session)._unique_roster_name(tenant_id="tenant-1", requested="Agent") + result = AgentDslService(sqlite_session)._unique_roster_name(tenant_id="tenant-1", requested="Agent") assert result == "Agent import 2" -def test_require_helpers_and_graph_detection() -> None: - session = Mock() - service = AgentDslService(session) +def test_require_helpers_and_graph_detection(sqlite_session: Session) -> None: + service = AgentDslService(sqlite_session) agent = _agent() snapshot = _snapshot() - session.scalar.side_effect = [agent, None, snapshot, None] + sqlite_session.add_all([agent, snapshot]) + sqlite_session.flush() assert service._require_agent(tenant_id="tenant-1", agent_id="agent-1") is agent with pytest.raises(ValueError, match="source Agent"): @@ -733,17 +766,4 @@ def test_require_helpers_and_graph_detection() -> None: assert AgentDslService._agent_icon_type(AgentIconType.EMOJI.value) == AgentIconType.EMOJI assert AgentDslService._agent_icon_type(None) is None assert is_agent_v2_graph({"nodes": [_agent_node("agent")]}) is True - assert is_agent_v2_graph({"nodes": [{"id": "legacy-agent", "data": {"type": "agent", "version": "2"}}]}) is False assert is_agent_v2_graph({"nodes": ["invalid", {"data": {"type": "start"}}]}) is False - - -def test_export_workflow_packages_ignores_historical_agent_version_two() -> None: - session = Mock() - service = AgentDslService(session) - graph = {"nodes": [{"id": "legacy-agent", "data": {"type": "agent", "version": "2"}}]} - - portable_graph, packages = service.export_workflow_packages(workflow=Mock(), graph=graph) - - assert portable_graph == graph - assert packages == {} - session.scalars.assert_not_called() diff --git a/api/tests/unit_tests/services/agent/test_agent_observability_service.py b/api/tests/unit_tests/services/agent/test_agent_observability_service.py index 8bcf67dbf93..32a6e5a54d8 100644 --- a/api/tests/unit_tests/services/agent/test_agent_observability_service.py +++ b/api/tests/unit_tests/services/agent/test_agent_observability_service.py @@ -1,7 +1,6 @@ import json from datetime import UTC, datetime from decimal import Decimal -from types import SimpleNamespace import pytest from sqlalchemy import Select, select @@ -30,6 +29,7 @@ from models.workflow import ( ) from services.agent import observability_service as observability_service_module from services.agent.observability_service import AgentLogQueryParams, AgentObservabilityService +from tests.unit_tests.config_override import apply_config_overrides def _app(*, app_id: str = "app-1", name: str = "Iris", mode: AppMode = AppMode.AGENT_CHAT) -> App: @@ -291,7 +291,7 @@ def test_statistics_workflow_chat_context_only_uses_chat_runs() -> None: def test_workflow_metadata_numeric_sql_supports_postgresql_and_mysql(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(observability_service_module, "dify_config", SimpleNamespace(DB_TYPE="postgresql")) + apply_config_overrides(monkeypatch, DB_TYPE="postgresql") postgres_sql = AgentObservabilityService._workflow_execution_metadata_numeric_sql( ("agent_log", "agent_backend", "usage", "total_tokens"), "BIGINT" @@ -300,7 +300,7 @@ def test_workflow_metadata_numeric_sql_supports_postgresql_and_mysql(monkeypatch assert "CAST(wne.execution_metadata AS JSONB)" in postgres_sql assert "#>> '{agent_log,agent_backend,usage,total_tokens}'" in postgres_sql - monkeypatch.setattr(observability_service_module, "dify_config", SimpleNamespace(DB_TYPE="mysql")) + apply_config_overrides(monkeypatch, DB_TYPE="mysql") mysql_sql = AgentObservabilityService._workflow_execution_metadata_numeric_sql(("total_tokens",), "BIGINT") @@ -315,7 +315,7 @@ def test_workflow_statistics_include_run_without_message( sqlite_session.add_all([workflow_app, _workflow_run(), _node_execution(), _workflow_binding()]) sqlite_session.commit() - monkeypatch.setattr(observability_service_module, "dify_config", SimpleNamespace(DB_TYPE="mysql")) + apply_config_overrides(monkeypatch, DB_TYPE="mysql") monkeypatch.setattr(observability_service_module, "convert_datetime_to_date", lambda field: f"DATE({field})") monkeypatch.setattr( AgentObservabilityService, diff --git a/api/tests/unit_tests/services/agent/test_home_snapshot_service.py b/api/tests/unit_tests/services/agent/test_home_snapshot_service.py index c66a26aeeb7..3b2a638f692 100644 --- a/api/tests/unit_tests/services/agent/test_home_snapshot_service.py +++ b/api/tests/unit_tests/services/agent/test_home_snapshot_service.py @@ -6,13 +6,15 @@ import pytest from dify_agent.client import DifyAgentHTTPError, DifyAgentNotFoundError, DifyAgentTimeoutError from sqlalchemy.orm import Session -from configs import dify_config from models.agent import ( Agent, AgentConfigDraft, AgentConfigDraftType, AgentConfigSnapshot, AgentHomeSnapshot, + AgentScope, + AgentSource, + AgentStatus, AgentWorkingResourceStatus, ) from models.agent_config_entities import AgentSoulConfig @@ -23,6 +25,7 @@ from services.agent.errors import ( ) from services.agent.home_snapshot_service import AgentHomeSnapshotService, validate_home_snapshot_binding from services.agent.workspace_service import AgentWorkspaceService +from tests.unit_tests.config_override import apply_config_overrides def _build_draft(*, home_snapshot_id: str | None = "home-old") -> AgentConfigDraft: @@ -46,23 +49,38 @@ def _client(*, snapshot_ref: str = "snapshot-ref-1") -> MagicMock: def test_home_snapshot_client_outlasts_the_gateway_snapshot_budget(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(dify_config, "AGENT_BACKEND_BASE_URL", "http://agent.example") + apply_config_overrides(monkeypatch, AGENT_BACKEND_BASE_URL="http://agent.example") client = AgentHomeSnapshotService._client() assert client._timeout == 45.0 -def test_validate_home_snapshot_binding_accepts_default_home_without_ledger_lookup() -> None: - session = MagicMock() +def _persist_agent(session: Session, *, app_id: str, backing_app_id: str | None) -> Agent: + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Snapshot Agent", + description="", + role="", + scope=AgentScope.ROSTER if backing_app_id is None else AgentScope.WORKFLOW_ONLY, + source=AgentSource.AGENT_APP if backing_app_id is None else AgentSource.WORKFLOW, + status=AgentStatus.ACTIVE, + app_id=app_id, + backing_app_id=backing_app_id, + ) + session.add(agent) + session.commit() + return agent + + +def test_validate_home_snapshot_binding_accepts_default_home_without_ledger_lookup(unbound_session: Session) -> None: validate_home_snapshot_binding( - session=session, + session=unbound_session, agent=Agent(id="agent-1"), home_snapshot_id=None, ) - session.scalar.assert_not_called() - @pytest.mark.parametrize( ("app_id", "backing_app_id", "expected_runtime_app_id"), @@ -73,12 +91,12 @@ def test_validate_home_snapshot_binding_accepts_default_home_without_ledger_look ) def test_build_apply_checkpoints_exact_active_binding( monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, app_id: str, backing_app_id: str | None, expected_runtime_app_id: str, ) -> None: - session = MagicMock() - session.scalar.return_value = SimpleNamespace(app_id=app_id, backing_app_id=backing_app_id) + _persist_agent(sqlite_session, app_id=app_id, backing_app_id=backing_app_id) binding = SimpleNamespace( backend_binding_ref="binding-ref-1", agent_id="agent-1", @@ -94,7 +112,7 @@ def test_build_apply_checkpoints_exact_active_binding( monkeypatch.setattr(AgentWorkspaceService, "validate_binding_generation", validate_generation) snapshot = AgentHomeSnapshotService.create_for_build_apply( - session=session, + session=sqlite_session, build_draft=_build_draft(), ) @@ -106,9 +124,8 @@ def test_build_apply_checkpoints_exact_active_binding( assert validate_generation.call_args.kwargs["base_home_snapshot_id"] == "home-old" -def test_build_apply_forwards_default_home_generation(monkeypatch: pytest.MonkeyPatch) -> None: - session = MagicMock() - session.scalar.return_value = SimpleNamespace(app_id="app-1", backing_app_id=None) +def test_build_apply_forwards_default_home_generation(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: + _persist_agent(sqlite_session, app_id="app-1", backing_app_id=None) binding = SimpleNamespace( backend_binding_ref="binding-ref-1", agent_id="agent-1", @@ -123,7 +140,7 @@ def test_build_apply_forwards_default_home_generation(monkeypatch: pytest.Monkey monkeypatch.setattr(AgentWorkspaceService, "validate_binding_generation", validate_generation) snapshot = AgentHomeSnapshotService.create_for_build_apply( - session=session, + session=sqlite_session, build_draft=_build_draft(home_snapshot_id=None), ) @@ -131,26 +148,26 @@ def test_build_apply_forwards_default_home_generation(monkeypatch: pytest.Monkey assert validate_generation.call_args.kwargs["base_home_snapshot_id"] is None -def test_build_apply_fails_fast_without_source_binding() -> None: - session = MagicMock() +def test_build_apply_fails_fast_without_source_binding(unbound_session: Session) -> None: build_draft = _build_draft() build_draft.agent_workspace_binding_id = None with pytest.raises(AgentBuildSandboxNotFoundError): AgentHomeSnapshotService.create_for_build_apply( - session=session, + session=unbound_session, build_draft=build_draft, ) -def test_home_snapshot_collection_database_failure_propagates(monkeypatch: pytest.MonkeyPatch) -> None: - context = MagicMock() - session = context.__enter__.return_value +def test_home_snapshot_collection_database_failure_propagates( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: error = RuntimeError("database unavailable") - session.scalar.side_effect = error + scalar = MagicMock(side_effect=error) + monkeypatch.setattr(sqlite_session, "scalar", scalar) monkeypatch.setattr( "services.agent.home_snapshot_service.session_factory.create_session", - lambda: context, + lambda: nullcontext(sqlite_session), ) with pytest.raises(RuntimeError) as exc_info: @@ -159,6 +176,7 @@ def test_home_snapshot_collection_database_failure_propagates(monkeypatch: pytes home_snapshot_id="home-1", ) + scalar.assert_called_once() assert exc_info.value is error diff --git a/api/tests/unit_tests/services/agent/test_skill_package_service.py b/api/tests/unit_tests/services/agent/test_skill_package_service.py index f634cfdda45..fa7cb7a48eb 100644 --- a/api/tests/unit_tests/services/agent/test_skill_package_service.py +++ b/api/tests/unit_tests/services/agent/test_skill_package_service.py @@ -221,7 +221,9 @@ def test_validate_and_normalize_rejects_archive_too_large_uncompressed(monkeypat def test_validate_and_normalize_rejects_archive_too_large_uploaded_bytes(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(skill_package_service_module.dify_config, "UPLOAD_SKILL_FILE_SIZE_LIMIT", 1) + from tests.unit_tests.config_override import apply_config_overrides + + apply_config_overrides(monkeypatch, UPLOAD_SKILL_FILE_SIZE_LIMIT=1) with pytest.raises(SkillPackageError) as exc_info: SkillPackageService().validate_and_normalize(content=b"x" * (1024 * 1024 + 1), filename="skill.zip") diff --git a/api/tests/unit_tests/services/agent/test_workflow_publish_service.py b/api/tests/unit_tests/services/agent/test_workflow_publish_service.py index 74031c57844..d50f3589b68 100644 --- a/api/tests/unit_tests/services/agent/test_workflow_publish_service.py +++ b/api/tests/unit_tests/services/agent/test_workflow_publish_service.py @@ -7,15 +7,19 @@ from sqlalchemy.orm import Session from models.agent import ( Agent, + AgentConfigSnapshot, AgentScope, + AgentSource, + AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding, ) +from models.agent_config_entities import AgentSoulConfig from models.enums import AppStatus from models.model import App, AppMode from models.workflow import Workflow, WorkflowType from services.agent.dsl_service import AgentDslService -from services.agent.workflow_publish_service import WorkflowAgentPublishService, _InlineAgentOwnershipError +from services.agent.workflow_publish_service import WorkflowAgentPublishService def _workflow(*, workflow_id: str = "workflow-1", version: str = Workflow.VERSION_DRAFT) -> Workflow: @@ -33,39 +37,66 @@ def _workflow(*, workflow_id: str = "workflow-1", version: str = Workflow.VERSIO ) -def test_inline_binding_from_another_node_is_cloned(monkeypatch: pytest.MonkeyPatch) -> None: - session = Mock() - draft_workflow = _workflow() - monkeypatch.setattr( - WorkflowAgentPublishService, - "_resolve_inline_agent_graph_binding", - Mock(side_effect=_InlineAgentOwnershipError("source belongs to another node")), +def _inline_agent( + *, + agent_id: str, + workflow_id: str, + node_id: str, + tenant_id: str = "tenant-1", +) -> Agent: + return Agent( + id=agent_id, + tenant_id=tenant_id, + name=f"Inline {agent_id}", + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + status=AgentStatus.ACTIVE, + app_id="app-1", + workflow_id=workflow_id, + workflow_node_id=node_id, ) - monkeypatch.setattr( - WorkflowAgentPublishService, - "_resolve_existing_inline_binding_agent", - Mock(return_value=None), + + +def _snapshot(*, snapshot_id: str, agent_id: str, version: int = 1) -> AgentConfigSnapshot: + return AgentConfigSnapshot( + id=snapshot_id, + tenant_id="tenant-1", + agent_id=agent_id, + version=version, + config_snapshot=AgentSoulConfig(), ) - clone = Mock(return_value=(SimpleNamespace(id="target-agent"), "target-snapshot")) - monkeypatch.setattr(WorkflowAgentPublishService, "_clone_inline_graph_binding_for_node", clone) + + +def test_inline_binding_from_another_node_is_cloned(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: + source_agent = _inline_agent(agent_id="source-agent", workflow_id="workflow-1", node_id="source-node") + source_snapshot = _snapshot(snapshot_id="source-snapshot", agent_id=source_agent.id) + sqlite_session.add_all([source_agent, source_snapshot]) + sqlite_session.commit() + target_agent = _inline_agent(agent_id="target-agent", workflow_id="workflow-1", node_id="pasted-node") + target_snapshot = _snapshot(snapshot_id="target-snapshot", agent_id=target_agent.id) + clone = Mock(return_value=(target_agent, target_snapshot)) + monkeypatch.setattr(AgentDslService, "clone_inline_binding_for_node", clone) WorkflowAgentPublishService._sync_agent_binding_for_node( - session=session, - draft_workflow=draft_workflow, + session=sqlite_session, + draft_workflow=_workflow(), node_id="pasted-node", node_data={"agent_task": "Summarize the input"}, node_binding={ "binding_type": WorkflowAgentBindingType.INLINE_AGENT.value, - "agent_id": "source-agent", - "current_snapshot_id": "source-snapshot", + "agent_id": source_agent.id, + "current_snapshot_id": source_snapshot.id, }, existing_binding=None, account_id="account-1", ) + sqlite_session.flush() clone.assert_called_once() - binding = session.add.call_args.args[0] - assert isinstance(binding, WorkflowAgentNodeBinding) + binding = sqlite_session.scalar( + select(WorkflowAgentNodeBinding).where(WorkflowAgentNodeBinding.node_id == "pasted-node") + ) + assert binding is not None assert binding.agent_id == "target-agent" assert binding.current_snapshot_id == "target-snapshot" assert binding.node_job_config.workflow_prompt == "Summarize the input" @@ -103,8 +134,9 @@ def test_draft_sync_resolves_roster_agents() -> None: assert {call.args[0].agent_id for call in session.add.call_args_list} == {"agent-a", "agent-b"} -def test_restore_replaces_bindings_and_returns_only_replaced_inline_agent() -> None: +def test_restore_replaces_bindings_and_returns_only_replaced_inline_agent(sqlite_session: Session) -> None: existing_inline = WorkflowAgentNodeBinding( + id="existing-inline", tenant_id="tenant-1", app_id="app-1", workflow_id="draft-workflow", @@ -117,6 +149,7 @@ def test_restore_replaces_bindings_and_returns_only_replaced_inline_agent() -> N created_by="account-1", ) existing_roster = WorkflowAgentNodeBinding( + id="existing-roster", tenant_id="tenant-1", app_id="app-1", workflow_id="draft-workflow", @@ -129,6 +162,7 @@ def test_restore_replaces_bindings_and_returns_only_replaced_inline_agent() -> N created_by="account-1", ) source = WorkflowAgentNodeBinding( + id="source-roster", tenant_id="tenant-1", app_id="app-1", workflow_id="published-workflow", @@ -140,30 +174,34 @@ def test_restore_replaces_bindings_and_returns_only_replaced_inline_agent() -> N node_job_config={"workflow_prompt": "Use the roster agent"}, created_by="account-1", ) - session = Mock() - session.scalars.side_effect = [ - SimpleNamespace(all=lambda: [existing_inline, existing_roster]), - SimpleNamespace(all=lambda: [source]), - ] - session.scalar.return_value = SimpleNamespace( + roster_agent = Agent( id="roster-agent", + tenant_id="tenant-1", + name="Roster Agent", scope=AgentScope.ROSTER, + source=AgentSource.ROSTER, + status=AgentStatus.ACTIVE, + app_id="roster-app", active_config_snapshot_id="published-snapshot", ) + sqlite_session.add_all([existing_inline, existing_roster, source, roster_agent]) + sqlite_session.commit() retirement_candidates = WorkflowAgentPublishService.restore_agent_node_bindings_to_draft( - session=session, + session=sqlite_session, source_workflow=_workflow(workflow_id="published-workflow", version="2026-07-13 00:00:00"), draft_workflow=_workflow(workflow_id="draft-workflow"), account_id="account-2", ) - assert {item.args[0].agent_id for item in session.delete.call_args_list} == { - "old-inline-agent", - "old-roster-agent", - } - restored = session.add.call_args.args[0] - assert isinstance(restored, WorkflowAgentNodeBinding) - assert restored.workflow_id == "draft-workflow" + assert sqlite_session.get(WorkflowAgentNodeBinding, existing_inline.id) is None + assert sqlite_session.get(WorkflowAgentNodeBinding, existing_roster.id) is None + restored = sqlite_session.scalar( + select(WorkflowAgentNodeBinding).where( + WorkflowAgentNodeBinding.workflow_id == "draft-workflow", + WorkflowAgentNodeBinding.node_id == "agent-node", + ) + ) + assert restored is not None assert restored.workflow_version == Workflow.VERSION_DRAFT assert restored.agent_id == "roster-agent" assert restored.current_snapshot_id == "published-snapshot" @@ -284,6 +322,7 @@ def test_publish_binding_copy_keeps_previous_published_owner( draft_workflow=draft_workflow, published_workflow=published_workflow, ) + sqlite_session.flush() assert result is True assert sqlite_session.get(WorkflowAgentNodeBinding, previous_inline_binding.id) is previous_inline_binding @@ -299,55 +338,50 @@ def test_publish_binding_copy_keeps_previous_published_owner( assert copied.current_snapshot_id == "draft-inline-snapshot" -def test_inline_binding_reuses_existing_node_owned_agent(monkeypatch: pytest.MonkeyPatch) -> None: - session = Mock() - draft_workflow = _workflow() +def test_inline_binding_reuses_existing_node_owned_agent(sqlite_session: Session) -> None: + existing_agent = _inline_agent(agent_id="existing-agent", workflow_id="workflow-1", node_id="pasted-node") + existing_snapshot = _snapshot(snapshot_id="existing-snapshot", agent_id=existing_agent.id) existing_binding = WorkflowAgentNodeBinding( + id="existing-binding", tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version=Workflow.VERSION_DRAFT, node_id="pasted-node", binding_type=WorkflowAgentBindingType.INLINE_AGENT, - agent_id="existing-agent", - current_snapshot_id="existing-snapshot", + agent_id=existing_agent.id, + current_snapshot_id=existing_snapshot.id, node_job_config={}, created_by="account-1", ) - existing_agent = SimpleNamespace(id="existing-agent") - monkeypatch.setattr( - WorkflowAgentPublishService, - "_resolve_inline_agent_graph_binding", - Mock(side_effect=_InlineAgentOwnershipError("source belongs to another node")), - ) - monkeypatch.setattr( - WorkflowAgentPublishService, - "_resolve_existing_inline_binding_agent", - Mock(return_value=existing_agent), - ) - clone = Mock() - monkeypatch.setattr(WorkflowAgentPublishService, "_clone_inline_graph_binding_for_node", clone) + sqlite_session.add_all([existing_agent, existing_snapshot, existing_binding]) + sqlite_session.commit() WorkflowAgentPublishService._sync_agent_binding_for_node( - session=session, - draft_workflow=draft_workflow, + session=sqlite_session, + draft_workflow=_workflow(), node_id="pasted-node", node_data={"agent_task": "Summarize"}, node_binding={ "binding_type": WorkflowAgentBindingType.INLINE_AGENT.value, - "agent_id": "source-agent", - "current_snapshot_id": "source-snapshot", + "agent_id": "unavailable-source-agent", + "current_snapshot_id": "unavailable-source-snapshot", }, existing_binding=existing_binding, account_id="account-1", ) + sqlite_session.flush() - assert existing_binding.agent_id == "existing-agent" - assert existing_binding.current_snapshot_id == "existing-snapshot" - clone.assert_not_called() + stored = sqlite_session.get(WorkflowAgentNodeBinding, existing_binding.id) + assert stored is not None + assert stored.agent_id == "existing-agent" + assert stored.current_snapshot_id == "existing-snapshot" + assert stored.node_job_config.workflow_prompt == "Summarize" -def test_resolve_existing_inline_binding_agent_returns_valid_agent_or_none(monkeypatch: pytest.MonkeyPatch) -> None: +def test_resolve_existing_inline_binding_agent_returns_valid_agent_or_none( + monkeypatch: pytest.MonkeyPatch, unbound_session: Session +) -> None: binding = WorkflowAgentNodeBinding( tenant_id="tenant-1", app_id="app-1", @@ -360,13 +394,13 @@ def test_resolve_existing_inline_binding_agent_returns_valid_agent_or_none(monke node_job_config={}, created_by="account-1", ) - resolved = SimpleNamespace(id="agent-1") + resolved = _inline_agent(agent_id="agent-1", workflow_id="workflow-1", node_id="node-1") resolver = Mock(return_value=resolved) monkeypatch.setattr(WorkflowAgentPublishService, "_resolve_inline_agent_graph_binding", resolver) assert ( WorkflowAgentPublishService._resolve_existing_inline_binding_agent( - session=Mock(), + session=unbound_session, draft_workflow=_workflow(), node_id="node-1", existing_binding=binding, @@ -377,7 +411,7 @@ def test_resolve_existing_inline_binding_agent_returns_valid_agent_or_none(monke resolver.side_effect = ValueError("stale") assert ( WorkflowAgentPublishService._resolve_existing_inline_binding_agent( - session=Mock(), + session=unbound_session, draft_workflow=_workflow(), node_id="node-1", existing_binding=binding, @@ -386,30 +420,42 @@ def test_resolve_existing_inline_binding_agent_returns_valid_agent_or_none(monke ) -def test_resolve_roster_binding_rejects_unpublished_agent() -> None: - session = Mock() - session.scalar.return_value = None +def test_resolve_roster_binding_rejects_unpublished_agent(sqlite_session: Session) -> None: + sqlite_session.add( + Agent( + id="decoy-agent", + tenant_id="tenant-1", + name="Decoy", + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + app_id="decoy-app", + ) + ) + sqlite_session.commit() with pytest.raises(ValueError, match="unavailable or unpublished roster agent"): WorkflowAgentPublishService._resolve_roster_agent_graph_binding( - session=session, + session=sqlite_session, draft_workflow=_workflow(), node_id="agent-node", agent_id="agent-1", ) -def test_clone_inline_graph_binding_for_node_clones_source(monkeypatch: pytest.MonkeyPatch) -> None: - session = Mock() - source_agent = SimpleNamespace(id="source-agent") - source_snapshot = SimpleNamespace(id="source-snapshot") - session.scalar.side_effect = [source_agent, source_snapshot] - target_agent = SimpleNamespace(id="target-agent") - target_snapshot = SimpleNamespace(id="target-snapshot") +def test_clone_inline_graph_binding_for_node_clones_source( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: + source_agent = _inline_agent(agent_id="source-agent", workflow_id="source-workflow", node_id="source-node") + source_snapshot = _snapshot(snapshot_id="source-snapshot", agent_id=source_agent.id) + sqlite_session.add_all([source_agent, source_snapshot]) + sqlite_session.commit() + target_agent = _inline_agent(agent_id="target-agent", workflow_id="workflow-1", node_id="target-node") + target_snapshot = _snapshot(snapshot_id="target-snapshot", agent_id=target_agent.id) clone = Mock(return_value=(target_agent, target_snapshot)) monkeypatch.setattr(AgentDslService, "clone_inline_binding_for_node", clone) result = WorkflowAgentPublishService._clone_inline_graph_binding_for_node( - session=session, + session=sqlite_session, draft_workflow=_workflow(), node_id="target-node", source_agent_id="source-agent", @@ -427,14 +473,17 @@ def test_clone_inline_graph_binding_for_node_clones_source(monkeypatch: pytest.M ) -@pytest.mark.parametrize("scalar_results", [[None], [SimpleNamespace(id="source-agent"), None]]) -def test_clone_inline_graph_binding_for_node_rejects_missing_source(scalar_results: list[object | None]) -> None: - session = Mock() - session.scalar.side_effect = scalar_results +@pytest.mark.parametrize("persist_source_agent", [False, True]) +def test_clone_inline_graph_binding_for_node_rejects_missing_source( + sqlite_session: Session, persist_source_agent: bool +) -> None: + if persist_source_agent: + sqlite_session.add(_inline_agent(agent_id="source-agent", workflow_id="source-workflow", node_id="source-node")) + sqlite_session.commit() with pytest.raises(ValueError, match="unavailable inline agent|missing inline agent config snapshot"): WorkflowAgentPublishService._clone_inline_graph_binding_for_node( - session=session, + session=sqlite_session, draft_workflow=_workflow(), node_id="target-node", source_agent_id="source-agent", @@ -443,37 +492,45 @@ def test_clone_inline_graph_binding_for_node_rejects_missing_source(scalar_resul ) -def test_restore_clones_inline_binding_owned_by_published_workflow(monkeypatch: pytest.MonkeyPatch) -> None: +def test_restore_clones_inline_binding_owned_by_published_workflow( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: + source_agent = _inline_agent(agent_id="published-agent", workflow_id="published-workflow", node_id="agent-node") + source_snapshot = _snapshot(snapshot_id="published-snapshot", agent_id=source_agent.id) source = WorkflowAgentNodeBinding( + id="published-binding", tenant_id="tenant-1", app_id="app-1", workflow_id="published-workflow", workflow_version="published", node_id="agent-node", binding_type=WorkflowAgentBindingType.INLINE_AGENT, - agent_id="published-agent", - current_snapshot_id="published-snapshot", + agent_id=source_agent.id, + current_snapshot_id=source_snapshot.id, node_job_config={"workflow_prompt": "work"}, created_by="account-1", ) - session = Mock() - session.scalars.side_effect = [SimpleNamespace(all=lambda: []), SimpleNamespace(all=lambda: [source])] - monkeypatch.setattr( - WorkflowAgentPublishService, - "_resolve_inline_agent_graph_binding", - Mock(side_effect=ValueError("owned by published workflow")), - ) - clone = Mock(return_value=(SimpleNamespace(id="draft-agent"), "draft-snapshot")) - monkeypatch.setattr(WorkflowAgentPublishService, "_clone_inline_graph_binding_for_node", clone) + sqlite_session.add_all([source_agent, source_snapshot, source]) + sqlite_session.commit() + target_agent = _inline_agent(agent_id="draft-agent", workflow_id="draft-workflow", node_id="agent-node") + target_snapshot = _snapshot(snapshot_id="draft-snapshot", agent_id=target_agent.id) + clone = Mock(return_value=(target_agent, target_snapshot)) + monkeypatch.setattr(AgentDslService, "clone_inline_binding_for_node", clone) WorkflowAgentPublishService.restore_agent_node_bindings_to_draft( - session=session, + session=sqlite_session, source_workflow=_workflow(workflow_id="published-workflow", version="published"), draft_workflow=_workflow(workflow_id="draft-workflow"), account_id="account-2", ) clone.assert_called_once() - restored = session.add.call_args.args[0] + restored = sqlite_session.scalar( + select(WorkflowAgentNodeBinding).where( + WorkflowAgentNodeBinding.workflow_id == "draft-workflow", + WorkflowAgentNodeBinding.workflow_version == Workflow.VERSION_DRAFT, + ) + ) + assert restored is not None assert restored.agent_id == "draft-agent" assert restored.current_snapshot_id == "draft-snapshot" diff --git a/api/tests/unit_tests/services/agent/test_workspace_service.py b/api/tests/unit_tests/services/agent/test_workspace_service.py index e8ce1e1f657..2a239c6e726 100644 --- a/api/tests/unit_tests/services/agent/test_workspace_service.py +++ b/api/tests/unit_tests/services/agent/test_workspace_service.py @@ -7,7 +7,6 @@ import pytest from sqlalchemy import select from sqlalchemy.orm import Session -from configs import dify_config from models.agent import ( AgentConfigVersionKind, AgentHomeSnapshot, @@ -22,6 +21,7 @@ from services.agent.workspace_service import ( AgentWorkspaceService, WorkspaceOwnerScope, ) +from tests.unit_tests.config_override import apply_config_overrides def _scope() -> WorkspaceOwnerScope: @@ -94,19 +94,17 @@ def _binding( def test_workspace_client_honors_the_configured_snapshot_timeout(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(dify_config, "AGENT_BACKEND_BASE_URL", "http://agent.example") - monkeypatch.setattr(dify_config, "AGENT_BACKEND_HOME_SNAPSHOT_TIMEOUT_SECONDS", 123.5) + apply_config_overrides( + monkeypatch, + AGENT_BACKEND_BASE_URL="http://agent.example", + AGENT_BACKEND_HOME_SNAPSHOT_TIMEOUT_SECONDS=123.5, + ) client = AgentWorkspaceService._client() assert client._timeout == 123.5 -@pytest.mark.parametrize( - "sqlite_session", - [(AgentHomeSnapshot, AgentWorkspace, AgentWorkspaceBinding)], - indirect=True, -) def test_create_binding_success_persists_new_workspace_and_binding( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: @@ -145,11 +143,6 @@ def test_create_binding_success_persists_new_workspace_and_binding( assert request.home_snapshot_ref == "home-ref" -@pytest.mark.parametrize( - "sqlite_session", - [(AgentHomeSnapshot, AgentWorkspace, AgentWorkspaceBinding)], - indirect=True, -) def test_create_binding_without_home_snapshot_uses_backend_default( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: @@ -173,11 +166,6 @@ def test_create_binding_without_home_snapshot_uses_backend_default( assert request.home_snapshot_ref is None -@pytest.mark.parametrize( - "sqlite_session", - [(AgentHomeSnapshot, AgentWorkspace, AgentWorkspaceBinding)], - indirect=True, -) def test_create_binding_rejects_missing_explicit_home_snapshot_before_backend_call( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: @@ -197,11 +185,6 @@ def test_create_binding_rejects_missing_explicit_home_snapshot_before_backend_ca client.create_execution_binding_sync.assert_not_called() -@pytest.mark.parametrize( - "sqlite_session", - [(AgentHomeSnapshot, AgentWorkspace, AgentWorkspaceBinding)], - indirect=True, -) def test_create_second_binding_reuses_existing_workspace( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: @@ -240,7 +223,6 @@ def test_create_second_binding_reuses_existing_workspace( assert request.workspace_id == workspace.id -@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True) def test_get_active_binding_resolves_exact_participant(sqlite_session: Session) -> None: conversation_workspace = _workspace(workspace_id="workspace-conversation") build_workspace = _workspace( @@ -271,7 +253,6 @@ def test_get_active_binding_resolves_exact_participant(sqlite_session: Session) assert resolved.id == conversation_binding.id -@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True) def test_get_active_binding_rejects_wrong_owner(sqlite_session: Session) -> None: build_workspace = _workspace( workspace_id="workspace-build", @@ -296,7 +277,6 @@ def test_get_active_binding_rejects_wrong_owner(sqlite_session: Session) -> None assert resolved is None -@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True) def test_retire_non_final_binding_keeps_workspace_active(sqlite_session: Session) -> None: binding = _binding() other_binding = _binding(binding_id="binding-2", agent_id="agent-2") @@ -317,7 +297,6 @@ def test_retire_non_final_binding_keeps_workspace_active(sqlite_session: Session assert other_binding.status is AgentWorkingResourceStatus.ACTIVE -@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True) def test_retire_final_binding_retires_workspace(sqlite_session: Session) -> None: binding = _binding() workspace = _workspace() @@ -332,15 +311,14 @@ def test_retire_final_binding_retires_workspace(sqlite_session: Session) -> None assert workspace.retired_at == binding.retired_at -def test_retire_workspace_retires_all_active_bindings() -> None: +def test_retire_workspace_retires_all_active_bindings(sqlite_session: Session) -> None: workspace = _workspace() bindings = [_binding(), _binding(binding_id="binding-2", agent_id="agent-2")] - session = MagicMock() - session.scalar.return_value = workspace - session.scalars.return_value.all.return_value = bindings + sqlite_session.add_all([workspace, *bindings]) + sqlite_session.flush() retired_id = AgentWorkspaceService.retire_workspace( - session=session, + session=sqlite_session, tenant_id="tenant-1", workspace_id=workspace.id, ) @@ -351,7 +329,6 @@ def test_retire_workspace_retires_all_active_bindings() -> None: assert all(binding.retired_at == workspace.retired_at for binding in bindings) -@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True) def test_retire_all_for_app_retires_only_active_workspaces_for_that_app(sqlite_session: Session) -> None: active = _workspace(workspace_id="workspace-active", owner_id="conversation-active") already_retired = _workspace( @@ -387,7 +364,6 @@ def test_retire_all_for_app_retires_only_active_workspaces_for_that_app(sqlite_s assert other_binding.status is AgentWorkingResourceStatus.ACTIVE -@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True) def test_collect_binding_without_retired_workspace_destroys_binding_only( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: @@ -411,7 +387,6 @@ def test_collect_binding_without_retired_workspace_destroys_binding_only( assert sqlite_session.get(AgentWorkspace, workspace.id) is not None -@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True) def test_collect_workspace_destroys_workspace_then_remaining_bindings( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: diff --git a/api/tests/unit_tests/services/controller_api.py b/api/tests/unit_tests/services/controller_api.py index 4dd0019cfc0..3ef2a15c3ca 100644 --- a/api/tests/unit_tests/services/controller_api.py +++ b/api/tests/unit_tests/services/controller_api.py @@ -82,7 +82,7 @@ This test suite follows a comprehensive testing strategy that covers: ================================================================================ """ -from collections.abc import Iterator +from collections.abc import Callable, Iterator from types import SimpleNamespace from unittest.mock import Mock, patch from uuid import uuid4 @@ -813,7 +813,8 @@ class TestExternalDatasetApi: ) @pytest.fixture - def mock_current_account_context(self, app: Flask) -> Iterator[Mock]: + def mock_current_account_context(self, app: Flask, config_overrides: Callable[..., None]) -> Iterator[Mock]: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) """Provide the wrapper auth context required by HTTP-client controller tests.""" mock_user = Account( name="Test User", @@ -830,7 +831,6 @@ class TestExternalDatasetApi: with ( patch("controllers.console.wraps.current_account_with_tenant") as mock_get_user, - patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("libs.login.check_csrf_token", return_value=None), ): mock_tenant_id = "tenant-123" diff --git a/api/tests/unit_tests/services/data_migration/test_import_service.py b/api/tests/unit_tests/services/data_migration/test_import_service.py index 80e155c1dae..512d2f10e66 100644 --- a/api/tests/unit_tests/services/data_migration/test_import_service.py +++ b/api/tests/unit_tests/services/data_migration/test_import_service.py @@ -28,6 +28,7 @@ from services.data_migration.entities import ( ) from services.data_migration.import_service import ImportRequest, ImportTargetResolver, MigrationImportService from services.entities.dsl_entities import ImportStatus +from tests.unit_tests.config_override import apply_config_overrides @dataclass(frozen=True) @@ -347,7 +348,7 @@ def test_workflow_app_import_closes_read_transaction_before_dsl_overwrite( return Import(id="import-id", status=ImportStatus.COMPLETED, app_id="imported-app-id") monkeypatch.setattr(import_service, "AppDslService", StubAppDslService) - monkeypatch.setattr(import_service.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) existing_app = _persist_app(database.session, app_id="11111111-1111-4111-8111-111111111111") database.session.begin() diff --git a/api/tests/unit_tests/services/enterprise/test_rbac_service.py b/api/tests/unit_tests/services/enterprise/test_rbac_service.py index bb1d883eee0..c65a015db79 100644 --- a/api/tests/unit_tests/services/enterprise/test_rbac_service.py +++ b/api/tests/unit_tests/services/enterprise/test_rbac_service.py @@ -1000,7 +1000,7 @@ class TestMemberRoles: } assert persisted_joins == { "acct-2": svc.TenantAccountRole.OWNER, - "acct-owner": svc.TenantAccountRole.ADMIN, + "acct-owner": svc.TenantAccountRole.NORMAL, } assert out.roles[0].id == "owner" @@ -1306,3 +1306,16 @@ class TestListOption: "page_number": 1, "resource_type": "app", } + + +class TestLegacyAgentManageKey: + def test_legacy_agent_manage_key_membership(self): + # Preserve Agent access for every legacy role while external RBAC is disabled. + for keys in ( + svc._LEGACY_WORKSPACE_OWNER_KEYS, + svc._LEGACY_WORKSPACE_ADMIN_KEYS, + svc._LEGACY_WORKSPACE_EDITOR_KEYS, + svc._LEGACY_WORKSPACE_NORMAL_KEYS, + svc._LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS, + ): + assert "agent.manage" in keys diff --git a/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_remote_retrieval.py b/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_remote_retrieval.py index 273f89b8db3..27eff6e2aeb 100644 --- a/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_remote_retrieval.py +++ b/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_remote_retrieval.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import Session from services.rag_pipeline.pipeline_template.database.database_retrieval import DatabasePipelineTemplateRetrieval from services.rag_pipeline.pipeline_template.pipeline_template_type import PipelineTemplateType from services.rag_pipeline.pipeline_template.remote.remote_retrieval import RemotePipelineTemplateRetrieval +from tests.unit_tests.config_override import apply_config_overrides @pytest.mark.parametrize("sqlite_session", [()], indirect=True) @@ -54,12 +55,8 @@ def test_get_pipeline_template_detail_fallbacks_to_database_on_error( assert not sqlite_session.in_transaction() -def test_fetch_pipeline_templates_from_dify_official(mocker: MockerFixture) -> None: - mocker.patch( - "services.rag_pipeline.pipeline_template.remote.remote_retrieval" - ".dify_config.HOSTED_FETCH_PIPELINE_TEMPLATES_REMOTE_DOMAIN", - "https://example.com", - ) +def test_fetch_pipeline_templates_from_dify_official(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch) -> None: + apply_config_overrides(monkeypatch, HOSTED_FETCH_PIPELINE_TEMPLATES_REMOTE_DOMAIN="https://example.com") success_response = mocker.Mock(status_code=200) success_response.json.return_value = {"pipeline_templates": [{"id": "remote-1"}]} @@ -80,12 +77,10 @@ def test_fetch_pipeline_templates_from_dify_official(mocker: MockerFixture) -> N assert http_get_mock.call_count == 2 -def test_fetch_pipeline_template_detail_from_dify_official(mocker: MockerFixture) -> None: - mocker.patch( - "services.rag_pipeline.pipeline_template.remote.remote_retrieval" - ".dify_config.HOSTED_FETCH_PIPELINE_TEMPLATES_REMOTE_DOMAIN", - "https://example.com", - ) +def test_fetch_pipeline_template_detail_from_dify_official( + mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + apply_config_overrides(monkeypatch, HOSTED_FETCH_PIPELINE_TEMPLATES_REMOTE_DOMAIN="https://example.com") success_response = mocker.Mock(status_code=200) success_response.json.return_value = {"id": "remote-1", "name": "Remote Template"} diff --git a/api/tests/unit_tests/services/rag_pipeline/test_pipeline_generate_service.py b/api/tests/unit_tests/services/rag_pipeline/test_pipeline_generate_service.py index 27188816af6..fabc093c9b0 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_pipeline_generate_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_pipeline_generate_service.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + import pytest from pytest_mock import MockerFixture from sqlalchemy.orm import Session @@ -76,9 +78,10 @@ def _make_document( ) -def test_get_max_active_requests_uses_smallest_non_zero_limit(mocker: MockerFixture) -> None: - mocker.patch("services.rag_pipeline.pipeline_generate_service.dify_config.APP_DEFAULT_ACTIVE_REQUESTS", 5) - mocker.patch("services.rag_pipeline.pipeline_generate_service.dify_config.APP_MAX_ACTIVE_REQUESTS", 3) +def test_get_max_active_requests_uses_smallest_non_zero_limit( + config_overrides: Callable[..., None], +) -> None: + config_overrides(APP_DEFAULT_ACTIVE_REQUESTS=5, APP_MAX_ACTIVE_REQUESTS=3) app_model = _make_app(max_active_requests=10) @@ -87,9 +90,10 @@ def test_get_max_active_requests_uses_smallest_non_zero_limit(mocker: MockerFixt assert result == 3 -def test_get_max_active_requests_returns_zero_when_all_unlimited(mocker: MockerFixture) -> None: - mocker.patch("services.rag_pipeline.pipeline_generate_service.dify_config.APP_DEFAULT_ACTIVE_REQUESTS", 0) - mocker.patch("services.rag_pipeline.pipeline_generate_service.dify_config.APP_MAX_ACTIVE_REQUESTS", 0) +def test_get_max_active_requests_returns_zero_when_all_unlimited( + config_overrides: Callable[..., None], +) -> None: + config_overrides(APP_DEFAULT_ACTIVE_REQUESTS=0, APP_MAX_ACTIVE_REQUESTS=0) app_model = _make_app(max_active_requests=0) diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline.py index 2ddb1ea4485..dcbff0dbdf3 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + from pytest_mock import MockerFixture from services.rag_pipeline.rag_pipeline import RagPipelineService @@ -9,8 +11,9 @@ def _make_service() -> RagPipelineService: def test_fetch_recommended_plugin_manifests_returns_empty_when_disabled( mocker: MockerFixture, + config_overrides: Callable[..., None], ) -> None: - mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.MARKETPLACE_ENABLED", False) + config_overrides(MARKETPLACE_ENABLED=False) batch_fetch = mocker.patch("services.rag_pipeline.rag_pipeline.marketplace.batch_fetch_plugin_by_ids") service = _make_service() @@ -22,8 +25,9 @@ def test_fetch_recommended_plugin_manifests_returns_empty_when_disabled( def test_fetch_recommended_plugin_manifests_returns_data_when_enabled( mocker: MockerFixture, + config_overrides: Callable[..., None], ) -> None: - mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.MARKETPLACE_ENABLED", True) + config_overrides(MARKETPLACE_ENABLED=True) expected = [{"plugin_id": "langgenius/openai", "name": "OpenAI"}] mocker.patch( "services.rag_pipeline.rag_pipeline.marketplace.batch_fetch_plugin_by_ids", diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_dsl_service.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_dsl_service.py index a2cc34741ef..f7f8e377f41 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_dsl_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_dsl_service.py @@ -11,7 +11,7 @@ import json from collections.abc import Generator from contextlib import contextmanager from types import SimpleNamespace -from typing import Any, cast +from typing import Any from unittest.mock import MagicMock, Mock, call import pytest @@ -96,6 +96,27 @@ def _workflow(session: Session, pipeline: Pipeline, *, graph: dict[str, Any] | N return workflow +def _workflow_for_dependencies( + *, graph: dict[str, Any], environment_variables: list[LLMEnvironmentVariable] | None = None +) -> Workflow: + workflow = Workflow( + id="workflow-dependencies", + tenant_id="tenant-1", + app_id="pipeline-1", + type=WorkflowType.RAG_PIPELINE, + kind=WorkflowKind.STANDARD, + version=Workflow.VERSION_DRAFT, + graph=json.dumps(graph), + features="{}", + created_by="account-1", + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=[], + ) + workflow.environment_variables = environment_variables or [] + return workflow + + def _dataset( session: Session, pipeline: Pipeline, @@ -240,8 +261,8 @@ def test_extract_dependencies_from_model_config_covers_models_rerankers_and_tool def test_extract_workflow_dependencies_uses_llm_environment_variable_provider( monkeypatch: pytest.MonkeyPatch, service: RagPipelineDslService ) -> None: - workflow = SimpleNamespace( - graph_dict={ + workflow = _workflow_for_dependencies( + graph={ "nodes": [ { "id": "llm-node", @@ -271,7 +292,7 @@ def test_extract_workflow_dependencies_uses_llm_environment_variable_provider( analyze_dependency, ) - result = service._extract_dependencies_from_workflow(cast(Workflow, workflow)) + result = service._extract_dependencies_from_workflow(workflow) assert result == ["new-provider"] analyze_dependency.assert_called_once_with("new-provider") @@ -283,8 +304,8 @@ def test_extract_workflow_dependencies_tolerates_unresolved_llm_environment_refe service: RagPipelineDslService, model_selector: list[str], ) -> None: - workflow = SimpleNamespace( - graph_dict={ + workflow = _workflow_for_dependencies( + graph={ "nodes": [ { "id": "llm-node", @@ -300,7 +321,6 @@ def test_extract_workflow_dependencies_tolerates_unresolved_llm_environment_refe } ] }, - environment_variables=[], ) analyze_dependency = Mock(side_effect=lambda provider: provider) monkeypatch.setattr( @@ -309,7 +329,7 @@ def test_extract_workflow_dependencies_tolerates_unresolved_llm_environment_refe analyze_dependency, ) - result = service._extract_dependencies_from_workflow(cast(Workflow, workflow)) + result = service._extract_dependencies_from_workflow(workflow) assert result == ["old-provider"] analyze_dependency.assert_called_once_with("old-provider") diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py index cea5bc86a02..a7194a805ef 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py @@ -1,5 +1,6 @@ import json import time +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime from types import SimpleNamespace @@ -249,9 +250,9 @@ def _make_recommended_plugin(plugin_id: str) -> PipelineRecommendedPlugin: def test_get_pipeline_templates_fallbacks_to_builtin_for_non_english_empty_result( - mocker: MockerFixture, sqlite_session: Session + mocker: MockerFixture, sqlite_session: Session, config_overrides: Callable[..., None] ) -> None: - mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.HOSTED_FETCH_PIPELINE_TEMPLATES_MODE", "remote") + config_overrides(HOSTED_FETCH_PIPELINE_TEMPLATES_MODE="remote") session = sqlite_session remote_retrieval = mocker.Mock() @@ -290,9 +291,12 @@ def test_get_pipeline_templates_customized_mode_uses_customized_factory( @pytest.mark.parametrize("template_type", ["built-in", "customized"]) def test_get_pipeline_template_detail_uses_expected_mode( - mocker: MockerFixture, template_type: str, sqlite_session: Session + mocker: MockerFixture, + template_type: str, + sqlite_session: Session, + config_overrides: Callable[..., None], ) -> None: - mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.HOSTED_FETCH_PIPELINE_TEMPLATES_MODE", "remote") + config_overrides(HOSTED_FETCH_PIPELINE_TEMPLATES_MODE="remote") session = sqlite_session retrieval = mocker.Mock() retrieval.get_pipeline_template_detail.return_value = {"id": "tpl-1"} @@ -1919,8 +1923,10 @@ def test_init_uses_default_sessionmaker_when_none(mocker: MockerFixture, sqlite_ assert exec_session_maker.kw["expire_on_commit"] is False -def test_get_pipeline_templates_builtin_en_us_no_fallback(mocker: MockerFixture, sqlite_session: Session) -> None: - mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.HOSTED_FETCH_PIPELINE_TEMPLATES_MODE", "remote") +def test_get_pipeline_templates_builtin_en_us_no_fallback( + mocker: MockerFixture, sqlite_session: Session, config_overrides: Callable[..., None] +) -> None: + config_overrides(HOSTED_FETCH_PIPELINE_TEMPLATES_MODE="remote") session = sqlite_session retrieval = mocker.Mock() retrieval.get_pipeline_templates.return_value = {"pipeline_templates": []} diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py index 8c4485b290c..be5728f396e 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py @@ -1,7 +1,6 @@ import logging from datetime import UTC, datetime from types import SimpleNamespace -from typing import cast import pytest from pytest_mock import MockerFixture @@ -15,6 +14,7 @@ from models.model import UploadFile from services.entities.knowledge_entities.rag_pipeline_entities import KnowledgeConfiguration from services.errors.rag_pipeline import RagPipelineResourceNotFoundError from services.rag_pipeline.rag_pipeline_transform_service import RagPipelineTransformService +from tests.unit_tests.config_override import apply_config_overrides def _dataset(**overrides: object) -> Dataset: @@ -47,6 +47,18 @@ def _document(**overrides: object) -> Document: return Document(**values) +def _pipeline(*, pipeline_id: str = "p-new", tenant_id: str = "t1") -> Pipeline: + pipeline = Pipeline( + tenant_id=tenant_id, + name="Pipeline", + description="", + created_by="user-1", + updated_by="user-1", + ) + pipeline.id = pipeline_id + return pipeline + + def _upload_file(*, file_id: str = "file-1", tenant_id: str = "tenant-1") -> UploadFile: upload_file = UploadFile( tenant_id=tenant_id, @@ -257,14 +269,11 @@ def test_transform_dataset_calls_empty_pipeline_when_no_doc_form( def test_deal_knowledge_index_high_quality_sets_embedding(mocker: MockerFixture) -> None: service = RagPipelineTransformService() - dataset = cast( - Dataset, - SimpleNamespace( - embedding_model="text-embedding-ada-002", - embedding_model_provider="openai", - retrieval_model=None, - summary_index_setting=None, - ), + dataset = _dataset( + embedding_model="text-embedding-ada-002", + embedding_model_provider="openai", + retrieval_model=None, + summary_index_setting=None, ) node = { "data": { @@ -388,7 +397,7 @@ def test_transform_dataset_full_flow(mocker: MockerFixture, sqlite_session: Sess mocker.patch.object(service, "_deal_dependencies") mocker.patch.object(service, "_deal_document_data") - pipeline = SimpleNamespace(id="p-new") + pipeline = _pipeline() create_pipeline = mocker.patch.object(service, "_create_pipeline", return_value=pipeline) result = service.transform_dataset(dataset, "user-1", sqlite_session) @@ -425,7 +434,7 @@ def test_transform_dataset_raises_for_unsupported_doc_form_after_pipeline_create sqlite_session.commit() mocker.patch.object(service, "_get_transform_yaml", return_value={"workflow": {"graph": {"nodes": []}}}) mocker.patch.object(service, "_deal_dependencies") - mocker.patch.object(service, "_create_pipeline", return_value=SimpleNamespace(id="p-new")) + mocker.patch.object(service, "_create_pipeline", return_value=_pipeline()) with pytest.raises(ValueError, match="Unsupported doc form"): service.transform_dataset(dataset, "user-1", sqlite_session) @@ -529,12 +538,9 @@ def _make_service(): def test_deal_dependencies_skips_marketplace_when_disabled( - mocker: MockerFixture, caplog: pytest.LogCaptureFixture + mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.dify_config.MARKETPLACE_ENABLED", - False, - ) + apply_config_overrides(monkeypatch, MARKETPLACE_ENABLED=False) installer = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller").return_value installer.list_plugins.return_value = [] mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration") @@ -559,11 +565,8 @@ def test_deal_dependencies_skips_marketplace_when_disabled( assert any("Marketplace disabled" in rec.message for rec in caplog.records) -def test_deal_dependencies_installs_when_enabled(mocker: MockerFixture) -> None: - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.dify_config.MARKETPLACE_ENABLED", - True, - ) +def test_deal_dependencies_installs_when_enabled(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch) -> None: + apply_config_overrides(monkeypatch, MARKETPLACE_ENABLED=True) installer = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller").return_value installer.list_plugins.return_value = [] migration = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration").return_value diff --git a/api/tests/unit_tests/services/test_account_email_registration_adapters.py b/api/tests/unit_tests/services/test_account_email_registration_adapters.py new file mode 100644 index 00000000000..14d2fd725f2 --- /dev/null +++ b/api/tests/unit_tests/services/test_account_email_registration_adapters.py @@ -0,0 +1,176 @@ +from unittest.mock import Mock, patch + +import pytest +from sqlalchemy.orm import Session, sessionmaker + +from extensions.ext_redis import RedisClientWrapper +from models.account import Account +from services.account_email_registration_adapters import ( + AccountServiceRegistrationGateway, + BillingAccountRegistrationPolicyGateway, + RedisEmailRegistrationSecurityGateway, + TokenManagerEmailRegistrationTokenGateway, +) +from services.account_errors import ( + AccountEmailDomainSuspendedError, + AccountNormalizedEmailAlreadyInUseError, + EmailRegistrationSeatsLimitError, +) +from services.account_service import TokenPair +from services.entities.account_entities import AccountEmailRegistrationPhase, AccountEmailRegistrationToken +from services.errors.account import ( + AccountNormalizedEmailAlreadyInUseError as AccountNormalizedEmailAlreadyInUseServiceError, +) +from services.errors.account import EmailDomainSuspendedError, SeatsLimitExceededError + + +def test_token_gateway_rejects_malformed_payload() -> None: + gateway = TokenManagerEmailRegistrationTokenGateway() + + with patch( + "services.account_email_registration_adapters.TokenManager.get_token_data", + return_value={"email": "user@example.com", "phase": "unknown"}, + ): + assert gateway.get("token") is None + + +def test_token_gateway_issues_verified_registration_state() -> None: + gateway = TokenManagerEmailRegistrationTokenGateway() + token_data = AccountEmailRegistrationToken( + email="user@example.com", + code="123456", + phase=AccountEmailRegistrationPhase.REGISTER, + ) + + with patch( + "services.account_email_registration_adapters.TokenManager.generate_token", + return_value="token", + ) as generate_token: + assert gateway.issue(token_data) == "token" + + generate_token.assert_called_once_with( + email="user@example.com", + token_type="email_register", + additional_data={"code": "123456", "phase": "register"}, + ) + + +def test_security_gateway_delegates_ip_limit_to_existing_policy_owner() -> None: + redis = Mock(spec=RedisClientWrapper) + gateway = RedisEmailRegistrationSecurityGateway( + redis=redis, + verification_failure_limit=5, + verification_lockout_duration=600, + ) + + with patch( + "services.account_email_registration_adapters.AccountService.is_email_send_ip_limit", + return_value=False, + ) as is_email_send_ip_limit: + assert gateway.is_ip_limited("127.0.0.1") is False + + is_email_send_ip_limit.assert_called_once_with("127.0.0.1") + redis.get.assert_not_called() + + +def test_security_gateway_uses_registration_and_login_keys() -> None: + redis = Mock(spec=RedisClientWrapper) + redis.get.return_value = 1 + gateway = RedisEmailRegistrationSecurityGateway( + redis=redis, + verification_failure_limit=5, + verification_lockout_duration=600, + ) + + with patch( + "services.account_email_registration_adapters.AccountService.reset_login_error_rate_limit" + ) as reset_login_error_rate_limit: + gateway.record_verification_failure("user@example.com") + gateway.reset_verification_failures("user@example.com") + gateway.reset_login_failures("user@example.com") + + redis.setex.assert_called_once_with("email_register_error_rate_limit:user@example.com", 600, 2) + redis.delete.assert_called_once_with("email_register_error_rate_limit:user@example.com") + reset_login_error_rate_limit.assert_called_once_with("user@example.com") + + +def test_billing_policy_is_disabled_outside_cloud() -> None: + gateway = BillingAccountRegistrationPolicyGateway(enabled=False) + + with patch("services.account_email_registration_adapters.BillingService.get_email_freeze_type") as freeze_type: + assert gateway.get_freeze_type("user@example.com") is None + + freeze_type.assert_not_called() + + +@pytest.mark.parametrize( + ("service_error", "application_error"), + [ + pytest.param(SeatsLimitExceededError(), EmailRegistrationSeatsLimitError, id="seat-limit"), + pytest.param(EmailDomainSuspendedError(), AccountEmailDomainSuspendedError, id="suspended-domain"), + pytest.param( + AccountNormalizedEmailAlreadyInUseServiceError(), + AccountNormalizedEmailAlreadyInUseError, + id="normalized-email-in-use", + ), + ], +) +def test_registration_gateway_translates_account_provisioning_errors( + sqlite_session_factory: sessionmaker[Session], + service_error: Exception, + application_error: type[Exception], +) -> None: + gateway = AccountServiceRegistrationGateway(session_factory=sqlite_session_factory) + + with patch( + "services.account_email_registration_adapters.AccountService.create_account_and_tenant", + side_effect=service_error, + ): + with pytest.raises(application_error): + gateway.create( + email="user@example.com", + password="ValidPass123!", + interface_language="en-US", + timezone=None, + ip_address="127.0.0.1", + ) + + +def test_registration_gateway_owns_short_lived_sessions( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + gateway = AccountServiceRegistrationGateway(session_factory=sqlite_session_factory) + + def create_account(*, session: Session, **_: object) -> Account: + account = Account(name="user@example.com", email="user@example.com") + account.id = "account-1" + session.add(account) + session.commit() + return account + + with patch( + "services.account_email_registration_adapters.AccountService.create_account_and_tenant", + side_effect=create_account, + ) as create_account_and_tenant: + account_id = gateway.create( + email="user@example.com", + password="ValidPass123!", + interface_language="en-US", + timezone=None, + ip_address="127.0.0.1", + ) + + assert create_account_and_tenant.call_args.kwargs["check_normalized_email"] is True + sqlite_session.expire_all() + assert sqlite_session.get(Account, account_id) is not None + + with patch( + "services.account_email_registration_adapters.AccountService.login", + return_value=TokenPair(access_token="access", refresh_token="refresh", csrf_token="csrf"), + ) as login: + tokens = gateway.login(account_id, ip_address="127.0.0.1") + + assert tokens.access_token == "access" + assert login.call_args.kwargs["account"].id == account_id + assert isinstance(login.call_args.kwargs["session"], Session) diff --git a/api/tests/unit_tests/services/test_account_email_registration_service.py b/api/tests/unit_tests/services/test_account_email_registration_service.py new file mode 100644 index 00000000000..b3091f51438 --- /dev/null +++ b/api/tests/unit_tests/services/test_account_email_registration_service.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +from datetime import datetime +from unittest.mock import Mock + +import pytest + +from services.account_email_registration_service import ( + AccountEmailRegistrationService, + AccountRegistrationGateway, + AccountRegistrationPolicyGateway, + EmailRegistrationCodeGenerator, + EmailRegistrationNotificationGateway, + EmailRegistrationSecurityGateway, + EmailRegistrationSendLimiter, + EmailRegistrationTokenGateway, +) +from services.account_errors import ( + AccountEmailAlreadyInUseError, + AccountEmailDomainSuspendedError, + EmailRegistrationPasswordMismatchError, + InvalidEmailRegistrationCodeError, + InvalidEmailRegistrationTokenError, +) +from services.account_ports import AccountRepository +from services.entities.account_entities import ( + AccountEmailRegistrationPhase, + AccountEmailRegistrationToken, + AccountSessionTokens, + AccountSnapshot, +) + + +def _account(*, email: str = "stored@example.com") -> AccountSnapshot: + return AccountSnapshot( + id="account-1", + name="Stored Account", + email=email, + avatar=None, + is_password_set=True, + interface_language="en-US", + interface_theme="light", + timezone="UTC", + last_login_at=None, + last_login_ip=None, + status="active", + initialized_at=datetime(2026, 1, 1), + created_at=datetime(2026, 1, 1), + ) + + +def _service() -> tuple[AccountEmailRegistrationService, dict[str, Mock]]: + dependencies = { + "accounts": Mock(spec=AccountRepository), + "tokens": Mock(spec=EmailRegistrationTokenGateway), + "codes": Mock(spec=EmailRegistrationCodeGenerator), + "notifications": Mock(spec=EmailRegistrationNotificationGateway), + "send_limits": Mock(spec=EmailRegistrationSendLimiter), + "security": Mock(spec=EmailRegistrationSecurityGateway), + "account_policy": Mock(spec=AccountRegistrationPolicyGateway), + "registration": Mock(spec=AccountRegistrationGateway), + } + service = AccountEmailRegistrationService( + accounts=dependencies["accounts"], + tokens=dependencies["tokens"], + codes=dependencies["codes"], + notifications=dependencies["notifications"], + send_limits=dependencies["send_limits"], + security=dependencies["security"], + account_policy=dependencies["account_policy"], + registration=dependencies["registration"], + ) + dependencies["accounts"].find_by_email.return_value = None + dependencies["codes"].generate.return_value = "123456" + dependencies["tokens"].issue.return_value = "token-1" + dependencies["send_limits"].is_limited.return_value = False + dependencies["security"].is_ip_limited.return_value = False + dependencies["security"].is_verification_limited.return_value = False + dependencies["account_policy"].get_freeze_type.return_value = None + return service, dependencies + + +def test_send_code_uses_case_fallback_account_and_existing_account_notification() -> None: + service, dependencies = _service() + dependencies["accounts"].find_by_email.return_value = _account(email="Stored@Example.com") + + token = service.send_code( + remote_ip="127.0.0.1", + requested_email="Stored@Example.com", + requested_language="zh-Hans", + ) + + assert token == "token-1" + dependencies["accounts"].find_by_email.assert_called_once_with("Stored@Example.com") + dependencies["tokens"].issue.assert_called_once_with( + AccountEmailRegistrationToken(email="Stored@Example.com", code="123456") + ) + dependencies["notifications"].send_account_exists.assert_called_once_with( + email="Stored@Example.com", + account_name="Stored Account", + language="zh-Hans", + ) + dependencies["send_limits"].record.assert_called_once_with("Stored@Example.com") + + +def test_send_code_normalizes_new_account_email_and_language() -> None: + service, dependencies = _service() + + service.send_code( + remote_ip="127.0.0.1", + requested_email="New@Example.com", + requested_language="unsupported", + ) + + dependencies["notifications"].send_code.assert_called_once_with( + email="new@example.com", + code="123456", + language="en-US", + ) + + +def test_send_code_rejects_suspended_domain_before_account_lookup() -> None: + service, dependencies = _service() + dependencies["account_policy"].get_freeze_type.return_value = "email_domain_suspended" + + with pytest.raises(AccountEmailDomainSuspendedError): + service.send_code( + remote_ip="127.0.0.1", + requested_email="user@suspended.example", + requested_language=None, + ) + + dependencies["accounts"].find_by_email.assert_not_called() + + +def test_verify_code_rotates_token_into_register_phase() -> None: + service, dependencies = _service() + dependencies["tokens"].get.return_value = AccountEmailRegistrationToken( + email="User@Example.com", + code="123456", + ) + dependencies["tokens"].issue.return_value = "verified-token" + + verification = service.verify_code( + email="USER@example.com", + code="123456", + token="pending-token", + ) + + assert verification.email == "user@example.com" + assert verification.token == "verified-token" + dependencies["tokens"].revoke.assert_called_once_with("pending-token") + dependencies["tokens"].issue.assert_called_once_with( + AccountEmailRegistrationToken( + email="user@example.com", + code="123456", + phase=AccountEmailRegistrationPhase.REGISTER, + ) + ) + dependencies["security"].reset_verification_failures.assert_called_once_with("user@example.com") + + +def test_verify_code_records_failure_without_consuming_token() -> None: + service, dependencies = _service() + dependencies["tokens"].get.return_value = AccountEmailRegistrationToken( + email="user@example.com", + code="123456", + ) + + with pytest.raises(InvalidEmailRegistrationCodeError): + service.verify_code(email="user@example.com", code="wrong", token="pending-token") + + dependencies["security"].record_verification_failure.assert_called_once_with("user@example.com") + dependencies["tokens"].revoke.assert_not_called() + + +def test_register_creates_account_and_logs_it_in() -> None: + service, dependencies = _service() + dependencies["tokens"].get.return_value = AccountEmailRegistrationToken( + email="New@Example.com", + code="123456", + phase=AccountEmailRegistrationPhase.REGISTER, + ) + dependencies["registration"].create.return_value = "account-1" + expected_tokens = AccountSessionTokens(access_token="access", refresh_token="refresh", csrf_token="csrf") + dependencies["registration"].login.return_value = expected_tokens + + tokens = service.register( + remote_ip="127.0.0.1", + token="verified-token", + new_password="ValidPass123!", + password_confirm="ValidPass123!", + language="zh-Hans", + timezone="Asia/Shanghai", + ) + + assert tokens == expected_tokens + dependencies["tokens"].revoke.assert_called_once_with("verified-token") + dependencies["accounts"].find_by_email.assert_called_once_with("New@Example.com") + dependencies["registration"].create.assert_called_once_with( + email="new@example.com", + password="ValidPass123!", + interface_language="zh-Hans", + timezone="Asia/Shanghai", + ip_address="127.0.0.1", + ) + dependencies["registration"].login.assert_called_once_with("account-1", ip_address="127.0.0.1") + dependencies["security"].reset_login_failures.assert_called_once_with("new@example.com") + + +def test_register_rejects_password_mismatch_before_reading_token() -> None: + service, dependencies = _service() + + with pytest.raises(EmailRegistrationPasswordMismatchError): + service.register( + remote_ip="127.0.0.1", + token="verified-token", + new_password="ValidPass123!", + password_confirm="DifferentPass123!", + language=None, + timezone=None, + ) + + dependencies["tokens"].get.assert_not_called() + + +def test_register_requires_verified_registration_phase() -> None: + service, dependencies = _service() + dependencies["tokens"].get.return_value = AccountEmailRegistrationToken( + email="new@example.com", + code="123456", + ) + + with pytest.raises(InvalidEmailRegistrationTokenError): + service.register( + remote_ip="127.0.0.1", + token="pending-token", + new_password="ValidPass123!", + password_confirm="ValidPass123!", + language=None, + timezone=None, + ) + + dependencies["tokens"].revoke.assert_not_called() + + +def test_register_consumes_token_before_rejecting_existing_account() -> None: + service, dependencies = _service() + dependencies["tokens"].get.return_value = AccountEmailRegistrationToken( + email="existing@example.com", + code="123456", + phase=AccountEmailRegistrationPhase.REGISTER, + ) + dependencies["accounts"].find_by_email.return_value = _account(email="existing@example.com") + + with pytest.raises(AccountEmailAlreadyInUseError): + service.register( + remote_ip="127.0.0.1", + token="verified-token", + new_password="ValidPass123!", + password_confirm="ValidPass123!", + language=None, + timezone=None, + ) + + dependencies["tokens"].revoke.assert_called_once_with("verified-token") + dependencies["registration"].create.assert_not_called() diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index 857e53594a3..79fcfb65265 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -10,7 +10,6 @@ from sqlalchemy.engine import Connection from sqlalchemy.engine.interfaces import DBAPICursor, ExecutionContext from sqlalchemy.orm import Session, sessionmaker -from configs import dify_config from enums import DeploymentEdition from models.account import ( Account, @@ -28,7 +27,7 @@ from services.account_service import ( RegisterService, TenantService, ) -from services.enterprise.rbac_service import MembersInRole, Paginated +from services.enterprise.rbac_service import MemberRolesResponse, MembersInRole, Paginated, RBACRole from services.errors.account import ( AccountAlreadyInTenantError, AccountEmailAlreadyInUseError, @@ -38,6 +37,7 @@ from services.errors.account import ( EmailDomainSuspendedError, NoPermissionError, ) +from tests.unit_tests.config_override import config_overrides_context type _MockDependencies = dict[str, MagicMock] @@ -83,6 +83,10 @@ def _tenant(session: Session | None = None) -> Tenant: class TestAccountService: + @pytest.fixture(autouse=True) + def _account_config(self, config_overrides: Callable[..., None]) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) + """ Comprehensive unit tests for AccountService methods. @@ -390,20 +394,23 @@ class TestAccountService: ) def test_create_account_email_frozen( - self, unbound_session: Session, mock_external_service_dependencies: _MockDependencies + self, + unbound_session: Session, + mock_external_service_dependencies: _MockDependencies, + config_overrides: Callable[..., None], ) -> None: """Test account creation with frozen email address.""" # Setup mocks mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = True - with patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD): - with pytest.raises(AccountRegisterError): - AccountService.create_account( - email="frozen@example.com", - name="Test User", - interface_language="en-US", - session=unbound_session, - ) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) + with pytest.raises(AccountRegisterError): + AccountService.create_account( + email="frozen@example.com", + name="Test User", + interface_language="en-US", + session=unbound_session, + ) def test_create_account_suspended_email_domain( self, unbound_session: Session, mock_external_service_dependencies: _MockDependencies @@ -414,7 +421,7 @@ class TestAccountService: "billing_service" ].get_email_freeze_type.return_value = "email_domain_suspended" - with patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD): + with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD): with pytest.raises(EmailDomainSuspendedError): AccountService.create_account( email="user@suspended.example", @@ -431,7 +438,7 @@ class TestAccountService: "billing_service" ].get_email_freeze_type.return_value = "email_domain_suspended" - with patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD): + with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD): with pytest.raises(EmailDomainSuspendedError): AccountService.get_user_through_email("user@suspended.example", session=unbound_session) @@ -440,9 +447,9 @@ class TestAccountService: ) -> None: mock_external_service_dependencies["billing_service"].get_email_freeze_type.return_value = "freeze" - with patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD): + with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD): assert AccountService.get_account_freeze_type("frozen@example.com") == "freeze" - with patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY): + with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY): assert AccountService.get_account_freeze_type("frozen@example.com") is None mock_external_service_dependencies["billing_service"].get_email_freeze_type.assert_called_once_with( @@ -768,6 +775,10 @@ class TestAccountService: class TestTenantService: + @pytest.fixture(autouse=True) + def _tenant_config(self, config_overrides: Callable[..., None]) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, RBAC_ENABLED=False) + """ Comprehensive unit tests for TenantService methods. @@ -816,6 +827,14 @@ class TestTenantService: sqlite_session.add(tenant_account_join) return tenant_account_join + def _db_role_of(self, sqlite_session: Session, tenant: Tenant, account_id: str) -> str | None: + return sqlite_session.scalar( + select(TenantAccountJoin.role).where( + TenantAccountJoin.tenant_id == tenant.id, + TenantAccountJoin.account_id == account_id, + ) + ) + def test_iter_member_account_id_batches_uses_offset_limit(self, sqlite_session: Session) -> None: tenant_id = "00000000-0000-0000-0000-000000000001" account_ids = [ @@ -998,7 +1017,7 @@ class TestTenantService: service_session.commit() with ( - patch("services.account_service.dify_config.RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(rbac_task_module.sync_joined_workspace_member_rbac_access_task, "delay", delay), ): TenantService.create_tenant_member( @@ -1031,7 +1050,7 @@ class TestTenantService: service_session.commit() with ( - patch("services.account_service.dify_config.RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(rbac_task_module.sync_joined_workspace_member_rbac_access_task, "delay", delay), ): TenantService.create_tenant_member( @@ -1150,7 +1169,6 @@ class TestTenantService: service_session.commit() with ( - patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync, ): mock_sync.return_value = True @@ -1204,7 +1222,6 @@ class TestTenantService: service_session.commit() with ( - patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync, ): mock_sync.return_value = True @@ -1249,7 +1266,6 @@ class TestTenantService: service_session.commit() with ( - patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync, ): mock_sync.return_value = True @@ -1353,9 +1369,76 @@ class TestTenantService: assert persisted_target_join is not None assert persisted_target_join.role == TenantAccountRole.ADMIN - def test_create_owner_tenant_rbac_enabled_assigns_owner_role( - self, sqlite_session: Session, mock_external_service_dependencies: _MockDependencies + @pytest.mark.parametrize( + ("outgoing_owner_role_tags", "expected_demoted_role_ids"), + [(["owner", "editor"], ["editor-role-id"]), (["owner"], ["no-access-role-id"])], + ) + def test_update_member_role_to_owner_rbac_enabled( + self, + sqlite_session: Session, + outgoing_owner_role_tags: list[str], + expected_demoted_role_ids: list[str], + config_overrides: Callable[..., None], ) -> None: + config_overrides(RBAC_ENABLED=True) + tenant = Tenant(name="Test Workspace") + sqlite_session.add(tenant) + sqlite_session.flush() + + operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-1") + candidate = TestAccountAssociatedDataFactory.create_account_mock(account_id="candidate-1") + self._add_tenant_account_join(sqlite_session, tenant, operator.id, TenantAccountRole.EDITOR) + self._add_tenant_account_join(sqlite_session, tenant, candidate.id, TenantAccountRole.EDITOR) + self._add_tenant_account_join(sqlite_session, tenant, "stale-db-owner", TenantAccountRole.OWNER) + sqlite_session.commit() + + outgoing_owner_roles = MemberRolesResponse( + account_id="real-rbac-owner", + roles=[ + RBACRole(id=f"{tag}-role-id", type="workspace", name=tag, role_tag=tag) + for tag in outgoing_owner_role_tags + ], + ) + + with ( + patch( + "services.account_service.AccountService.get_workspace_permission_keys", + return_value={"workspace.role.manage"}, + ), + patch( + "services.account_service.AccountService.get_rbac_workspace_owner_account_id", + return_value="real-rbac-owner", + ), + patch( + "services.account_service.AccountService._resolve_legacy_role_id", + side_effect=lambda *, role, **_kwargs: f"{role.value}-role-id", + ), + patch( + "services.account_service.AccountService._resolve_role_id_by_tag", + return_value="no-access-role-id", + ), + patch("services.account_service.RBACService.MemberRoles.get", return_value=outgoing_owner_roles), + patch("services.account_service.RBACService.MemberRoles.replace") as mock_replace, + ): + TenantService.update_member_role(tenant, candidate, "owner", operator, session=sqlite_session) + + mock_replace.assert_any_call( + tenant_id=tenant.id, + account_id=operator.id, + member_account_id="real-rbac-owner", + role_ids=expected_demoted_role_ids, + session=sqlite_session, + ) + assert self._db_role_of(sqlite_session, tenant, "stale-db-owner") == TenantAccountRole.NORMAL + assert self._db_role_of(sqlite_session, tenant, candidate.id) == TenantAccountRole.OWNER + + def test_create_owner_tenant_rbac_enabled_assigns_owner_role( + self, + sqlite_session: Session, + mock_external_service_dependencies: _MockDependencies, + config_overrides: Callable[..., None], + ) -> None: + config_overrides(RBAC_ENABLED=True) mock_account = TestAccountAssociatedDataFactory.create_account_mock(account_id="user-rbac", name="RBAC User") mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True mock_external_service_dependencies[ @@ -1368,7 +1451,6 @@ class TestTenantService: sqlite_session.flush() with ( - patch("services.account_service.dify_config.RBAC_ENABLED", True), patch("services.account_service.TenantService.create_tenant", return_value=mock_tenant), patch( "services.account_service.AccountService._resolve_legacy_role_id", @@ -1504,8 +1586,11 @@ class TestTenantService: with pytest.raises(NoPermissionError): TenantService.check_member_permission(tenant, mock_operator, mock_member, "remove", session=sqlite_session) - def test_rbac_member_can_remove_non_owner_member(self, sqlite_session: Session) -> None: + def test_rbac_member_can_remove_non_owner_member( + self, sqlite_session: Session, config_overrides: Callable[..., None] + ) -> None: """Test RBAC workspace.member.manage allows removing a non-owner member.""" + config_overrides(RBAC_ENABLED=True) mock_tenant = _tenant(sqlite_session) mock_tenant.id = "tenant-456" mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") @@ -1515,7 +1600,6 @@ class TestTenantService: mock_permissions.workspace = MagicMock(permission_keys=["workspace.member.manage"]) with ( - patch("services.account_service.dify_config.RBAC_ENABLED", True), patch("services.account_service.RBACService.MyPermissions.get", return_value=mock_permissions), patch("services.account_service.AccountService.is_rbac_workspace_owner", return_value=False), ): @@ -1523,8 +1607,11 @@ class TestTenantService: mock_tenant, mock_operator, mock_member, "remove", session=sqlite_session ) - def test_rbac_member_cannot_remove_without_permission(self, sqlite_session: Session) -> None: + def test_rbac_member_cannot_remove_without_permission( + self, sqlite_session: Session, config_overrides: Callable[..., None] + ) -> None: """Test RBAC permission check rejects removal without workspace.member.manage.""" + config_overrides(RBAC_ENABLED=True) mock_tenant = _tenant(sqlite_session) mock_tenant.id = "tenant-456" mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") @@ -1534,7 +1621,6 @@ class TestTenantService: mock_permissions.workspace = MagicMock(permission_keys=["workspace.role.manage"]) with ( - patch("services.account_service.dify_config.RBAC_ENABLED", True), patch("services.account_service.RBACService.MyPermissions.get", return_value=mock_permissions), ): with pytest.raises(NoPermissionError): @@ -1542,8 +1628,11 @@ class TestTenantService: mock_tenant, mock_operator, mock_member, "remove", session=sqlite_session ) - def test_rbac_member_cannot_remove_owner_member(self, sqlite_session: Session) -> None: + def test_rbac_member_cannot_remove_owner_member( + self, sqlite_session: Session, config_overrides: Callable[..., None] + ) -> None: """Test RBAC permission check rejects removing an owner member.""" + config_overrides(RBAC_ENABLED=True) mock_tenant = _tenant(sqlite_session) mock_tenant.id = "tenant-456" mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") @@ -1553,7 +1642,6 @@ class TestTenantService: mock_permissions.workspace = MagicMock(permission_keys=["workspace.member.manage"]) with ( - patch("services.account_service.dify_config.RBAC_ENABLED", True), patch("services.account_service.RBACService.MyPermissions.get", return_value=mock_permissions), patch("services.account_service.AccountService.is_rbac_workspace_owner", return_value=True), ): @@ -1589,6 +1677,10 @@ class TestTenantService: class TestRegisterService: + @pytest.fixture(autouse=True) + def _register_config(self, config_overrides: Callable[..., None]) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) + """ Comprehensive unit tests for RegisterService methods. @@ -1748,10 +1840,10 @@ class TestRegisterService: self, sqlite_session: Session, mock_external_service_dependencies: _MockDependencies, - monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], ) -> None: """Enterprise-only side effect should be invoked for the ENTERPRISE edition.""" - monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE, raising=False) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False @@ -1794,10 +1886,8 @@ class TestRegisterService: self, sqlite_session: Session, mock_external_service_dependencies: _MockDependencies, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Enterprise-only side effect should not be invoked for the COMMUNITY edition.""" - monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY, raising=False) mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False @@ -1828,12 +1918,12 @@ class TestRegisterService: self, sqlite_session: Session, mock_external_service_dependencies: _MockDependencies, - monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], ) -> None: """Default workspace join should still be attempted when personal workspace creation fails.""" from services.errors.workspace import WorkSpaceNotAllowedCreateError - monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE, raising=False) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False @@ -1935,10 +2025,10 @@ class TestRegisterService: self, sqlite_session: Session, mock_external_service_dependencies: _MockDependencies, - monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], ) -> None: """Enterprise-only side effect should be invoked after successful register commit.""" - monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE, raising=False) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False @@ -1969,10 +2059,8 @@ class TestRegisterService: self, sqlite_session: Session, mock_external_service_dependencies: _MockDependencies, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Enterprise-only side effect should not be invoked for the COMMUNITY edition.""" - monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY, raising=False) mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False @@ -2002,12 +2090,12 @@ class TestRegisterService: self, sqlite_session: Session, mock_external_service_dependencies: _MockDependencies, - monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], ) -> None: """Default workspace join should run even when personal workspace creation raises.""" from services.errors.workspace import WorkSpaceNotAllowedCreateError - monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE, raising=False) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True mock_external_service_dependencies[ @@ -2042,12 +2130,12 @@ class TestRegisterService: self, sqlite_session: Session, mock_external_service_dependencies: _MockDependencies, - monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], ) -> None: """Default workspace join should run before propagating workspace-limit registration failure.""" from services.errors.workspace import WorkspacesLimitExceededError - monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE, raising=False) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True mock_external_service_dependencies[ @@ -3173,6 +3261,10 @@ def test_get_account_by_email_with_case_fallback_uses_lowercase(sqlite_session: class TestIsEmailSendIpLimit: """The 10-minute first-strike window must actually take effect (#39477).""" + @pytest.fixture(autouse=True) + def _email_limit_config(self, config_overrides: Callable[..., None]) -> None: + config_overrides(EMAIL_SEND_IP_LIMIT_PER_MINUTE=60) + def _mock_redis(self, *, minute_count: int, hour_count: int | None, frozen: bool = False) -> MagicMock: values = { "email_send_ip_limit_freeze:1.2.3.4": "1" if frozen else None, @@ -3188,12 +3280,12 @@ class TestIsEmailSendIpLimit: with patch("services.account_service.redis_client", redis_client): assert AccountService.is_email_send_ip_limit("1.2.3.4") is True - def test_first_strike_sets_ten_minute_window(self) -> None: + def test_first_strike_sets_ten_minute_window(self, config_overrides: Callable[..., None]) -> None: + config_overrides(EMAIL_SEND_IP_LIMIT_PER_MINUTE=1) redis_client = self._mock_redis(minute_count=999, hour_count=None) redis_client.set.return_value = True with ( patch("services.account_service.redis_client", redis_client), - patch.object(dify_config, "EMAIL_SEND_IP_LIMIT_PER_MINUTE", 1), ): assert AccountService.is_email_send_ip_limit("1.2.3.4") is True @@ -3203,22 +3295,22 @@ class TestIsEmailSendIpLimit: redis_client.incr.assert_not_called() redis_client.expire.assert_not_called() - def test_first_strike_lost_claim_freezes_immediately(self) -> None: + def test_first_strike_lost_claim_freezes_immediately(self, config_overrides: Callable[..., None]) -> None: + config_overrides(EMAIL_SEND_IP_LIMIT_PER_MINUTE=1) redis_client = self._mock_redis(minute_count=999, hour_count=None) redis_client.set.return_value = None # another worker claimed the strike first with ( patch("services.account_service.redis_client", redis_client), - patch.object(dify_config, "EMAIL_SEND_IP_LIMIT_PER_MINUTE", 1), ): assert AccountService.is_email_send_ip_limit("1.2.3.4") is True redis_client.setex.assert_called_once_with("email_send_ip_limit_freeze:1.2.3.4", 60 * 60, 1) - def test_second_strike_inside_window_freezes_for_an_hour(self) -> None: + def test_second_strike_inside_window_freezes_for_an_hour(self, config_overrides: Callable[..., None]) -> None: + config_overrides(EMAIL_SEND_IP_LIMIT_PER_MINUTE=1) redis_client = self._mock_redis(minute_count=999, hour_count=1) with ( patch("services.account_service.redis_client", redis_client), - patch.object(dify_config, "EMAIL_SEND_IP_LIMIT_PER_MINUTE", 1), ): assert AccountService.is_email_send_ip_limit("1.2.3.4") is True @@ -3228,6 +3320,5 @@ class TestIsEmailSendIpLimit: redis_client = self._mock_redis(minute_count=0, hour_count=None) with ( patch("services.account_service.redis_client", redis_client), - patch.object(dify_config, "EMAIL_SEND_IP_LIMIT_PER_MINUTE", 60), ): assert AccountService.is_email_send_ip_limit("1.2.3.4") is False diff --git a/api/tests/unit_tests/services/test_agent_app_sandbox_service.py b/api/tests/unit_tests/services/test_agent_app_sandbox_service.py index c2418ecd09a..5a8fea8f4aa 100644 --- a/api/tests/unit_tests/services/test_agent_app_sandbox_service.py +++ b/api/tests/unit_tests/services/test_agent_app_sandbox_service.py @@ -36,6 +36,7 @@ from services.agent_app_sandbox_service import ( AgentSandboxInspectorError, WorkflowAgentSandboxService, ) +from tests.unit_tests.config_override import apply_config_overrides def _add_normal_conversation(session: Session, *, binding_id: str) -> Conversation: @@ -687,7 +688,7 @@ def test_workflow_download_resolves_only_exact_active_owner_chain( "FileRequestService", lambda: SimpleNamespace(request_download=request_download), ) - monkeypatch.setattr(sandbox_module.dify_config, "FILES_URL", "https://files.example") + apply_config_overrides(monkeypatch, FILES_URL="https://files.example") service = WorkflowAgentSandboxService(client_factory=lambda: nullcontext(cast(Client, client))) result = service.download_file( @@ -847,7 +848,7 @@ def test_workflow_download_uses_authenticated_account_and_trusted_file_request( "services.agent_app_sandbox_service.FileRequestService", lambda: SimpleNamespace(request_download=request_download), ) - monkeypatch.setattr("services.agent_app_sandbox_service.dify_config.FILES_URL", "https://files.example") + apply_config_overrides(monkeypatch, FILES_URL="https://files.example") result = WorkflowAgentSandboxService(client_factory=lambda: nullcontext(client)).download_file( tenant_id="tenant-1", @@ -904,7 +905,7 @@ def test_agent_app_download_uses_complete_account_context_after_session_exit( "FileRequestService", lambda: SimpleNamespace(request_download=request_download), ) - monkeypatch.setattr(sandbox_module.dify_config, "FILES_URL", "https://files.example") + apply_config_overrides(monkeypatch, FILES_URL="https://files.example") result = AgentAppSandboxService(client_factory=lambda: nullcontext(client)).download_file( tenant_id="tenant-1", diff --git a/api/tests/unit_tests/services/test_agent_config_service.py b/api/tests/unit_tests/services/test_agent_config_service.py index bc2a1f84b0b..f69def8bd7c 100644 --- a/api/tests/unit_tests/services/test_agent_config_service.py +++ b/api/tests/unit_tests/services/test_agent_config_service.py @@ -4,6 +4,7 @@ from __future__ import annotations import io import zipfile +from collections.abc import Callable from datetime import datetime from types import SimpleNamespace from unittest.mock import patch @@ -1162,7 +1163,10 @@ def test_resolve_skill_file_member_path_requires_existing_member() -> None: assert exc_info.value.status_code == 404 -def test_download_url_helpers_bind_shared_download_request_to_console_origin() -> None: +def test_download_url_helpers_bind_shared_download_request_to_console_origin( + config_overrides: Callable[..., None], +) -> None: + config_overrides(FILES_URL="https://example.com") service = AgentConfigService() with ( @@ -1174,7 +1178,6 @@ def test_download_url_helpers_bind_shared_download_request_to_console_origin() - ConfigDownloadRequest("guide.txt", "text/plain", 20, "/files/guide.txt?sign=2"), ], ), - patch(f"{MODULE}.dify_config.FILES_URL", "https://example.com"), ): assert ( service.download_skill_url( diff --git a/api/tests/unit_tests/services/test_app_dsl_service.py b/api/tests/unit_tests/services/test_app_dsl_service.py index 9b76c0938c4..178041d2e48 100644 --- a/api/tests/unit_tests/services/test_app_dsl_service.py +++ b/api/tests/unit_tests/services/test_app_dsl_service.py @@ -1,3 +1,5 @@ +import json +from collections.abc import Callable from types import SimpleNamespace from typing import cast from unittest.mock import Mock @@ -9,13 +11,14 @@ from sqlalchemy.orm import Session, sessionmaker from core.rbac import RBACPermission, RBACResourceScope from core.workflow.llm_environment_variable import LLMEnvironmentVariable -from models import App, AppMode +from models import Account, App, AppMode, Tenant from models.model import AppModelConfig, AppModelConfigDict, IconType -from models.workflow import Workflow +from models.workflow import Workflow, WorkflowType from services.app_dsl_service import AppDslService, PendingData from services.entities.dsl_entities import ImportStatus from services.errors.account import NoPermissionError from services.errors.app import WorkflowNotFoundError +from tests.unit_tests.config_override import apply_config_overrides _OVERWRITE_APP_ID = "11111111-1111-4111-8111-111111111111" _TENANT_ID = "22222222-2222-4222-8222-222222222222" @@ -52,9 +55,59 @@ def _persist_overwrite_target(session: Session, *, maintainer: str = _OTHER_ACCO return app +def _account(*, account_id: str = "account-1", tenant_id: str = "tenant-1") -> Account: + account = Account(name="DSL author", email=f"{account_id}@example.com") + account.id = account_id + tenant = Tenant(name="DSL workspace") + tenant.id = tenant_id + account._current_tenant = tenant + return account + + +def _app( + *, + app_id: str = "11111111-1111-1111-1111-111111111111", + tenant_id: str = "33333333-3333-3333-3333-333333333333", + mode: AppMode = AppMode.CHAT, + app_model_config_id: str | None = None, +) -> App: + return App( + id=app_id, + tenant_id=tenant_id, + app_model_config_id=app_model_config_id, + name="Existing app", + description="", + mode=mode, + icon_type=IconType.EMOJI, + icon="robot", + icon_background="#FFFFFF", + enable_site=True, + enable_api=True, + max_active_requests=0, + use_icon_as_answer_icon=False, + ) + + +def _workflow( + *, graph: dict[str, object], environment_variables: list[LLMEnvironmentVariable] | None = None +) -> Workflow: + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + type=WorkflowType.WORKFLOW, + version="draft", + graph=json.dumps(graph), + _features="{}", + created_by="account-1", + ) + workflow.environment_variables = environment_variables or [] + return workflow + + def test_extract_workflow_dependencies_uses_llm_environment_variable_provider(monkeypatch: pytest.MonkeyPatch) -> None: - workflow = SimpleNamespace( - graph_dict={ + workflow = _workflow( + graph={ "nodes": [ { "id": "llm-node", @@ -83,7 +136,7 @@ def test_extract_workflow_dependencies_uses_llm_environment_variable_provider(mo analyze_dependency, ) - result = AppDslService._extract_dependencies_from_workflow(cast(Workflow, workflow)) + result = AppDslService._extract_dependencies_from_workflow(workflow) assert result == ["new-provider"] analyze_dependency.assert_called_once_with("new-provider") @@ -93,8 +146,8 @@ def test_extract_workflow_dependencies_uses_llm_environment_variable_provider(mo def test_extract_workflow_dependencies_tolerates_unresolved_llm_environment_reference( monkeypatch: pytest.MonkeyPatch, model_selector: list[str] ) -> None: - workflow = SimpleNamespace( - graph_dict={ + workflow = _workflow( + graph={ "nodes": [ { "id": "llm-node", @@ -110,7 +163,6 @@ def test_extract_workflow_dependencies_tolerates_unresolved_llm_environment_refe } ] }, - environment_variables=[], ) analyze_dependency = Mock(side_effect=lambda provider: provider) monkeypatch.setattr( @@ -118,7 +170,7 @@ def test_extract_workflow_dependencies_tolerates_unresolved_llm_environment_refe analyze_dependency, ) - result = AppDslService._extract_dependencies_from_workflow(cast(Workflow, workflow)) + result = AppDslService._extract_dependencies_from_workflow(workflow) assert result == ["old-provider"] analyze_dependency.assert_called_once_with("old-provider") @@ -129,7 +181,7 @@ def test_import_app_rejects_oversized_yaml_content_before_parsing( ) -> None: monkeypatch.setattr("services.app_dsl_service.DSL_MAX_SIZE", 3) service = AppDslService(session=unbound_session) - account = Mock(current_tenant_id="tenant-1") + account = _account() result = service.import_app(account=account, import_mode="yaml-content", yaml_content="你你") @@ -149,7 +201,7 @@ def test_import_app_rejects_oversized_yaml_url_bytes_before_decode( service = AppDslService(session=unbound_session) result = service.import_app( - account=Mock(current_tenant_id="tenant-1"), + account=_account(), import_mode="yaml-url", yaml_url="https://example.com/app.yaml", ) @@ -169,7 +221,7 @@ def test_import_app_returns_decode_error_for_invalid_yaml_url_bytes( service = AppDslService(session=unbound_session) result = service.import_app( - account=Mock(current_tenant_id="tenant-1"), + account=_account(), import_mode="yaml-url", yaml_url="https://example.com/app.yaml", ) @@ -191,7 +243,7 @@ def test_import_app_checks_overwrite_rbac_before_database_access( check = Mock(side_effect=deny_before_transaction) setex = Mock() - monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", check) monkeypatch.setattr("services.app_dsl_service.redis_client.setex", setex) @@ -229,7 +281,7 @@ def test_confirm_import_rechecks_overwrite_rbac_before_database_access( return False check = Mock(side_effect=deny_before_transaction) - monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", check) with pytest.raises(NoPermissionError, match="permission to overwrite"): @@ -253,7 +305,7 @@ def test_confirm_import_does_not_create_when_overwrite_target_disappeared( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: monkeypatch.setattr("services.app_dsl_service.redis_client.get", Mock(return_value=_PENDING_DATA_JSON)) - monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", Mock(return_value=True)) redis_delete = Mock() monkeypatch.setattr("services.app_dsl_service.redis_client.delete", redis_delete) @@ -279,7 +331,7 @@ def test_pending_import_is_scoped_to_its_owner(monkeypatch: pytest.MonkeyPatch, lambda key, _expiry, value: pending_imports.__setitem__(key, value), ) service = AppDslService(session=unbound_session) - creator = Mock(id="account-1", current_tenant_id="tenant-1") + creator = _account() pending = service.import_app( account=creator, @@ -299,12 +351,12 @@ def test_pending_import_is_scoped_to_its_owner(monkeypatch: pytest.MonkeyPatch, monkeypatch.setattr( service, "_create_or_update_app", - Mock(return_value=Mock(id="app-1", mode=AppMode.WORKFLOW)), + Mock(return_value=_app(app_id="app-1", mode=AppMode.WORKFLOW)), ) for other_account in ( - Mock(id="account-1", current_tenant_id="tenant-2"), - Mock(id="account-2", current_tenant_id="tenant-1"), + _account(tenant_id="tenant-2"), + _account(account_id="account-2"), ): assert service.confirm_import(import_id=pending.id, account=other_account).status == ImportStatus.FAILED @@ -356,25 +408,13 @@ def test_create_or_update_app_loads_existing_model_config_with_service_session( arrange_session.add(app_model_config) arrange_session.commit() app_model_config_id = app_model_config.id - app = cast( - App, - SimpleNamespace( - id="11111111-1111-1111-1111-111111111111", - tenant_id="33333333-3333-3333-3333-333333333333", - app_model_config_id=app_model_config_id, - name="Existing app", - description="", - icon_type=IconType.EMOJI, - icon="robot", - icon_background="#FFFFFF", - ), - ) + app = _app(app_model_config_id=app_model_config_id) with sqlite_session_factory() as service_session: result = AppDslService(session=service_session)._create_or_update_app( app=app, data={"app": {"mode": AppMode.CHAT}, "model_config": {"model": {}}}, - account=Mock(id="account-1"), + account=_account(), ) assert result is app @@ -398,25 +438,13 @@ def test_create_or_update_app_flushes_new_model_config_before_signal( signal = Mock() signal.send.side_effect = record_signal monkeypatch.setattr("services.app_dsl_service.app_model_config_was_updated", signal) - app = cast( - App, - SimpleNamespace( - id="11111111-1111-1111-1111-111111111111", - tenant_id="33333333-3333-3333-3333-333333333333", - app_model_config_id=None, - name="Existing app", - description="", - icon_type=IconType.EMOJI, - icon="robot", - icon_background="#FFFFFF", - ), - ) + app = _app() try: AppDslService(session=sqlite_session)._create_or_update_app( app=app, data={"app": {"mode": AppMode.CHAT}, "model_config": {"model": {}}}, - account=Mock(id="22222222-2222-2222-2222-222222222222"), + account=_account(account_id="22222222-2222-2222-2222-222222222222"), ) finally: event.remove(sqlite_session, "after_flush", record_flush) @@ -503,21 +531,7 @@ def test_export_dsl_loads_model_config_and_annotation_reply_with_request_session "services.app_dsl_service.DependenciesAnalysisService.generate_dependencies", Mock(return_value=[]), ) - app = cast( - App, - SimpleNamespace( - id="11111111-1111-1111-1111-111111111111", - tenant_id="33333333-3333-3333-3333-333333333333", - app_model_config_id=app_model_config_id, - mode=AppMode.CHAT, - name="Chat app", - icon_type=IconType.EMOJI, - icon="robot", - icon_background="#FFFFFF", - description="", - use_icon_as_answer_icon=False, - ), - ) + app = _app(app_model_config_id=app_model_config_id) with sqlite_session_factory() as service_session: exported = AppDslService.export_dsl(app, session=service_session) @@ -528,38 +542,46 @@ def test_export_dsl_loads_model_config_and_annotation_reply_with_request_session load_annotation_reply_config.assert_called_once_with(service_session, app_id) -def test_ensure_agent_manage_permission_noops_when_rbac_disabled(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", False) +def test_ensure_agent_manage_permission_noops_when_rbac_disabled( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides(RBAC_ENABLED=False) check = Mock() monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", check) - AppDslService._ensure_agent_manage_permission(Mock(id="account-1", current_tenant_id="tenant-1")) + AppDslService._ensure_agent_manage_permission(_account()) check.assert_not_called() -def test_ensure_agent_manage_permission_allows_agent_manager(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True) +def test_ensure_agent_manage_permission_allows_agent_manager( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides(RBAC_ENABLED=True) check = Mock(return_value=True) monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", check) - AppDslService._ensure_agent_manage_permission(Mock(id="account-1", current_tenant_id="tenant-1")) + AppDslService._ensure_agent_manage_permission(_account()) check.assert_called_once_with("tenant-1", "account-1", scene=RBACPermission.AGENT_MANAGE) -def test_ensure_agent_manage_permission_rejects_without_agent_manage(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True) +def test_ensure_agent_manage_permission_rejects_without_agent_manage( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides(RBAC_ENABLED=True) monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", Mock(return_value=False)) with pytest.raises(NoPermissionError): - AppDslService._ensure_agent_manage_permission(Mock(id="account-1", current_tenant_id="tenant-1")) + AppDslService._ensure_agent_manage_permission(_account()) def test_create_or_update_app_gates_agent_mode_before_creation( - monkeypatch: pytest.MonkeyPatch, unbound_session: Session + monkeypatch: pytest.MonkeyPatch, + unbound_session: Session, + config_overrides: Callable[..., None], ) -> None: - monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True) + config_overrides(RBAC_ENABLED=True) monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", Mock(return_value=False)) service = AppDslService(session=unbound_session) @@ -567,22 +589,24 @@ def test_create_or_update_app_gates_agent_mode_before_creation( service._create_or_update_app( app=None, data={"app": {"mode": "agent", "name": "Gated agent"}}, - account=Mock(id="account-1", current_tenant_id="tenant-1"), + account=_account(), ) assert not unbound_session.in_transaction() def test_import_app_reraises_permission_denial_instead_of_failed_result( - monkeypatch: pytest.MonkeyPatch, unbound_session: Session + monkeypatch: pytest.MonkeyPatch, + unbound_session: Session, + config_overrides: Callable[..., None], ) -> None: - monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True) + config_overrides(RBAC_ENABLED=True) monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", Mock(return_value=False)) service = AppDslService(session=unbound_session) with pytest.raises(NoPermissionError): service.import_app( - account=Mock(id="account-1", current_tenant_id="tenant-1"), + account=_account(), import_mode="yaml-content", yaml_content="app:\n mode: agent\n name: Denied agent\n", ) @@ -590,18 +614,20 @@ def test_import_app_reraises_permission_denial_instead_of_failed_result( assert not unbound_session.in_transaction() -def test_append_workflow_export_data_reports_missing_selected_workflow(monkeypatch: pytest.MonkeyPatch) -> None: +def test_append_workflow_export_data_reports_missing_selected_workflow( + monkeypatch: pytest.MonkeyPatch, unbound_session: Session +) -> None: workflow_id = "11111111-1111-4111-8111-111111111111" workflow_service = Mock() workflow_service.get_draft_workflow.return_value = None monkeypatch.setattr("services.app_dsl_service.WorkflowService", Mock(return_value=workflow_service)) - app = cast(App, SimpleNamespace(id="app-1", tenant_id="tenant-1")) + app = _app(app_id="app-1", tenant_id="tenant-1") with pytest.raises(WorkflowNotFoundError, match=f"Workflow version not found. Workflow ID: {workflow_id}"): AppDslService._append_workflow_export_data( export_data={}, app_model=app, include_secret=False, - session=Mock(), + session=unbound_session, workflow_id=workflow_id, ) diff --git a/api/tests/unit_tests/services/test_app_generate_service.py b/api/tests/unit_tests/services/test_app_generate_service.py index b0bf1a2fd4e..646a30d5cfb 100644 --- a/api/tests/unit_tests/services/test_app_generate_service.py +++ b/api/tests/unit_tests/services/test_app_generate_service.py @@ -21,13 +21,18 @@ from unittest.mock import MagicMock import pytest from pytest_mock import MockerFixture +from sqlalchemy.orm import Session import services.app_generate_service as ags_module from core.app.entities.app_invoke_entities import InvokeFrom from enums import DeploymentEdition, QuotaType from models.model import AppMode from services.app_generate_service import AppGenerateService -from services.errors.app import WorkflowIdFormatError, WorkflowNotFoundError +from services.errors.app import ( + TriggerWorkflowServiceModeUnavailableError, + WorkflowIdFormatError, + WorkflowNotFoundError, +) # --------------------------------------------------------------------------- @@ -79,10 +84,24 @@ def _make_user() -> MagicMock: return user -def _make_workflow(*, workflow_id: str = "workflow-id", created_by: str = "owner-id") -> MagicMock: +class _RealSessionTest: + @pytest.fixture(autouse=True) + def _bind_unbound_session(self, unbound_session: Session) -> None: + self.session = unbound_session + + +def _make_workflow( + *, + workflow_id: str = "workflow-id", + created_by: str = "owner-id", + node_types: tuple[str, ...] = (), +) -> MagicMock: workflow = MagicMock() workflow.id = workflow_id workflow.created_by = created_by + workflow.walk_nodes.return_value = [ + (f"node-{index}", {"type": node_type}) for index, node_type in enumerate(node_types) + ] return workflow @@ -251,7 +270,7 @@ class TestGetMaxActiveRequests: # --------------------------------------------------------------------------- # generate – every AppMode branch # --------------------------------------------------------------------------- -class TestGenerate: +class TestGenerate(_RealSessionTest): """Tests for AppGenerateService.generate covering each mode.""" @pytest.fixture(autouse=True) @@ -280,7 +299,7 @@ class TestGenerate: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=False, - session=MagicMock(), + session=self.session, ) assert result == {"result": "ok"} gen_spy.assert_called_once() @@ -301,7 +320,7 @@ class TestGenerate: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=False, - session=MagicMock(), + session=self.session, ) assert result == {"result": "agent"} gen_spy.assert_called_once() @@ -317,7 +336,7 @@ class TestGenerate: side_effect=lambda x: x, ) app = _make_app(AppMode.CHAT, is_agent=True) - session = MagicMock() + session = self.session result = AppGenerateService.generate( app_model=app, user=_make_user(), @@ -340,7 +359,7 @@ class TestGenerate: "services.app_generate_service.AgentAppGenerator.convert_to_event_stream", side_effect=lambda x: x, ) - session = MagicMock() + session = self.session result = AppGenerateService.generate( app_model=_make_app(AppMode.AGENT), @@ -371,7 +390,7 @@ class TestGenerate: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=False, - session=MagicMock(), + session=self.session, ) assert result == {"result": "chat"} gen_spy.assert_called_once() @@ -391,7 +410,7 @@ class TestGenerate: side_effect=lambda x: x, ) - session = MagicMock() + session = self.session result = AppGenerateService.generate( app_model=_make_app(AppMode.ADVANCED_CHAT), user=_make_user(), @@ -430,7 +449,7 @@ class TestGenerate: args={"workflow_id": None, "query": "hi", "inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=True, - session=MagicMock(), + session=self.session, ) # In streaming mode it should go through retrieve_events, not generate gen_instance.retrieve_events.assert_called_once() @@ -453,7 +472,7 @@ class TestGenerate: side_effect=lambda x: x, ) - session = MagicMock() + session = self.session result = AppGenerateService.generate( app_model=_make_app(AppMode.WORKFLOW), user=_make_user(), @@ -467,6 +486,84 @@ class TestGenerate: assert call_kwargs.get("pause_state_config") is not None assert call_kwargs["pause_state_config"].state_owner_user_id == "owner-id" + @pytest.mark.parametrize( + "invoke_from", + [InvokeFrom.OPENAPI, InvokeFrom.SERVICE_API, InvokeFrom.WEB_APP], + ) + @pytest.mark.parametrize("node_type", ["trigger-plugin", "trigger-schedule", "trigger-webhook"]) + def test_trigger_workflow_rejects_manual_service_surfaces( + self, + invoke_from: InvokeFrom, + node_type: str, + mocker: MockerFixture, + ) -> None: + workflow = _make_workflow(node_types=(node_type,)) + mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) + generate = mocker.patch("services.app_generate_service.WorkflowAppGenerator.generate") + + with pytest.raises(TriggerWorkflowServiceModeUnavailableError): + AppGenerateService.generate( + app_model=_make_app(AppMode.WORKFLOW), + user=_make_user(), + args={"inputs": {}}, + invoke_from=invoke_from, + streaming=False, + session=MagicMock(), + ) + + generate.assert_not_called() + + def test_trigger_workflow_allows_trigger_execution(self, mocker: MockerFixture) -> None: + workflow = _make_workflow(node_types=("trigger-webhook",)) + mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) + generate = mocker.patch( + "services.app_generate_service.WorkflowAppGenerator.generate", + return_value={"result": "trigger"}, + ) + mocker.patch( + "services.app_generate_service.WorkflowAppGenerator.convert_to_event_stream", + side_effect=lambda value: value, + ) + + result = AppGenerateService.generate( + app_model=_make_app(AppMode.WORKFLOW), + user=_make_user(), + args={"inputs": {}}, + invoke_from=InvokeFrom.TRIGGER, + streaming=False, + session=MagicMock(), + ) + + assert result == {"result": "trigger"} + generate.assert_called_once() + + def test_specific_start_workflow_version_remains_runnable(self, mocker: MockerFixture) -> None: + workflow_id = str(uuid.uuid4()) + workflow = _make_workflow(workflow_id=workflow_id, node_types=("start",)) + get_workflow = mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) + mocker.patch( + "services.app_generate_service.WorkflowAppGenerator.generate", + return_value={"result": "version"}, + ) + mocker.patch( + "services.app_generate_service.WorkflowAppGenerator.convert_to_event_stream", + side_effect=lambda value: value, + ) + app = _make_app(AppMode.WORKFLOW) + session = MagicMock() + + result = AppGenerateService.generate( + app_model=app, + user=_make_user(), + args={"inputs": {}, "workflow_id": workflow_id}, + invoke_from=InvokeFrom.SERVICE_API, + streaming=False, + session=session, + ) + + assert result == {"result": "version"} + get_workflow.assert_called_once_with(app, InvokeFrom.SERVICE_API, workflow_id, session=session) + # -- WORKFLOW streaming ------------------------------------------------- def test_workflow_streaming(self, mocker: MockerFixture, config_overrides: Callable[..., None]): config_overrides(PUBSUB_REDIS_CHANNEL_TYPE="streams") @@ -492,7 +589,7 @@ class TestGenerate: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=True, - session=MagicMock(), + session=self.session, ) retrieve_spy.assert_called_once() # Dispatch is gated on subscribe; simulate the SSE layer entering the @@ -511,14 +608,14 @@ class TestGenerate: args={}, invoke_from=InvokeFrom.SERVICE_API, streaming=False, - session=MagicMock(), + session=self.session, ) # --------------------------------------------------------------------------- # generate – billing / quota # --------------------------------------------------------------------------- -class TestGenerateBilling: +class TestGenerateBilling(_RealSessionTest): @pytest.fixture(autouse=True) def _common(self, mocker: MockerFixture): mocker.patch("services.app_generate_service.RateLimit", _DummyRateLimit) @@ -549,7 +646,7 @@ class TestGenerateBilling: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=False, - session=MagicMock(), + session=self.session, ) reserve_mock.assert_called_once_with(QuotaType.WORKFLOW, "tenant-id") quota_charge.commit.assert_called_once() @@ -573,7 +670,7 @@ class TestGenerateBilling: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=False, - session=MagicMock(), + session=self.session, ) def test_exception_refunds_quota_and_exits_rate_limit( @@ -601,7 +698,7 @@ class TestGenerateBilling: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=False, - session=MagicMock(), + session=self.session, ) quota_charge.refund.assert_called_once() @@ -633,7 +730,7 @@ class TestGenerateBilling: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=False, - session=MagicMock(), + session=self.session, ) # exit is called in finally block for non-streaming assert exit_calls == ["dummy-request-id"] @@ -664,7 +761,7 @@ class TestGenerateBilling: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=False, - session=MagicMock(), + session=self.session, ) quota_charge.refund.assert_called_once() @@ -698,7 +795,7 @@ class TestGenerateBilling: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=True, - session=MagicMock(), + session=self.session, ) quota_charge.refund.assert_called_once() @@ -708,14 +805,16 @@ class TestGenerateBilling: # --------------------------------------------------------------------------- # _get_workflow # --------------------------------------------------------------------------- -class TestGetWorkflow: +class TestGetWorkflow(_RealSessionTest): def test_debugger_fetches_draft(self, mocker: MockerFixture): draft_wf = _make_workflow() ws = MagicMock() ws.get_draft_workflow.return_value = draft_wf mocker.patch("services.app_generate_service.WorkflowService", return_value=ws) - result = AppGenerateService._get_workflow(_make_app(AppMode.WORKFLOW), InvokeFrom.DEBUGGER, session=MagicMock()) + result = AppGenerateService._get_workflow( + _make_app(AppMode.WORKFLOW), InvokeFrom.DEBUGGER, session=self.session + ) assert result is draft_wf ws.get_draft_workflow.assert_called_once() @@ -725,7 +824,7 @@ class TestGetWorkflow: mocker.patch("services.app_generate_service.WorkflowService", return_value=ws) with pytest.raises(ValueError, match="Workflow not initialized"): - AppGenerateService._get_workflow(_make_app(AppMode.WORKFLOW), InvokeFrom.DEBUGGER, session=MagicMock()) + AppGenerateService._get_workflow(_make_app(AppMode.WORKFLOW), InvokeFrom.DEBUGGER, session=self.session) def test_non_debugger_fetches_published(self, mocker: MockerFixture): pub_wf = _make_workflow() @@ -734,7 +833,7 @@ class TestGetWorkflow: mocker.patch("services.app_generate_service.WorkflowService", return_value=ws) result = AppGenerateService._get_workflow( - _make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API, session=MagicMock() + _make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API, session=self.session ) assert result is pub_wf ws.get_published_workflow.assert_called_once() @@ -745,7 +844,7 @@ class TestGetWorkflow: mocker.patch("services.app_generate_service.WorkflowService", return_value=ws) with pytest.raises(ValueError, match="Workflow not published"): - AppGenerateService._get_workflow(_make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API, session=MagicMock()) + AppGenerateService._get_workflow(_make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API, session=self.session) def test_specific_workflow_id_valid_uuid(self, mocker: MockerFixture): valid_uuid = str(uuid.uuid4()) @@ -758,7 +857,7 @@ class TestGetWorkflow: _make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API, workflow_id=valid_uuid, - session=MagicMock(), + session=self.session, ) assert result is specific_wf ws.get_published_workflow_by_id.assert_called_once() @@ -772,7 +871,7 @@ class TestGetWorkflow: _make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API, workflow_id="not-a-uuid", - session=MagicMock(), + session=self.session, ) def test_specific_workflow_id_not_found(self, mocker: MockerFixture): @@ -786,14 +885,14 @@ class TestGetWorkflow: _make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API, workflow_id=valid_uuid, - session=MagicMock(), + session=self.session, ) # --------------------------------------------------------------------------- # generate_single_iteration # --------------------------------------------------------------------------- -class TestGenerateSingleIteration: +class TestGenerateSingleIteration(_RealSessionTest): def test_advanced_chat_mode(self, mocker: MockerFixture): workflow = _make_workflow() mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) @@ -806,7 +905,7 @@ class TestGenerateSingleIteration: return_value={"event": "iteration"}, ) app = _make_app(AppMode.ADVANCED_CHAT) - session = MagicMock() + session = self.session result = AppGenerateService.generate_single_iteration( app_model=app, user=_make_user(), @@ -830,7 +929,7 @@ class TestGenerateSingleIteration: return_value={"event": "wf-iteration"}, ) app = _make_app(AppMode.WORKFLOW) - session = MagicMock() + session = self.session result = AppGenerateService.generate_single_iteration( app_model=app, user=_make_user(), @@ -846,14 +945,14 @@ class TestGenerateSingleIteration: app = _make_app(AppMode.CHAT) with pytest.raises(ValueError, match="Invalid app mode"): AppGenerateService.generate_single_iteration( - app_model=app, user=_make_user(), node_id="n1", args={}, session=MagicMock() + app_model=app, user=_make_user(), node_id="n1", args={}, session=self.session ) # --------------------------------------------------------------------------- # generate_single_loop # --------------------------------------------------------------------------- -class TestGenerateSingleLoop: +class TestGenerateSingleLoop(_RealSessionTest): def test_advanced_chat_mode(self, mocker: MockerFixture): workflow = _make_workflow() mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) @@ -866,7 +965,7 @@ class TestGenerateSingleLoop: return_value={"event": "loop"}, ) app = _make_app(AppMode.ADVANCED_CHAT) - session = MagicMock() + session = self.session result = AppGenerateService.generate_single_loop( app_model=app, user=_make_user(), @@ -890,7 +989,7 @@ class TestGenerateSingleLoop: return_value={"event": "wf-loop"}, ) app = _make_app(AppMode.WORKFLOW) - session = MagicMock() + session = self.session result = AppGenerateService.generate_single_loop( app_model=app, user=_make_user(), @@ -906,20 +1005,20 @@ class TestGenerateSingleLoop: app = _make_app(AppMode.COMPLETION) with pytest.raises(ValueError, match="Invalid app mode"): AppGenerateService.generate_single_loop( - app_model=app, user=_make_user(), node_id="n1", args=MagicMock(), session=MagicMock() + app_model=app, user=_make_user(), node_id="n1", args=MagicMock(), session=self.session ) # --------------------------------------------------------------------------- # generate_more_like_this # --------------------------------------------------------------------------- -class TestGenerateMoreLikeThis: +class TestGenerateMoreLikeThis(_RealSessionTest): def test_delegates_to_completion_generator(self, mocker: MockerFixture): gen_spy = mocker.patch( "services.app_generate_service.CompletionAppGenerator.generate_more_like_this", return_value={"result": "similar"}, ) - session = MagicMock() + session = self.session result = AppGenerateService.generate_more_like_this( app_model=_make_app(AppMode.COMPLETION), user=_make_user(), diff --git a/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py b/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py index fe667ef5771..1595a1db952 100644 --- a/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py +++ b/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py @@ -9,6 +9,7 @@ from core.app.apps.message_based_app_generator import MessageBasedAppGenerator from core.app.apps.message_generator import MessageGenerator from models.model import AppMode from services.app_generate_service import AppGenerateService +from tests.unit_tests.config_override import apply_config_overrides # ----------------------------- @@ -114,10 +115,7 @@ def _patch_get_channel_streams(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("extensions.ext_redis.get_pubsub_broadcast_channel", lambda: chan) monkeypatch.setattr("core.app.apps.message_generator.get_pubsub_broadcast_channel", lambda: chan) monkeypatch.setattr("core.app.apps.message_based_app_generator.get_pubsub_broadcast_channel", lambda: chan) - # Ensure AppGenerateService sees streams mode - import services.app_generate_service as ags - - monkeypatch.setattr(ags.dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "streams", raising=False) + apply_config_overrides(monkeypatch, PUBSUB_REDIS_CHANNEL_TYPE="streams") @pytest.fixture @@ -134,10 +132,7 @@ def _patch_get_channel_pubsub(monkeypatch: pytest.MonkeyPatch): # Patch both the source and the imported alias used by MessageGenerator monkeypatch.setattr("extensions.ext_redis.get_pubsub_broadcast_channel", lambda: chan) monkeypatch.setattr("core.app.apps.message_generator.get_pubsub_broadcast_channel", lambda: chan) - # Ensure AppGenerateService sees pubsub mode - import services.app_generate_service as ags - - monkeypatch.setattr(ags.dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "pubsub", raising=False) + apply_config_overrides(monkeypatch, PUBSUB_REDIS_CHANNEL_TYPE="pubsub") def _publish_events(app_mode: AppMode, run_id: str, events: list[dict]): diff --git a/api/tests/unit_tests/services/test_app_service.py b/api/tests/unit_tests/services/test_app_service.py index 223be9f678b..b9bf28be032 100644 --- a/api/tests/unit_tests/services/test_app_service.py +++ b/api/tests/unit_tests/services/test_app_service.py @@ -67,9 +67,18 @@ def _persist_app(session: Session, *, tenant_id: str, name: str = "Visible App") return app -def _persist_agent_app(session: Session, *, app_name: str = "Old", agent_name: str = "Old") -> tuple[App, Agent]: - tenant_id = str(uuid4()) - creator_id = str(uuid4()) +def _persist_agent_app( + session: Session, + *, + app_name: str = "Old", + agent_name: str = "Old", + tenant_id: str | None = None, + creator_id: str | None = None, + active_config_snapshot_id: str | None = None, + active_config_is_published: bool = False, +) -> tuple[App, Agent]: + tenant_id = tenant_id or str(uuid4()) + creator_id = creator_id or str(uuid4()) app = App( id=str(uuid4()), tenant_id=tenant_id, @@ -95,6 +104,8 @@ def _persist_agent_app(session: Session, *, app_name: str = "Old", agent_name: s icon="robot", icon_background="#fff", app_id=app.id, + active_config_snapshot_id=active_config_snapshot_id, + active_config_is_published=active_config_is_published, created_by=creator_id, ) session.add_all([app, agent]) @@ -103,7 +114,10 @@ def _persist_agent_app(session: Session, *, app_name: str = "Old", agent_name: s class TestCreateAppTransactionBoundary: - def test_commits_database_state_before_external_side_effects(self, sqlite_session: Session) -> None: + def test_commits_database_state_before_external_side_effects( + self, sqlite_session: Session, config_overrides: Callable[..., None] + ) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) account = _persist_account(sqlite_session) phase_events: list[str] = [] event.listen(sqlite_session, "after_commit", lambda _session: phase_events.append("commit")) @@ -121,7 +135,6 @@ class TestCreateAppTransactionBoundary: "services.app_service.FeatureService.get_system_features", return_value=SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), ), - patch("services.app_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), ): app = AppService().create_app( account.current_tenant_id, @@ -163,7 +176,10 @@ class TestCreateAppTransactionBoundary: assert sqlite_session.scalars(select(AppModelConfig)).all() == [] assert sqlite_session.get(Agent, existing_agent.id) is existing_agent - def test_falls_back_when_default_model_schema_is_unavailable(self, sqlite_session: Session) -> None: + def test_falls_back_when_default_model_schema_is_unavailable( + self, sqlite_session: Session, config_overrides: Callable[..., None] + ) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) account = _persist_account(sqlite_session) model_type_instance = MagicMock() model_type_instance.get_model_schema.side_effect = ValueError("Base model unknown-model not found") @@ -184,7 +200,6 @@ class TestCreateAppTransactionBoundary: "services.app_service.FeatureService.get_system_features", return_value=SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), ), - patch("services.app_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), ): app = AppService().create_app( account.current_tenant_id, @@ -493,6 +508,78 @@ class TestAgentAppType: params = CreateAppParams(name="Iris", mode="agent") assert params.mode == "agent" + def test_list_filter_and_counts_use_server_owned_publication_state(self, sqlite_session: Session): + tenant_id = str(uuid4()) + creator_id = str(uuid4()) + published_app, _ = _persist_agent_app( + sqlite_session, + app_name="Published", + agent_name="Published Agent", + tenant_id=tenant_id, + creator_id=creator_id, + active_config_snapshot_id=str(uuid4()), + active_config_is_published=True, + ) + draft_app, _ = _persist_agent_app( + sqlite_session, + app_name="Draft", + agent_name="Draft Agent", + tenant_id=tenant_id, + creator_id=creator_id, + active_config_snapshot_id=str(uuid4()), + ) + unpublished_app, _ = _persist_agent_app( + sqlite_session, + app_name="Unpublished", + agent_name="Unpublished Agent", + tenant_id=tenant_id, + creator_id=creator_id, + ) + _persist_agent_app( + sqlite_session, + app_name="Other tenant", + agent_name="Other Agent", + active_config_snapshot_id=str(uuid4()), + active_config_is_published=True, + ) + + service = AppService() + published_page = service.get_paginate_apps( + creator_id, + tenant_id, + AppListParams(mode="agent", status="normal", agent_is_published=True), + sqlite_session, + ) + draft_page = service.get_paginate_apps( + creator_id, + tenant_id, + AppListParams(mode="agent", status="normal", agent_is_published=False), + sqlite_session, + ) + counts = service.get_agent_publication_counts( + creator_id, + tenant_id, + AppListParams(mode="agent", status="normal", agent_is_published=True), + sqlite_session, + ) + searched_counts = service.get_agent_publication_counts( + creator_id, + tenant_id, + AppListParams(mode="agent", status="normal", name="Draft", agent_is_published=True), + sqlite_session, + ) + + assert published_page is not None + assert {app.id for app in published_page.items} == {published_app.id} + assert published_page.total == 1 + assert draft_page is not None + assert {app.id for app in draft_page.items} == {draft_app.id, unpublished_app.id} + assert draft_page.total == 2 + assert counts.published == 1 + assert counts.drafts == 2 + assert searched_counts.published == 0 + assert searched_counts.drafts == 1 + def test_bound_agent_id_is_none_for_non_agent_app(self): """Non-agent apps short-circuit without touching the DB.""" from models.model import App, AppMode diff --git a/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py b/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py index c50696a5f98..e143fd6bcad 100644 --- a/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py +++ b/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py @@ -1,4 +1,5 @@ import datetime +from collections.abc import Callable from typing import Any import pytest @@ -10,6 +11,14 @@ from services.retention.workflow_run import clear_free_plan_expired_workflow_run from services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs import WorkflowRunCleanup +@pytest.fixture(autouse=True) +def _cleanup_config(config_overrides: Callable[..., None]) -> None: + config_overrides( + DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, + SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=0, + ) + + def make_ref(run_id: str, tenant_id: str, created_at: datetime.datetime) -> WorkflowRunCleanupRef: return WorkflowRunCleanupRef(id=run_id, tenant_id=tenant_id, created_at=created_at) @@ -110,15 +119,9 @@ def create_cleanup( monkeypatch: pytest.MonkeyPatch, repo: FakeRepo, *, - grace_period_days: int = 0, whitelist: set[str] | None = None, **kwargs: Any, ) -> WorkflowRunCleanup: - monkeypatch.setattr( - cleanup_module.dify_config, - "SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD", - grace_period_days, - ) monkeypatch.setattr( cleanup_module.WorkflowRunCleanup, "_get_cleanup_whitelist", @@ -130,8 +133,6 @@ def create_cleanup( def test_filter_free_tenants_outside_cloud_edition(monkeypatch: pytest.MonkeyPatch) -> None: cleanup = create_cleanup(monkeypatch, repo=FakeRepo([]), days=30, batch_size=10) - monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) - def fail_bulk(_: list[str]) -> dict[str, SubscriptionPlan]: raise RuntimeError("should not call") @@ -143,10 +144,10 @@ def test_filter_free_tenants_outside_cloud_edition(monkeypatch: pytest.MonkeyPat assert free == tenants -def test_filter_free_tenants_bulk_mixed(monkeypatch: pytest.MonkeyPatch) -> None: +def test_filter_free_tenants_bulk_mixed(monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) cleanup = create_cleanup(monkeypatch, repo=FakeRepo([]), days=30, batch_size=10) - monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr( cleanup_module.BillingService, "get_plan_bulk_with_cache", @@ -163,10 +164,15 @@ def test_filter_free_tenants_bulk_mixed(monkeypatch: pytest.MonkeyPatch) -> None assert free == {"t_free", "t_missing"} -def test_filter_free_tenants_respects_grace_period(monkeypatch: pytest.MonkeyPatch) -> None: - cleanup = create_cleanup(monkeypatch, repo=FakeRepo([]), days=30, batch_size=10, grace_period_days=45) +def test_filter_free_tenants_respects_grace_period( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides( + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=45, + ) + cleanup = create_cleanup(monkeypatch, repo=FakeRepo([]), days=30, batch_size=10) - monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) now = datetime.datetime.now(datetime.UTC) within_grace_ts = int((now - datetime.timedelta(days=10)).timestamp()) outside_grace_ts = int((now - datetime.timedelta(days=90)).timestamp()) @@ -184,7 +190,10 @@ def test_filter_free_tenants_respects_grace_period(monkeypatch: pytest.MonkeyPat assert free == {"long_sandbox"} -def test_filter_free_tenants_skips_cleanup_whitelist(monkeypatch: pytest.MonkeyPatch) -> None: +def test_filter_free_tenants_skips_cleanup_whitelist( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) cleanup = create_cleanup( monkeypatch, repo=FakeRepo([]), @@ -193,7 +202,6 @@ def test_filter_free_tenants_skips_cleanup_whitelist(monkeypatch: pytest.MonkeyP whitelist={"tenant_whitelist"}, ) - monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr( cleanup_module.BillingService, "get_plan_bulk_with_cache", @@ -211,10 +219,12 @@ def test_filter_free_tenants_skips_cleanup_whitelist(monkeypatch: pytest.MonkeyP assert free == {"tenant_regular"} -def test_filter_free_tenants_bulk_failure(monkeypatch: pytest.MonkeyPatch) -> None: +def test_filter_free_tenants_bulk_failure( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) cleanup = create_cleanup(monkeypatch, repo=FakeRepo([]), days=30, batch_size=10) - monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr( cleanup_module.BillingService, "get_plan_bulk_with_cache", @@ -226,7 +236,8 @@ def test_filter_free_tenants_bulk_failure(monkeypatch: pytest.MonkeyPatch) -> No assert free == set() -def test_run_deletes_only_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None: +def test_run_deletes_only_free_tenants(monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) cutoff = datetime.datetime.now() repo = FakeRepo( batches=[ @@ -238,7 +249,6 @@ def test_run_deletes_only_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None: ) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) - monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr( cleanup_module.BillingService, "get_plan_bulk_with_cache", @@ -256,7 +266,10 @@ def test_run_deletes_only_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None: assert repo.cleanup_ref_calls[1]["tenant_ids"] == ["t_free"] -def test_run_filters_candidate_tenants_before_target_query(monkeypatch: pytest.MonkeyPatch) -> None: +def test_run_filters_candidate_tenants_before_target_query( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) cutoff = datetime.datetime.now() repo = FakeRepo( batches=[ @@ -268,7 +281,6 @@ def test_run_filters_candidate_tenants_before_target_query(monkeypatch: pytest.M ) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) - monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) billing_calls: list[list[str]] = [] def fake_bulk(tenant_ids: list[str]) -> dict[str, SubscriptionPlan]: @@ -287,12 +299,12 @@ def test_run_filters_candidate_tenants_before_target_query(monkeypatch: pytest.M assert repo.deleted == [["run-free"]] -def test_run_skips_when_no_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None: +def test_run_skips_when_no_free_tenants(monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) cutoff = datetime.datetime.now() repo = FakeRepo(batches=[[make_ref("run-paid", "t_paid", cutoff)]]) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) - monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr( cleanup_module.BillingService, "get_plan_bulk_with_cache", @@ -305,12 +317,14 @@ def test_run_skips_when_no_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None assert len(repo.cleanup_ref_calls) == 2 -def test_run_paid_only_records_skipped_metrics(monkeypatch: pytest.MonkeyPatch) -> None: +def test_run_paid_only_records_skipped_metrics( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) cutoff = datetime.datetime.now() repo = FakeRepo(batches=[[make_ref("run-paid", "t_paid", cutoff)]]) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) - monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr( cleanup_module.BillingService, "get_plan_bulk_with_cache", @@ -342,8 +356,6 @@ def test_run_target_query_is_bounded_by_candidate_high_water(monkeypatch: pytest ) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=2) - monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) - cleanup.run() assert repo.cleanup_ref_calls[1]["last_seen"] is None @@ -372,7 +384,6 @@ def test_run_records_metrics_on_success(monkeypatch: pytest.MonkeyPatch) -> None }, ) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) - monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) batch_calls: list[dict[str, object]] = [] completion_calls: list[dict[str, object]] = [] @@ -400,7 +411,6 @@ def test_run_records_failed_metrics(monkeypatch: pytest.MonkeyPatch) -> None: cutoff = datetime.datetime.now() repo = FailingRepo(batches=[[make_ref("run-free", "t_free", cutoff)]]) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) - monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) completion_calls: list[dict[str, object]] = [] monkeypatch.setattr(cleanup._metrics, "record_completion", lambda **kwargs: completion_calls.append(kwargs)) @@ -428,8 +438,6 @@ def test_run_dry_run_skips_deletions(monkeypatch: pytest.MonkeyPatch, capsys: py ) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10, dry_run=True) - monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) - cleanup.run() assert repo.deleted == [] diff --git a/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py b/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py index 2086d256b72..2b4ad04fe07 100644 --- a/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py +++ b/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py @@ -47,6 +47,12 @@ from models.workflow import ( from services import clear_free_plan_tenant_expired_logs as service_module from services.clear_free_plan_tenant_expired_logs import ClearFreePlanTenantExpiredLogs + +@pytest.fixture(autouse=True) +def _community_edition(config_overrides: Callable[..., None]) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) + + REAL_DATETIME = datetime.datetime SQLITE_MODELS = ( Tenant, @@ -527,14 +533,15 @@ def test_process_with_tenant_ids_filters_by_plan_and_logs_errors( caplog: pytest.LogCaptureFixture, sqlite_session: Session, sqlite_engine: Engine, + config_overrides: Callable[..., None], ) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) sqlite_session.add_all( [_create_tenant("tenant-sandbox"), _create_tenant("tenant-paid"), _create_tenant("tenant-fail")] ) sqlite_session.commit() _configure_process_boundaries(monkeypatch, sqlite_engine) monkeypatch.setattr(service_module.click, "echo", MagicMock()) - monkeypatch.setattr(service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) def fake_get_info(tenant_id: str) -> dict[str, dict[str, str]]: if tenant_id == "tenant-sandbox": @@ -587,7 +594,6 @@ def test_process_without_tenant_ids_batches_and_scales_interval( monkeypatch.setattr(service_module.datetime, "datetime", FixedDateTime) _configure_process_boundaries(monkeypatch, sqlite_engine) monkeypatch.setattr(service_module.click, "echo", lambda *_args, **_kwargs: None) - monkeypatch.setattr(service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) process_tenant = MagicMock() monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", process_tenant) statements: list[str] = [] @@ -627,7 +633,6 @@ def test_process_with_tenant_ids_emits_progress_every_100( sqlite_session.add_all([_create_tenant(tenant_id) for tenant_id in tenant_ids]) sqlite_session.commit() _configure_process_boundaries(monkeypatch, sqlite_engine) - monkeypatch.setattr(service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) echo = MagicMock() monkeypatch.setattr(service_module.click, "echo", echo) monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", MagicMock()) @@ -661,7 +666,6 @@ def test_process_without_tenant_ids_all_intervals_too_many_uses_min_interval( monkeypatch.setattr(service_module.datetime, "datetime", FixedDateTime) _configure_process_boundaries(monkeypatch, sqlite_engine) monkeypatch.setattr(service_module.click, "echo", lambda *_args, **_kwargs: None) - monkeypatch.setattr(service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) process_tenant = MagicMock() monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", process_tenant) statements: list[str] = [] diff --git a/api/tests/unit_tests/services/test_credit_pool_service.py b/api/tests/unit_tests/services/test_credit_pool_service.py index 2781f1de426..92bb8b5a244 100644 --- a/api/tests/unit_tests/services/test_credit_pool_service.py +++ b/api/tests/unit_tests/services/test_credit_pool_service.py @@ -1,6 +1,6 @@ """Credit-pool accounting tests backed by real SQLite sessions.""" -from collections.abc import Generator +from collections.abc import Callable from types import SimpleNamespace from unittest.mock import ANY, MagicMock, patch from uuid import uuid4 @@ -48,9 +48,8 @@ def _make_redis_lock() -> MagicMock: @pytest.fixture(autouse=True) -def _disable_billing_quota_by_default() -> Generator[None, None, None]: - with patch("services.credit_pool_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY): - yield +def _disable_billing_quota_by_default(config_overrides: Callable[..., None]) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) def test_get_pool_uses_provided_session(sqlite_session: Session) -> None: @@ -248,10 +247,10 @@ def test_deduct_credits_capped_uses_tenant_redis_lock_before_db_deduction(sqlite get_locked_pool.assert_called_once_with(session=sqlite_session, tenant_id=tenant_id, pool_type="paid") -def test_get_pool_uses_billing_quota_balance_when_enabled() -> None: +def test_get_pool_uses_billing_quota_balance_when_enabled(config_overrides: Callable[..., None]) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) tenant_id = "tenant-1" with ( - patch("services.credit_pool_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("services.billing_service.BillingService.quota_get_balance") as quota_get_balance, ): quota_get_balance.return_value = { @@ -369,10 +368,12 @@ def test_reserve_credits_database_fallback_restores_released_amount(sqlite_sessi assert _get_quota_used(session=sqlite_session, pool_id=pool.id) == 2 -def test_check_and_deduct_credits_uses_billing_reserve_and_commit_when_enabled() -> None: +def test_check_and_deduct_credits_uses_billing_reserve_and_commit_when_enabled( + config_overrides: Callable[..., None], +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) tenant_id = "tenant-1" with ( - patch("services.credit_pool_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("services.billing_service.BillingService.quota_reserve") as quota_reserve, patch("services.billing_service.BillingService.quota_commit") as quota_commit, patch("services.billing_service.BillingService.quota_release") as quota_release, @@ -413,9 +414,11 @@ def test_check_and_deduct_credits_uses_billing_reserve_and_commit_when_enabled() quota_release.assert_not_called() -def test_check_and_deduct_credits_forwards_deterministic_billing_identity() -> None: +def test_check_and_deduct_credits_forwards_deterministic_billing_identity( + config_overrides: Callable[..., None], +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) with ( - patch("services.credit_pool_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("services.billing_service.BillingService.quota_reserve") as quota_reserve, patch("services.billing_service.BillingService.quota_commit") as quota_commit, ): @@ -454,9 +457,11 @@ def test_check_and_deduct_credits_forwards_deterministic_billing_identity() -> N ) -def test_check_and_deduct_credits_raises_when_billing_reserve_is_insufficient() -> None: +def test_check_and_deduct_credits_raises_when_billing_reserve_is_insufficient( + config_overrides: Callable[..., None], +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) with ( - patch("services.credit_pool_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("services.billing_service.BillingService.quota_reserve") as quota_reserve, ): quota_reserve.return_value = {"reservation_id": "", "available": 1, "reserved": 0} @@ -465,9 +470,11 @@ def test_check_and_deduct_credits_raises_when_billing_reserve_is_insufficient() CreditPoolService.check_and_deduct_credits(tenant_id="tenant-1", credits_required=3) -def test_check_and_deduct_credits_releases_billing_reservation_when_commit_fails() -> None: +def test_check_and_deduct_credits_releases_billing_reservation_when_commit_fails( + config_overrides: Callable[..., None], +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) with ( - patch("services.credit_pool_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("services.billing_service.BillingService.quota_reserve") as quota_reserve, patch("services.billing_service.BillingService.quota_commit", side_effect=RuntimeError("commit failed")), patch("services.billing_service.BillingService.quota_release") as quota_release, @@ -487,9 +494,10 @@ def test_check_and_deduct_credits_releases_billing_reservation_when_commit_fails def test_check_and_deduct_credits_logs_when_billing_release_fails( caplog: pytest.LogCaptureFixture, + config_overrides: Callable[..., None], ) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) with ( - patch("services.credit_pool_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("services.billing_service.BillingService.quota_reserve") as quota_reserve, patch("services.billing_service.BillingService.quota_commit", side_effect=RuntimeError("commit failed")), patch( @@ -512,10 +520,12 @@ def test_check_and_deduct_credits_logs_when_billing_release_fails( assert caplog.records[0].exc_info is not None -def test_deduct_credits_capped_uses_billing_consume_capped_when_enabled() -> None: +def test_deduct_credits_capped_uses_billing_consume_capped_when_enabled( + config_overrides: Callable[..., None], +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) tenant_id = "tenant-1" with ( - patch("services.credit_pool_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("services.billing_service.BillingService.quota_consume_capped") as quota_consume_capped, ): quota_consume_capped.return_value = { diff --git a/api/tests/unit_tests/services/test_dataset_service_dataset.py b/api/tests/unit_tests/services/test_dataset_service_dataset.py index 426c95663b4..545342c9df4 100644 --- a/api/tests/unit_tests/services/test_dataset_service_dataset.py +++ b/api/tests/unit_tests/services/test_dataset_service_dataset.py @@ -295,7 +295,9 @@ class TestDatasetServiceRetrieval: assert total == 2 assert {dataset.id for dataset in datasets} == {creator_one.id, creator_two.id} - def test_get_datasets_applies_rbac_resource_scope_and_maintainer_override(self, sqlite_session: Session) -> None: + def test_get_datasets_applies_rbac_resource_scope_and_maintainer_override( + self, config_overrides: Callable[..., None], sqlite_session: Session + ) -> None: user = _account(role=TenantAccountRole.NORMAL) accessible = _dataset(dataset_id="accessible", name="Accessible", maintainer="other") owned = _dataset(dataset_id="owned", name="Owned", maintainer=user.id) @@ -304,8 +306,9 @@ class TestDatasetServiceRetrieval: sqlite_session.add_all([accessible, owned, hidden, foreign]) sqlite_session.commit() + config_overrides(RBAC_ENABLED=True) + with ( - patch("services.dataset_service.dify_config.RBAC_ENABLED", True), patch( "services.dataset_service.enterprise_rbac_service.RBACService.MyPermissions.get", return_value=SimpleNamespace(workspace=SimpleNamespace(permission_keys=[])), @@ -324,24 +327,28 @@ class TestDatasetServiceRetrieval: assert total == 2 assert {dataset.id for dataset in datasets} == {accessible.id, owned.id} - def test_get_datasets_without_user_keeps_only_team_visible_rows(self, sqlite_session: Session) -> None: + def test_get_datasets_without_user_keeps_only_team_visible_rows( + self, config_overrides: Callable[..., None], sqlite_session: Session + ) -> None: shared = _dataset(dataset_id="shared", name="Shared", permission=DatasetPermissionEnum.ALL_TEAM) private = _dataset(dataset_id="private", name="Private", permission=DatasetPermissionEnum.ONLY_ME) sqlite_session.add_all([shared, private]) sqlite_session.commit() - with patch("services.dataset_service.dify_config.RBAC_ENABLED", False): - datasets, total = DatasetService.get_datasets( - page=1, - per_page=20, - session=sqlite_session, - tenant_id="tenant-1", - ) + config_overrides(RBAC_ENABLED=False) + datasets, total = DatasetService.get_datasets( + page=1, + per_page=20, + session=sqlite_session, + tenant_id="tenant-1", + ) assert total == 1 assert [dataset.id for dataset in datasets] == [shared.id] - def test_get_datasets_by_ids_intersects_requested_and_accessible_ids(self, sqlite_session: Session) -> None: + def test_get_datasets_by_ids_intersects_requested_and_accessible_ids( + self, config_overrides: Callable[..., None], sqlite_session: Session + ) -> None: user = _account(role=TenantAccountRole.NORMAL) accessible = _dataset(dataset_id="accessible", name="Accessible", maintainer="other") owned = _dataset(dataset_id="owned", name="Owned", maintainer=user.id) @@ -349,35 +356,39 @@ class TestDatasetServiceRetrieval: sqlite_session.add_all([accessible, owned, hidden]) sqlite_session.commit() - with patch("services.dataset_service.dify_config.RBAC_ENABLED", True): - datasets, total = DatasetService.get_datasets_by_ids( - [accessible.id, owned.id, hidden.id], - "tenant-1", - user=user, - accessible_dataset_ids=[accessible.id, "not-requested"], - include_own_datasets=True, - session=sqlite_session, - ) + config_overrides(RBAC_ENABLED=True) + datasets, total = DatasetService.get_datasets_by_ids( + [accessible.id, owned.id, hidden.id], + "tenant-1", + user=user, + accessible_dataset_ids=[accessible.id, "not-requested"], + include_own_datasets=True, + session=sqlite_session, + ) assert total == 2 assert {dataset.id for dataset in datasets} == {accessible.id, owned.id} - def test_get_datasets_rbac_without_user_returns_no_rows(self, sqlite_session: Session) -> None: + def test_get_datasets_rbac_without_user_returns_no_rows( + self, config_overrides: Callable[..., None], sqlite_session: Session + ) -> None: sqlite_session.add(_dataset()) sqlite_session.commit() - with patch("services.dataset_service.dify_config.RBAC_ENABLED", True): - datasets, total = DatasetService.get_datasets( - page=1, - per_page=20, - session=sqlite_session, - tenant_id="tenant-1", - ) + config_overrides(RBAC_ENABLED=True) + datasets, total = DatasetService.get_datasets( + page=1, + per_page=20, + session=sqlite_session, + tenant_id="tenant-1", + ) assert datasets == [] assert total == 0 - def test_get_datasets_rbac_include_all_requires_workspace_permission(self, sqlite_session: Session) -> None: + def test_get_datasets_rbac_include_all_requires_workspace_permission( + self, config_overrides: Callable[..., None], sqlite_session: Session + ) -> None: user = _account(role=TenantAccountRole.NORMAL) sqlite_session.add_all( [ @@ -387,8 +398,8 @@ class TestDatasetServiceRetrieval: ) sqlite_session.commit() + config_overrides(RBAC_ENABLED=True) with ( - patch("services.dataset_service.dify_config.RBAC_ENABLED", True), patch( "services.dataset_service.enterprise_rbac_service.RBACService.MyPermissions.get", return_value=SimpleNamespace( diff --git a/api/tests/unit_tests/services/test_dataset_service_document.py b/api/tests/unit_tests/services/test_dataset_service_document.py index 1763cfab7b9..18229ac3fb1 100644 --- a/api/tests/unit_tests/services/test_dataset_service_document.py +++ b/api/tests/unit_tests/services/test_dataset_service_document.py @@ -1,5 +1,6 @@ """Unit tests for DocumentService behaviors in dataset_service.""" +from collections.abc import Callable from datetime import datetime from sqlalchemy import event, select @@ -1117,13 +1118,18 @@ class TestDocumentServiceSaveDocumentWithDatasetId: check_quota.assert_not_called() - def test_save_document_with_dataset_id_enforces_batch_upload_limit(self, account_context, unbound_session: Session): + def test_save_document_with_dataset_id_enforces_batch_upload_limit( + self, + account_context, + unbound_session: Session, + config_overrides: Callable[..., None], + ): + config_overrides(BATCH_UPLOAD_LIMIT=1) dataset = _dataset_row() knowledge_config = _make_upload_knowledge_config(file_ids=["file-1", "file-2"]) with ( patch("services.dataset_service.FeatureService.get_features", return_value=_make_features(enabled=True)), - patch("services.dataset_service.dify_config.BATCH_UPLOAD_LIMIT", 1), patch.object(DocumentService, "check_documents_upload_quota") as check_quota, ): with pytest.raises(ValueError, match="batch upload limit of 1"): @@ -1706,8 +1712,12 @@ class TestDocumentServiceSaveWithoutDatasetBilling: yield account def test_save_document_without_dataset_id_counts_notion_pages_for_quota( - self, account_context, sqlite_session: Session + self, + account_context, + sqlite_session: Session, + config_overrides: Callable[..., None], ): + config_overrides(BATCH_UPLOAD_LIMIT="10") knowledge_config = KnowledgeConfig( indexing_technique="economy", data_source=DataSource( @@ -1736,7 +1746,6 @@ class TestDocumentServiceSaveWithoutDatasetBilling: with ( patch("services.dataset_service.FeatureService.get_features", return_value=features), - patch("services.dataset_service.dify_config.BATCH_UPLOAD_LIMIT", "10"), patch.object(DocumentService, "check_documents_upload_quota") as check_quota, patch.object( DocumentService, @@ -1755,8 +1764,12 @@ class TestDocumentServiceSaveWithoutDatasetBilling: assert sqlite_session.get(Dataset, dataset.id) is dataset def test_save_document_without_dataset_id_enforces_batch_limit_for_website_urls( - self, account_context, unbound_session: Session + self, + account_context, + unbound_session: Session, + config_overrides: Callable[..., None], ): + config_overrides(BATCH_UPLOAD_LIMIT="1") knowledge_config = KnowledgeConfig( indexing_technique="economy", data_source=DataSource( @@ -1774,7 +1787,6 @@ class TestDocumentServiceSaveWithoutDatasetBilling: with ( patch("services.dataset_service.FeatureService.get_features", return_value=_make_features(enabled=True)), - patch("services.dataset_service.dify_config.BATCH_UPLOAD_LIMIT", "1"), patch.object(DocumentService, "check_documents_upload_quota") as check_quota, ): with pytest.raises(ValueError, match="batch upload limit of 1"): diff --git a/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py b/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py index 50ca483b976..76dc9f584d2 100644 --- a/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py +++ b/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py @@ -1,5 +1,5 @@ import types -from unittest.mock import Mock, create_autospec +from unittest.mock import Mock import pytest from redis.exceptions import LockNotOwnedError @@ -203,19 +203,48 @@ def test_add_segment_ignores_lock_not_owned( # --------------------------------------------------------------------------- +@pytest.mark.parametrize("sqlite_session", [(Account, Tenant, Dataset, Document, DocumentSegment)], indirect=True) def test_multi_create_segment_ignores_lock_not_owned( monkeypatch: pytest.MonkeyPatch, fake_current_user, fake_lock, + sqlite_session: Session, ): # Arrange - dataset = create_autospec(Dataset, instance=True) - dataset.id = "ds-1" - dataset.tenant_id = fake_current_user.current_tenant_id - dataset.indexing_technique = IndexTechniqueType.ECONOMY # again, skip high_quality path + dataset = Dataset( + id=DATASET_ID, + tenant_id=TENANT_ID, + name="Test Dataset", + description="", + created_by=USER_ID, + indexing_technique=IndexTechniqueType.ECONOMY, + ) + document = Document( + id=DOCUMENT_ID, + tenant_id=TENANT_ID, + dataset_id=DATASET_ID, + position=1, + data_source_type="upload_file", + data_source_info="{}", + batch="batch-1", + name="Test Document", + created_from="web", + created_by=USER_ID, + word_count=0, + doc_form=IndexStructureType.QA_INDEX, + ) + sqlite_session.add_all([fake_current_user._current_tenant, fake_current_user, dataset, document]) + sqlite_session.commit() - document = create_autospec(Document, instance=True) - document.id = "doc-1" - document.dataset_id = dataset.id - document.word_count = 0 - document.doc_form = IndexStructureType.QA_INDEX + result = SegmentService.multi_create_segment( + segments=[{"content": "question", "answer": "answer", "keywords": ["key"]}], + document=document, + dataset=dataset, + session=sqlite_session, + ) + + assert result is None + assert not sqlite_session.in_transaction() + assert sqlite_session.scalar(select(func.count(DocumentSegment.id))) == 0 + sqlite_session.refresh(document) + assert document.word_count == 0 diff --git a/api/tests/unit_tests/services/test_dataset_service_segment.py b/api/tests/unit_tests/services/test_dataset_service_segment.py index 2e2ad0aa774..5598361f977 100644 --- a/api/tests/unit_tests/services/test_dataset_service_segment.py +++ b/api/tests/unit_tests/services/test_dataset_service_segment.py @@ -1,5 +1,7 @@ """Unit tests for SegmentService behaviors in dataset_service.""" +from collections.abc import Callable + from services.dataset_ref_service import DatasetRef, DatasetRefService, DocumentRef, SegmentRef from .dataset_service_test_helpers import ( @@ -471,13 +473,13 @@ class TestSegmentServiceValidation: with pytest.raises(ValueError, match="Content is empty"): SegmentService.segment_create_args_validate({"content": " "}, document) - def test_segment_create_args_validate_enforces_attachment_limit(self): + def test_segment_create_args_validate_enforces_attachment_limit(self, config_overrides: Callable[..., None]): + config_overrides(SINGLE_CHUNK_ATTACHMENT_LIMIT=1) document = _make_document(doc_form=IndexStructureType.PARAGRAPH_INDEX) args = {"content": "hello", "attachment_ids": ["a-1", "a-2"]} - with patch("services.dataset_service.dify_config.SINGLE_CHUNK_ATTACHMENT_LIMIT", 1): - with pytest.raises(ValueError, match="Exceeded maximum attachment limit of 1"): - SegmentService.segment_create_args_validate(args, document) + with pytest.raises(ValueError, match="Exceeded maximum attachment limit of 1"): + SegmentService.segment_create_args_validate(args, document) def test_segment_create_args_validate_requires_attachment_ids_list(self): document = _make_document(doc_form=IndexStructureType.PARAGRAPH_INDEX) diff --git a/api/tests/unit_tests/services/test_email_code_login_challenge.py b/api/tests/unit_tests/services/test_email_code_login_challenge.py index 761c1bffe66..5ea11346eda 100644 --- a/api/tests/unit_tests/services/test_email_code_login_challenge.py +++ b/api/tests/unit_tests/services/test_email_code_login_challenge.py @@ -10,6 +10,7 @@ from services.email_code_login_challenge import ( EmailCodeLoginChallengeStore, EmailCodeLoginChallengeUnavailableError, ) +from tests.unit_tests.config_override import apply_config_overrides TOKEN = "00000000-0000-4000-8000-000000000001" @@ -23,8 +24,11 @@ def challenge_redis() -> Iterator[MagicMock]: def test_create_stores_only_one_per_email_v2_challenge( challenge_redis: MagicMock, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setattr("services.email_code_login_challenge.dify_config.EMAIL_CODE_LOGIN_MAX_ATTEMPTS", 5) - monkeypatch.setattr("services.email_code_login_challenge.dify_config.EMAIL_CODE_LOGIN_TOKEN_EXPIRY_MINUTES", 5) + apply_config_overrides( + monkeypatch, + EMAIL_CODE_LOGIN_MAX_ATTEMPTS=5, + EMAIL_CODE_LOGIN_TOKEN_EXPIRY_MINUTES=5, + ) with patch("services.email_code_login_challenge.uuid.uuid4", return_value=TOKEN): token = EmailCodeLoginChallengeStore.create( diff --git a/api/tests/unit_tests/services/test_feature_service_deployment_edition.py b/api/tests/unit_tests/services/test_feature_service_deployment_edition.py index e0a9bd68685..b31e398add4 100644 --- a/api/tests/unit_tests/services/test_feature_service_deployment_edition.py +++ b/api/tests/unit_tests/services/test_feature_service_deployment_edition.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from unittest.mock import MagicMock import pytest @@ -23,10 +24,11 @@ def test_system_feature_model_requires_deployment_edition() -> None: ) def test_get_system_features_uses_configured_deployment_edition( monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], edition: DeploymentEdition, ) -> None: fulfill_from_enterprise = MagicMock() - monkeypatch.setattr("services.feature_service.dify_config.DEPLOYMENT_EDITION", edition) + config_overrides(DEPLOYMENT_EDITION=edition) monkeypatch.setattr( "services.feature_service.FeatureService._fulfill_params_from_enterprise", fulfill_from_enterprise, @@ -55,12 +57,11 @@ def test_get_system_features_uses_configured_deployment_edition( ], ) def test_trial_app_policy_is_cloud_only( - monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], edition: DeploymentEdition, feature_enabled: bool, expected: bool, ) -> None: - monkeypatch.setattr("services.feature_service.dify_config.DEPLOYMENT_EDITION", edition) - monkeypatch.setattr("services.feature_service.dify_config.ENABLE_TRIAL_APP", feature_enabled) + config_overrides(DEPLOYMENT_EDITION=edition, ENABLE_TRIAL_APP=feature_enabled) assert FeatureService.is_trial_app_enabled() is expected diff --git a/api/tests/unit_tests/services/test_feature_service_explore_banner.py b/api/tests/unit_tests/services/test_feature_service_explore_banner.py index fb709d532c2..ade37592777 100644 --- a/api/tests/unit_tests/services/test_feature_service_explore_banner.py +++ b/api/tests/unit_tests/services/test_feature_service_explore_banner.py @@ -1,7 +1,8 @@ +from collections.abc import Callable + import pytest from enums import DeploymentEdition -from services import feature_service as feature_service_module from services.feature_service import FeatureService @@ -16,12 +17,12 @@ from services.feature_service import FeatureService ) def test_get_system_features_enables_explore_banner_only_for_cloud( monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], edition: DeploymentEdition, configured: bool, expected: bool, ) -> None: - monkeypatch.setattr(feature_service_module.dify_config, "DEPLOYMENT_EDITION", edition) - monkeypatch.setattr(feature_service_module.dify_config, "ENABLE_EXPLORE_BANNER", configured) + config_overrides(DEPLOYMENT_EDITION=edition, ENABLE_EXPLORE_BANNER=configured) monkeypatch.setattr(FeatureService, "_fulfill_params_from_enterprise", lambda *_: None) result = FeatureService.get_system_features() diff --git a/api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py b/api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py index 497e331a448..d790121f35c 100644 --- a/api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py +++ b/api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from dataclasses import dataclass import pytest @@ -80,10 +81,10 @@ CASES = [ @pytest.mark.parametrize("case", CASES, ids=lambda case: case.name) def test_resolve_human_input_email_delivery_enabled_matrix( - monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], case: HumanInputEmailDeliveryCase, ): - monkeypatch.setattr(feature_service_module.dify_config, "DEPLOYMENT_EDITION", case.deployment_edition) + config_overrides(DEPLOYMENT_EDITION=case.deployment_edition) features = FeatureModel() features.billing.enabled = case.billing_feature_enabled features.billing.subscription.plan = case.plan @@ -96,8 +97,10 @@ def test_resolve_human_input_email_delivery_enabled_matrix( assert result is case.expected -def test_get_vector_space_converts_billing_float_size(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(feature_service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) +def test_get_vector_space_converts_billing_float_size( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +): + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) monkeypatch.setattr( feature_service_module.BillingService, "get_vector_space", @@ -111,8 +114,10 @@ def test_get_vector_space_converts_billing_float_size(monkeypatch: pytest.Monkey assert result.usage_unknown is False -def test_get_vector_space_preserves_unknown_usage(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(feature_service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) +def test_get_vector_space_preserves_unknown_usage( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +): + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) monkeypatch.setattr( feature_service_module.BillingService, "get_vector_space", diff --git a/api/tests/unit_tests/services/test_feature_service_internal_policies.py b/api/tests/unit_tests/services/test_feature_service_internal_policies.py index b2a326e1cdb..8fe271c609c 100644 --- a/api/tests/unit_tests/services/test_feature_service_internal_policies.py +++ b/api/tests/unit_tests/services/test_feature_service_internal_policies.py @@ -1,12 +1,15 @@ +from collections.abc import Callable + import pytest from enums import DeploymentEdition from services.feature_service import FeatureService -def test_workspace_creation_uses_environment_policy(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("services.feature_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) - monkeypatch.setattr("services.feature_service.dify_config.ALLOW_CREATE_WORKSPACE", True) +def test_workspace_creation_uses_environment_policy( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, ALLOW_CREATE_WORKSPACE=True) monkeypatch.setattr( "services.feature_service.EnterpriseService.get_info", lambda: (_ for _ in ()).throw(AssertionError("enterprise API should not be called")), @@ -15,8 +18,10 @@ def test_workspace_creation_uses_environment_policy(monkeypatch: pytest.MonkeyPa assert FeatureService.is_workspace_creation_allowed() is True -def test_workspace_creation_uses_enterprise_policy(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("services.feature_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) +def test_workspace_creation_uses_enterprise_policy( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) monkeypatch.setattr( "services.feature_service.EnterpriseService.get_info", lambda: {"IsAllowCreateWorkspace": False}, @@ -27,17 +32,17 @@ def test_workspace_creation_uses_enterprise_policy(monkeypatch: pytest.MonkeyPat def test_workspace_creation_keeps_environment_policy_when_enterprise_value_is_missing( monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], ) -> None: - monkeypatch.setattr("services.feature_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) - monkeypatch.setattr("services.feature_service.dify_config.ALLOW_CREATE_WORKSPACE", True) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE, ALLOW_CREATE_WORKSPACE=True) monkeypatch.setattr("services.feature_service.EnterpriseService.get_info", lambda: {}) assert FeatureService.is_workspace_creation_allowed() is True -def test_plugin_manager_is_enabled_only_for_enterprise(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("services.feature_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) +def test_plugin_manager_is_enabled_only_for_enterprise(config_overrides: Callable[..., None]) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) assert FeatureService.is_plugin_manager_enabled() is True - monkeypatch.setattr("services.feature_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) assert FeatureService.is_plugin_manager_enabled() is False diff --git a/api/tests/unit_tests/services/test_feature_service_knowledge_file_size_limit.py b/api/tests/unit_tests/services/test_feature_service_knowledge_file_size_limit.py index 2bb9a6123c4..5a38f1f71fd 100644 --- a/api/tests/unit_tests/services/test_feature_service_knowledge_file_size_limit.py +++ b/api/tests/unit_tests/services/test_feature_service_knowledge_file_size_limit.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from unittest.mock import Mock import pytest @@ -21,18 +22,17 @@ from services.feature_service import FeatureService ) def test_get_knowledge_file_size_limit( monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], deployment_edition: DeploymentEdition, tenant_id: str | None, billing_feature_enabled: bool, plan: CloudPlan, expected: int, ) -> None: - monkeypatch.setattr(feature_service_module.dify_config, "DEPLOYMENT_EDITION", deployment_edition) - monkeypatch.setattr(feature_service_module.dify_config, "UPLOAD_FILE_SIZE_LIMIT", 15) - monkeypatch.setattr( - feature_service_module.dify_config, - "KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN", - 50, + config_overrides( + DEPLOYMENT_EDITION=deployment_edition, + UPLOAD_FILE_SIZE_LIMIT=15, + KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN=50, ) get_info = Mock( return_value={ @@ -50,13 +50,13 @@ def test_get_knowledge_file_size_limit( get_info.assert_not_called() -def test_paid_knowledge_file_size_limit_never_reduces_default(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(feature_service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) - monkeypatch.setattr(feature_service_module.dify_config, "UPLOAD_FILE_SIZE_LIMIT", 100) - monkeypatch.setattr( - feature_service_module.dify_config, - "KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN", - 50, +def test_paid_knowledge_file_size_limit_never_reduces_default( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides( + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + UPLOAD_FILE_SIZE_LIMIT=100, + KNOWLEDGE_UPLOAD_FILE_SIZE_LIMIT_FOR_PAID_PLAN=50, ) monkeypatch.setattr( feature_service_module.BillingService, diff --git a/api/tests/unit_tests/services/test_feature_service_knowledge_fs.py b/api/tests/unit_tests/services/test_feature_service_knowledge_fs.py index ebf46bc9fe7..ecea55f2c78 100644 --- a/api/tests/unit_tests/services/test_feature_service_knowledge_fs.py +++ b/api/tests/unit_tests/services/test_feature_service_knowledge_fs.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + import pytest from enums import DeploymentEdition @@ -24,34 +26,20 @@ def test_system_feature_model_disables_knowledge_fs_by_default() -> None: ], ) def test_get_system_features_reads_knowledge_fs_availability( - monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], enabled: bool, base_url: str | None, capability_enabled: bool, signing_ready: bool, upload_enabled: bool, ) -> None: - monkeypatch.setattr( - feature_service_module.dify_config, - "DEPLOYMENT_EDITION", - DeploymentEdition.CLOUD, - ) - monkeypatch.setattr(feature_service_module.dify_config, "KNOWLEDGE_FS_ENABLED", enabled) - monkeypatch.setattr(feature_service_module.dify_config, "KNOWLEDGE_FS_BASE_URL", base_url) - monkeypatch.setattr( - feature_service_module.dify_config, - "KNOWLEDGE_FS_CAPABILITY_V2_ENABLED", - capability_enabled, - ) - monkeypatch.setattr( - feature_service_module.dify_config, - "KNOWLEDGE_FS_CAPABILITY_V2_SIGNING_KID", - "signing-key" if signing_ready else None, - ) - monkeypatch.setattr( - feature_service_module.dify_config, - "KNOWLEDGE_FS_CAPABILITY_V2_PRIVATE_KEY_PEM", - object() if signing_ready else None, + config_overrides( + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + KNOWLEDGE_FS_ENABLED=enabled, + KNOWLEDGE_FS_BASE_URL=base_url, + KNOWLEDGE_FS_CAPABILITY_V2_ENABLED=capability_enabled, + KNOWLEDGE_FS_CAPABILITY_V2_SIGNING_KID="signing-key" if signing_ready else None, + KNOWLEDGE_FS_CAPABILITY_V2_PRIVATE_KEY_PEM=object() if signing_ready else None, ) result = FeatureService.get_system_features() diff --git a/api/tests/unit_tests/services/test_feature_service_learn_app.py b/api/tests/unit_tests/services/test_feature_service_learn_app.py index 7a58f45c15b..dc499aaa414 100644 --- a/api/tests/unit_tests/services/test_feature_service_learn_app.py +++ b/api/tests/unit_tests/services/test_feature_service_learn_app.py @@ -1,7 +1,8 @@ +from collections.abc import Callable + import pytest from enums import DeploymentEdition -from services import feature_service as feature_service_module from services.entities.feature_entities import SystemFeatureModel from services.feature_service import FeatureService @@ -13,8 +14,8 @@ def test_system_feature_model_defaults_enable_learn_app(): @pytest.mark.parametrize("enabled", [True, False]) -def test_get_system_features_reads_enable_learn_app(monkeypatch: pytest.MonkeyPatch, enabled: bool): - monkeypatch.setattr(feature_service_module.dify_config, "ENABLE_LEARN_APP", enabled) +def test_get_system_features_reads_enable_learn_app(config_overrides: Callable[..., None], enabled: bool): + config_overrides(ENABLE_LEARN_APP=enabled) result = FeatureService.get_system_features() @@ -22,8 +23,10 @@ def test_get_system_features_reads_enable_learn_app(monkeypatch: pytest.MonkeyPa @pytest.mark.parametrize("enabled", [True, False]) -def test_get_system_features_reads_enable_step_by_step_tour(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None: - monkeypatch.setattr(feature_service_module.dify_config, "ENABLE_STEP_BY_STEP_TOUR", enabled) +def test_get_system_features_reads_enable_step_by_step_tour( + config_overrides: Callable[..., None], enabled: bool +) -> None: + config_overrides(ENABLE_STEP_BY_STEP_TOUR=enabled) result = FeatureService.get_system_features() diff --git a/api/tests/unit_tests/services/test_feature_service_license_expiry_notice.py b/api/tests/unit_tests/services/test_feature_service_license_expiry_notice.py index 84c130dda12..dac43f83706 100644 --- a/api/tests/unit_tests/services/test_feature_service_license_expiry_notice.py +++ b/api/tests/unit_tests/services/test_feature_service_license_expiry_notice.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + import pytest from enums import DeploymentEdition @@ -15,14 +17,12 @@ def test_license_model_defaults_license_expiry_notice_disabled() -> None: @pytest.mark.parametrize("enabled", [True, False]) def test_get_license_non_enterprise_ignores_expiry_notice_config( - monkeypatch: pytest.MonkeyPatch, enabled: bool + config_overrides: Callable[..., None], enabled: bool ) -> None: """Non-enterprise deployments have no license, so the env toggle never turns the notice on.""" - monkeypatch.setattr(feature_service_module.dify_config, "ENABLE_LICENSE_EXPIRY_NOTICE", enabled) - monkeypatch.setattr( - feature_service_module.dify_config, - "DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, + config_overrides( + ENABLE_LICENSE_EXPIRY_NOTICE=enabled, + DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, ) result = FeatureService.get_license() @@ -32,14 +32,12 @@ def test_get_license_non_enterprise_ignores_expiry_notice_config( @pytest.mark.parametrize("enabled", [True, False]) def test_get_license_enterprise_reads_license_expiry_notice_enabled( - monkeypatch: pytest.MonkeyPatch, enabled: bool + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None], enabled: bool ) -> None: """The enterprise-sourced license carries the env-resolved notice flag alongside its real status.""" - monkeypatch.setattr(feature_service_module.dify_config, "ENABLE_LICENSE_EXPIRY_NOTICE", enabled) - monkeypatch.setattr( - feature_service_module.dify_config, - "DEPLOYMENT_EDITION", - DeploymentEdition.ENTERPRISE, + config_overrides( + ENABLE_LICENSE_EXPIRY_NOTICE=enabled, + DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE, ) monkeypatch.setattr( feature_service_module.EnterpriseService, diff --git a/api/tests/unit_tests/services/test_feature_service_licensed_seats.py b/api/tests/unit_tests/services/test_feature_service_licensed_seats.py index 1231c9c56ea..9df759a8f6a 100644 --- a/api/tests/unit_tests/services/test_feature_service_licensed_seats.py +++ b/api/tests/unit_tests/services/test_feature_service_licensed_seats.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + import pytest from enums import DeploymentEdition @@ -8,9 +10,9 @@ from services.feature_service import FeatureService _ENTERPRISE_INFO = {"License": {"licensedSeats": {"enabled": True, "limit": 3, "used": 1}}} -def test_get_license_parses_licensed_seats(monkeypatch: pytest.MonkeyPatch): +def test_get_license_parses_licensed_seats(monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]): """The authenticated license accessor copies the licensed-seat quota out of the enterprise payload.""" - monkeypatch.setattr("services.feature_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) monkeypatch.setattr( feature_service_module.EnterpriseService, "get_info", @@ -25,9 +27,9 @@ def test_get_license_parses_licensed_seats(monkeypatch: pytest.MonkeyPatch): assert license_model.seats.size == 1 -def test_get_license_non_enterprise_is_unconstrained(monkeypatch: pytest.MonkeyPatch): +def test_get_license_non_enterprise_is_unconstrained(config_overrides: Callable[..., None]): """Non-enterprise deployments have no license; seat allocation is unconstrained.""" - monkeypatch.setattr("services.feature_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) license_model = FeatureService.get_license() diff --git a/api/tests/unit_tests/services/test_feature_service_plugin_installation_permission.py b/api/tests/unit_tests/services/test_feature_service_plugin_installation_permission.py index 8ae24b07eb9..4ec58c793ee 100644 --- a/api/tests/unit_tests/services/test_feature_service_plugin_installation_permission.py +++ b/api/tests/unit_tests/services/test_feature_service_plugin_installation_permission.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Callable import pytest @@ -9,9 +10,9 @@ from services.feature_service import FeatureService def test_get_plugin_installation_permission_defaults_to_all_for_non_enterprise( - monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], ) -> None: - monkeypatch.setattr(feature_service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) permission = FeatureService.get_plugin_installation_permission() @@ -21,8 +22,9 @@ def test_get_plugin_installation_permission_defaults_to_all_for_non_enterprise( def test_get_plugin_installation_permission_parses_enterprise_policy( monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], ) -> None: - monkeypatch.setattr(feature_service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) monkeypatch.setattr( feature_service_module.EnterpriseService, "get_info", diff --git a/api/tests/unit_tests/services/test_feature_service_trial_models.py b/api/tests/unit_tests/services/test_feature_service_trial_models.py index 6c5c2c556da..c4c70b80ca0 100644 --- a/api/tests/unit_tests/services/test_feature_service_trial_models.py +++ b/api/tests/unit_tests/services/test_feature_service_trial_models.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from unittest.mock import Mock import pytest @@ -13,25 +14,21 @@ def test_get_system_features_excludes_trial_models(): assert "trial_models" not in result -def test_get_trial_models_returns_providers_with_paid_or_trial_enabled(monkeypatch: pytest.MonkeyPatch): +def test_get_trial_models_returns_providers_with_paid_or_trial_enabled( + config_overrides: Callable[..., None], +): + values: dict[str, bool] = {} for provider in HostedTrialProvider: - monkeypatch.setattr( - feature_service_module.dify_config, - f"HOSTED_{provider.config_key}_PAID_ENABLED", - False, - raising=False, - ) - monkeypatch.setattr( - feature_service_module.dify_config, - f"HOSTED_{provider.config_key}_TRIAL_ENABLED", - False, - raising=False, - ) + values[f"HOSTED_{provider.config_key}_PAID_ENABLED"] = False + values[f"HOSTED_{provider.config_key}_TRIAL_ENABLED"] = False - monkeypatch.setattr(feature_service_module.dify_config, "HOSTED_OPENAI_PAID_ENABLED", True, raising=False) - monkeypatch.setattr(feature_service_module.dify_config, "HOSTED_OPENAI_TRIAL_ENABLED", True, raising=False) - monkeypatch.setattr(feature_service_module.dify_config, "HOSTED_XAI_PAID_ENABLED", True, raising=False) - monkeypatch.setattr(feature_service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + values.update( + HOSTED_OPENAI_PAID_ENABLED=True, + HOSTED_OPENAI_TRIAL_ENABLED=True, + HOSTED_XAI_PAID_ENABLED=True, + DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, + ) + config_overrides(**values) result = FeatureService.get_trial_models("tenant_1") @@ -50,26 +47,20 @@ def test_get_trial_models_returns_providers_with_paid_or_trial_enabled(monkeypat ) def test_get_trial_models_filters_providers_by_workspace_plan( monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], plan: CloudPlan, expected: list[str], ) -> None: + values: dict[str, object] = {} for provider in HostedTrialProvider: - monkeypatch.setattr( - feature_service_module.dify_config, - f"HOSTED_{provider.config_key}_PAID_ENABLED", - False, - raising=False, - ) - monkeypatch.setattr( - feature_service_module.dify_config, - f"HOSTED_{provider.config_key}_TRIAL_ENABLED", - False, - raising=False, - ) - - monkeypatch.setattr(feature_service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) - monkeypatch.setattr(feature_service_module.dify_config, "HOSTED_OPENAI_TRIAL_ENABLED", True, raising=False) - monkeypatch.setattr(feature_service_module.dify_config, "HOSTED_XAI_PAID_ENABLED", True, raising=False) + values[f"HOSTED_{provider.config_key}_PAID_ENABLED"] = False + values[f"HOSTED_{provider.config_key}_TRIAL_ENABLED"] = False + values.update( + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + HOSTED_OPENAI_TRIAL_ENABLED=True, + HOSTED_XAI_PAID_ENABLED=True, + ) + config_overrides(**values) get_workspace_plan = Mock(return_value=plan) monkeypatch.setattr(feature_service_module.FeatureService, "get_workspace_plan", get_workspace_plan) diff --git a/api/tests/unit_tests/services/test_feature_service_webapp_public_access.py b/api/tests/unit_tests/services/test_feature_service_webapp_public_access.py index 00b4bb8ec43..ba6bc45c357 100644 --- a/api/tests/unit_tests/services/test_feature_service_webapp_public_access.py +++ b/api/tests/unit_tests/services/test_feature_service_webapp_public_access.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + import pytest from enums import DeploymentEdition @@ -14,11 +16,11 @@ from services.feature_service import FeatureService ids=["disabled_by_env", "enabled_by_env"], ) def test_fulfill_system_params_from_env_sets_allow_public_access( - monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], env_value: bool, expected: bool, ): - monkeypatch.setattr("services.feature_service.dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED", env_value) + config_overrides(WEBAPP_PUBLIC_ACCESS_ENABLED=env_value) system_features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY) FeatureService._fulfill_system_params_from_env(system_features) diff --git a/api/tests/unit_tests/services/test_file_service.py b/api/tests/unit_tests/services/test_file_service.py index ffd507fd06a..49367183d9c 100644 --- a/api/tests/unit_tests/services/test_file_service.py +++ b/api/tests/unit_tests/services/test_file_service.py @@ -1,7 +1,7 @@ import base64 import hashlib import os -from collections.abc import Iterator +from collections.abc import Callable, Iterator from datetime import UTC, datetime from unittest.mock import patch @@ -10,7 +10,6 @@ from sqlalchemy import Engine from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import NotFound -from configs import dify_config from enums import DeploymentEdition from extensions.storage.storage_type import StorageType from models.base import TypeBase @@ -175,12 +174,12 @@ class TestFileService: assert result.name.endswith(".txt") assert db_session.get(UploadFile, result.id) is not None - def test_upload_file_blocked_extension(self, file_service): - with patch.object(dify_config, "inner_UPLOAD_FILE_EXTENSION_BLACKLIST", "exe"): - with pytest.raises(BlockedFileExtensionError): - file_service.upload_file( - filename="test.exe", content=b"", mimetype="application/octet-stream", user=_account() - ) + def test_upload_file_blocked_extension(self, file_service, config_overrides: Callable[..., None]): + config_overrides(inner_UPLOAD_FILE_EXTENSION_BLACKLIST="exe") + with pytest.raises(BlockedFileExtensionError): + file_service.upload_file( + filename="test.exe", content=b"", mimetype="application/octet-stream", user=_account() + ) def test_upload_file_unsupported_type_for_datasets(self, file_service): with pytest.raises(UnsupportedFileTypeError): @@ -188,12 +187,12 @@ class TestFileService: filename="test.jpg", content=b"", mimetype="image/jpeg", user=_account(), source="datasets" ) - def test_upload_file_too_large(self, file_service): + def test_upload_file_too_large(self, file_service, config_overrides: Callable[..., None]): # 16MB file for an image with 15MB limit content = b"a" * (16 * 1024 * 1024) - with patch.object(dify_config, "UPLOAD_IMAGE_FILE_SIZE_LIMIT", 15): - with pytest.raises(FileTooLargeError): - file_service.upload_file(filename="test.jpg", content=content, mimetype="image/jpeg", user=_account()) + config_overrides(UPLOAD_IMAGE_FILE_SIZE_LIMIT=15) + with pytest.raises(FileTooLargeError): + file_service.upload_file(filename="test.jpg", content=content, mimetype="image/jpeg", user=_account()) def test_upload_file_end_user(self, file_service: FileService, db_session: Session): user = EndUser( @@ -210,54 +209,54 @@ class TestFileService: assert result.created_by_role == CreatorUserRole.END_USER assert db_session.get(UploadFile, result.id) is not None - def test_is_file_size_within_limit(self): - with ( - patch.object(dify_config, "UPLOAD_IMAGE_FILE_SIZE_LIMIT", 10), - patch.object(dify_config, "UPLOAD_VIDEO_FILE_SIZE_LIMIT", 20), - patch.object(dify_config, "UPLOAD_AUDIO_FILE_SIZE_LIMIT", 30), - patch.object(dify_config, "UPLOAD_FILE_SIZE_LIMIT", 5), - ): - # Image - assert FileService.is_file_size_within_limit(extension="jpg", file_size=10 * 1024 * 1024) is True - assert FileService.is_file_size_within_limit(extension="png", file_size=11 * 1024 * 1024) is False + def test_is_file_size_within_limit(self, config_overrides: Callable[..., None]): + config_overrides( + UPLOAD_IMAGE_FILE_SIZE_LIMIT=10, + UPLOAD_VIDEO_FILE_SIZE_LIMIT=20, + UPLOAD_AUDIO_FILE_SIZE_LIMIT=30, + UPLOAD_FILE_SIZE_LIMIT=5, + ) + # Image + assert FileService.is_file_size_within_limit(extension="jpg", file_size=10 * 1024 * 1024) is True + assert FileService.is_file_size_within_limit(extension="png", file_size=11 * 1024 * 1024) is False - # Video - assert FileService.is_file_size_within_limit(extension="mp4", file_size=20 * 1024 * 1024) is True - assert FileService.is_file_size_within_limit(extension="avi", file_size=21 * 1024 * 1024) is False + # Video + assert FileService.is_file_size_within_limit(extension="mp4", file_size=20 * 1024 * 1024) is True + assert FileService.is_file_size_within_limit(extension="avi", file_size=21 * 1024 * 1024) is False - # Audio - assert FileService.is_file_size_within_limit(extension="mp3", file_size=30 * 1024 * 1024) is True - assert FileService.is_file_size_within_limit(extension="wav", file_size=31 * 1024 * 1024) is False + # Audio + assert FileService.is_file_size_within_limit(extension="mp3", file_size=30 * 1024 * 1024) is True + assert FileService.is_file_size_within_limit(extension="wav", file_size=31 * 1024 * 1024) is False - # Default - assert FileService.is_file_size_within_limit(extension="txt", file_size=5 * 1024 * 1024) is True - assert FileService.is_file_size_within_limit(extension="pdf", file_size=6 * 1024 * 1024) is False - assert ( - FileService.is_file_size_within_limit( - extension="pdf", - file_size=6 * 1024 * 1024, - default_file_size_limit=7, - ) - is True + # Default + assert FileService.is_file_size_within_limit(extension="txt", file_size=5 * 1024 * 1024) is True + assert FileService.is_file_size_within_limit(extension="pdf", file_size=6 * 1024 * 1024) is False + assert ( + FileService.is_file_size_within_limit( + extension="pdf", + file_size=6 * 1024 * 1024, + default_file_size_limit=7, ) - assert ( - FileService.is_file_size_within_limit( - extension="pdf", - file_size=8 * 1024 * 1024, - default_file_size_limit=7, - ) - is False + is True + ) + assert ( + FileService.is_file_size_within_limit( + extension="pdf", + file_size=8 * 1024 * 1024, + default_file_size_limit=7, ) + is False + ) - # Media-specific limits are not affected by the knowledge document override. - assert ( - FileService.is_file_size_within_limit( - extension="jpg", - file_size=11 * 1024 * 1024, - default_file_size_limit=100, - ) - is False + # Media-specific limits are not affected by the knowledge document override. + assert ( + FileService.is_file_size_within_limit( + extension="jpg", + file_size=11 * 1024 * 1024, + default_file_size_limit=100, ) + is False + ) def test_get_file_base64_success(self, file_service: FileService, db_session: Session): self._persist_upload_file(db_session, key="test_key") @@ -276,7 +275,10 @@ class TestFileService: with pytest.raises(NotFound, match="File not found"): file_service.get_file_base64("non_existent") - def test_get_file_presigned_url_success(self, file_service: FileService, db_session: Session): + def test_get_file_presigned_url_success( + self, file_service: FileService, db_session: Session, config_overrides: Callable[..., None] + ): + config_overrides(FILES_ACCESS_TIMEOUT=300) self._persist_upload_file( db_session, extension="png", @@ -285,7 +287,6 @@ class TestFileService: ) with ( - patch.object(dify_config, "FILES_ACCESS_TIMEOUT", 300), patch("services.file_service.storage") as mock_storage, ): mock_storage.generate_presigned_url.return_value = "https://s3.example.com/icon.png?signature=test" @@ -303,12 +304,11 @@ class TestFileService: with pytest.raises(NotFound, match="File not found"): file_service.get_file_presigned_url(file_id="file_id", tenant_id="tenant_id") - def test_get_icon_url_uses_direct_storage_url_for_cloud_s3(self, file_service: FileService): - with ( - patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch.object(dify_config, "STORAGE_TYPE", StorageType.S3), - patch.object(file_service, "get_file_presigned_url", return_value="direct-url") as get_presigned_url, - ): + def test_get_icon_url_uses_direct_storage_url_for_cloud_s3( + self, file_service: FileService, config_overrides: Callable[..., None] + ): + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, STORAGE_TYPE=StorageType.S3) + with patch.object(file_service, "get_file_presigned_url", return_value="direct-url") as get_presigned_url: result = file_service.get_icon_url("file_id", "tenant_id") assert result == "direct-url" @@ -326,12 +326,10 @@ class TestFileService: file_service: FileService, deployment_edition: DeploymentEdition, storage_type: StorageType, + config_overrides: Callable[..., None], ): - with ( - patch.object(dify_config, "DEPLOYMENT_EDITION", deployment_edition), - patch.object(dify_config, "STORAGE_TYPE", storage_type), - patch("services.file_service.file_helpers.get_signed_file_url", return_value="preview-url") as get_url, - ): + config_overrides(DEPLOYMENT_EDITION=deployment_edition, STORAGE_TYPE=storage_type) + with patch("services.file_service.file_helpers.get_signed_file_url", return_value="preview-url") as get_url: result = file_service.get_icon_url("file_id", "tenant_id") assert result == "preview-url" diff --git a/api/tests/unit_tests/services/test_human_input_delivery_test_service.py b/api/tests/unit_tests/services/test_human_input_delivery_test_service.py index fb9ebf6e9be..8929d49702f 100644 --- a/api/tests/unit_tests/services/test_human_input_delivery_test_service.py +++ b/api/tests/unit_tests/services/test_human_input_delivery_test_service.py @@ -2,7 +2,8 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from collections.abc import Callable +from unittest.mock import MagicMock from uuid import uuid4 import pytest @@ -10,7 +11,6 @@ from flask import Flask from sqlalchemy.engine import Engine from sqlalchemy.orm import Session -from configs import dify_config from core.workflow.human_input_adapter import ( EmailDeliveryConfig, EmailDeliveryMethod, @@ -45,17 +45,17 @@ def _make_valid_email_config(): ) -def test_build_form_link(): - with patch.object(dify_config, "APP_WEB_URL", "http://example.com/"): - assert _build_form_link("token123") == "http://example.com/form/token123" +def test_build_form_link(config_overrides: Callable[..., None]): + config_overrides(APP_WEB_URL="http://example.com/") + assert _build_form_link("token123") == "http://example.com/form/token123" - with patch.object(dify_config, "APP_WEB_URL", "http://example.com"): - assert _build_form_link("token123") == "http://example.com/form/token123" + config_overrides(APP_WEB_URL="http://example.com") + assert _build_form_link("token123") == "http://example.com/form/token123" assert _build_form_link(None) is None - with patch.object(dify_config, "APP_WEB_URL", None): - assert _build_form_link("token123") is None + config_overrides(APP_WEB_URL=None) + assert _build_form_link("token123") is None class TestDeliveryTestRegistry: @@ -320,7 +320,8 @@ class TestEmailDeliveryTestHandler: handler = EmailDeliveryTestHandler(session_factory=sqlite_engine) assert handler._query_workspace_member_emails(tenant_id="t1", user_ids=[]) == {} - def test_build_substitutions(self): + def test_build_substitutions(self, config_overrides: Callable[..., None]): + config_overrides(APP_WEB_URL="http://example.com") context = DeliveryTestContext( tenant_id="t1", app_id="a1", @@ -331,8 +332,7 @@ class TestEmailDeliveryTestHandler: recipients=[DeliveryTestEmailRecipient(email="test@example.com", form_token="token123")], ) - with patch.object(dify_config, "APP_WEB_URL", "http://example.com"): - subs = EmailDeliveryTestHandler._build_substitutions(context=context, recipient_email="test@example.com") + subs = EmailDeliveryTestHandler._build_substitutions(context=context, recipient_email="test@example.com") assert subs["node_title"] == "title" assert subs["form_content"] == "content" diff --git a/api/tests/unit_tests/services/test_human_input_service.py b/api/tests/unit_tests/services/test_human_input_service.py index 8760e6cb957..5c42999ae74 100644 --- a/api/tests/unit_tests/services/test_human_input_service.py +++ b/api/tests/unit_tests/services/test_human_input_service.py @@ -40,6 +40,7 @@ from services.human_input_service import ( HumanInputService, InvalidFormDataError, ) +from tests.unit_tests.config_override import apply_config_overrides def _make_app(mode: AppMode) -> App: @@ -130,7 +131,7 @@ def test_ensure_form_active_respects_global_timeout( created_at=naive_utc_now() - timedelta(hours=2), expiration_time=naive_utc_now() + timedelta(hours=2), ) - monkeypatch.setattr(human_input_service_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 3600) + apply_config_overrides(monkeypatch, HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS=3600) with pytest.raises(FormExpiredError): service.ensure_form_active(Form(expired_record)) @@ -701,7 +702,7 @@ def test_is_globally_expired_zero_timeout( ) -> None: service = HumanInputService(unbound_session_factory) - monkeypatch.setattr(human_input_service_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0) + apply_config_overrides(monkeypatch, HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS=0) assert service._is_globally_expired(Form(sample_form_record)) is False diff --git a/api/tests/unit_tests/services/test_model_provider_service.py b/api/tests/unit_tests/services/test_model_provider_service.py index 0de7fe2c9c4..2c2f86bbf40 100644 --- a/api/tests/unit_tests/services/test_model_provider_service.py +++ b/api/tests/unit_tests/services/test_model_provider_service.py @@ -387,7 +387,9 @@ class TestModelProviderServiceConfiguration: def test_preferred_provider_fallback_uses_custom_presence_not_configuration_status( self, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setattr(service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + from tests.unit_tests.config_override import apply_config_overrides + + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) state = _ProviderSummaryState(has_custom_provider=True) preferred_provider_type = ModelProviderService._get_preferred_provider_type( diff --git a/api/tests/unit_tests/services/test_notification_gateway.py b/api/tests/unit_tests/services/test_notification_gateway.py new file mode 100644 index 00000000000..9df67e7a325 --- /dev/null +++ b/api/tests/unit_tests/services/test_notification_gateway.py @@ -0,0 +1,63 @@ +from unittest.mock import patch + +from services.entities.notification_entities import NotificationContent +from services.notification_gateway import BillingNotificationGateway + + +def test_get_active_maps_billing_proto_json_contract() -> None: + payload = { + "shouldShow": True, + "notifications": [ + { + "notificationId": "notification-1", + "frequency": "once", + "contents": { + "en-US": { + "lang": "en-US", + "title": "Title", + "subtitle": "Subtitle", + "body": "Body", + "titlePicUrl": "title.png", + } + }, + } + ], + } + + with patch("services.notification_gateway.BillingService.get_account_notification", return_value=payload): + result = BillingNotificationGateway().get_active("account-1") + + assert result.should_show is True + assert result.notifications[0].notification_id == "notification-1" + assert result.notifications[0].contents["en-US"].title_pic_url == "title.png" + + +def test_get_active_omits_empty_localized_content_so_service_can_fall_back() -> None: + empty_localized_content: dict[str, str] = {} + payload = { + "shouldShow": True, + "notifications": [ + { + "notificationId": "notification-1", + "frequency": "once", + "contents": { + "zh-Hans": empty_localized_content, + "en-US": {"lang": "en-US", "title": "Title"}, + }, + } + ], + } + + with patch("services.notification_gateway.BillingService.get_account_notification", return_value=payload): + result = BillingNotificationGateway().get_active("account-1") + + assert result.notifications[0].contents == { + "en-US": NotificationContent("en-US", "Title", "", "", ""), + } + + +def test_dismiss_delegates_to_billing_service() -> None: + with patch("services.notification_gateway.BillingService.dismiss_notification") as dismiss: + BillingNotificationGateway().dismiss("notification-1", "account-1") + + dismiss.assert_called_once_with(notification_id="notification-1", account_id="account-1") diff --git a/api/tests/unit_tests/services/test_notification_service.py b/api/tests/unit_tests/services/test_notification_service.py new file mode 100644 index 00000000000..3be7f08a6f7 --- /dev/null +++ b/api/tests/unit_tests/services/test_notification_service.py @@ -0,0 +1,138 @@ +from datetime import datetime +from unittest.mock import Mock + +import pytest + +from machinery.context import RequestContext +from services.account_ports import AccountRepository +from services.entities.account_entities import AccountSnapshot +from services.entities.notification_entities import ( + AccountNotification, + AccountNotificationBatch, + NotificationContent, + NotificationItem, + NotificationResult, +) +from services.notification_service import NotificationService + + +def _context() -> RequestContext: + return RequestContext( + request_id="request-1", + trace_id="trace-1", + account_id="account-1", + active_workspace_id="workspace-1", + ) + + +class NotificationGatewayStub: + def __init__(self, batch: AccountNotificationBatch) -> None: + self.batch = batch + self.get_account_ids: list[str] = [] + self.dismissals: list[tuple[str, str]] = [] + + def get_active(self, account_id: str) -> AccountNotificationBatch: + self.get_account_ids.append(account_id) + return self.batch + + def dismiss(self, notification_id: str, account_id: str) -> None: + self.dismissals.append((notification_id, account_id)) + + +def _account(language: str | None = "zh-Hans") -> AccountSnapshot: + return AccountSnapshot( + id="account-1", + name="Account", + email="account@example.com", + avatar=None, + is_password_set=False, + interface_language=language, + interface_theme="light", + timezone="UTC", + last_login_at=None, + last_login_ip=None, + status="active", + initialized_at=None, + created_at=datetime(2026, 1, 1), + ) + + +def _accounts(account: AccountSnapshot | None) -> Mock: + accounts = Mock(spec=AccountRepository) + accounts.get.return_value = account + return accounts + + +def _notification(contents: dict[str, NotificationContent]) -> AccountNotification: + return AccountNotification( + notification_id="notification-1", + frequency="once", + contents=contents, + ) + + +def test_get_active_localizes_notification_for_account_language() -> None: + chinese = NotificationContent("zh-Hans", "标题", "副标题", "正文", "zh.png") + english = NotificationContent("en-US", "Title", "Subtitle", "Body", "en.png") + gateway = NotificationGatewayStub( + AccountNotificationBatch(True, (_notification({"zh-Hans": chinese, "en-US": english}),)) + ) + service = NotificationService(accounts=_accounts(_account()), notifications=gateway) + + result = service.get_active(_context()) + + assert result == NotificationResult( + should_show=True, + notifications=(NotificationItem("notification-1", "once", "zh-Hans", "标题", "副标题", "正文", "zh.png"),), + ) + assert gateway.get_account_ids == ["account-1"] + + +def test_get_active_falls_back_to_english() -> None: + english = NotificationContent("en-US", "Title", "Subtitle", "Body", "en.png") + gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({"en-US": english}),))) + service = NotificationService(accounts=_accounts(_account("fr-FR")), notifications=gateway) + + result = service.get_active(_context()) + + assert result.notifications[0].lang == "en-US" + assert result.notifications[0].title == "Title" + + +def test_get_active_skips_account_query_when_gateway_says_not_to_show() -> None: + accounts = _accounts(None) + service = NotificationService( + accounts=accounts, + notifications=NotificationGatewayStub(AccountNotificationBatch(False, ())), + ) + + result = service.get_active(_context()) + + assert result == NotificationResult(False, ()) + accounts.get.assert_not_called() + + +def test_get_active_uses_empty_content_when_notification_has_no_translations() -> None: + gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({}),))) + service = NotificationService(accounts=_accounts(_account(None)), notifications=gateway) + + result = service.get_active(_context()) + + assert result.notifications == (NotificationItem("notification-1", "once", "en-US", "", "", "", ""),) + + +def test_get_active_rejects_unknown_admitted_account() -> None: + gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({}),))) + service = NotificationService(accounts=_accounts(None), notifications=gateway) + + with pytest.raises(RuntimeError, match="unknown account"): + service.get_active(_context()) + + +def test_dismiss_delegates_identifiers_to_gateway() -> None: + gateway = NotificationGatewayStub(AccountNotificationBatch(False, ())) + service = NotificationService(accounts=_accounts(_account()), notifications=gateway) + + service.dismiss(_context(), "notification-1") + + assert gateway.dismissals == [("notification-1", "account-1")] diff --git a/api/tests/unit_tests/services/test_recommended_app_catalog_gateway.py b/api/tests/unit_tests/services/test_recommended_app_catalog_gateway.py index bcd48c1b2d3..82a74d8d551 100644 --- a/api/tests/unit_tests/services/test_recommended_app_catalog_gateway.py +++ b/api/tests/unit_tests/services/test_recommended_app_catalog_gateway.py @@ -18,6 +18,7 @@ from services.recommended_app_query_service import ( RecommendedAppInfoRecord, RecommendedAppRecord, ) +from tests.unit_tests.config_override import apply_config_overrides def _page_payload(*app_ids: str, learn_dify_ids: frozenset[str] = frozenset()) -> dict[str, object]: @@ -224,7 +225,7 @@ class TestRemoteRecommendedAppCatalogGateway: @pytest.fixture(autouse=True) def _use_remote_mode(self, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: gateway_module.clear_remote_fetch_cache() - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="remote") yield gateway_module.clear_remote_fetch_cache() @@ -445,12 +446,11 @@ class TestRemoteRecommendedAppCatalogGateway: response.json.return_value = _detail_payload() http_get = MagicMock(return_value=response) monkeypatch.setattr(gateway_module.httpx, "get", http_get) - monkeypatch.setattr( - gateway_module.dify_config, - "HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN", - "https://catalog.example.com", + apply_config_overrides( + monkeypatch, + HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN="https://catalog.example.com", + CONSOLE_WEB_URL="https://console.example.com", ) - monkeypatch.setattr(gateway_module.dify_config, "CONSOLE_WEB_URL", "https://console.example.com") gateway = RemoteRecommendedAppCatalogGateway() gateway.get_detail("app-1") @@ -466,12 +466,11 @@ class TestRemoteRecommendedAppCatalogGateway: response.json.return_value = _page_payload() http_get = MagicMock(return_value=response) monkeypatch.setattr(gateway_module.httpx, "get", http_get) - monkeypatch.setattr( - gateway_module.dify_config, - "HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN", - "https://catalog.example.com", + apply_config_overrides( + monkeypatch, + HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN="https://catalog.example.com", + HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL=600, ) - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL", 600) gateway = RemoteRecommendedAppCatalogGateway() assert gateway.list_recommended("en-US") == _expected_page() @@ -482,7 +481,7 @@ class TestRemoteRecommendedAppCatalogGateway: response = MagicMock(status_code=500) http_get = MagicMock(return_value=response) monkeypatch.setattr(gateway_module.httpx, "get", http_get) - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL", 600) + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL=600) expected_page = _expected_page() fallback = MagicMock() fallback.list_recommended.return_value = expected_page @@ -501,7 +500,7 @@ class TestRemoteRecommendedAppCatalogGateway: response.json.return_value = _page_payload() http_get = MagicMock(return_value=response) monkeypatch.setattr(gateway_module.httpx, "get", http_get) - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL", 0) + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL=0) gateway = RemoteRecommendedAppCatalogGateway() gateway.list_recommended("en-US") @@ -516,11 +515,11 @@ class TestRemoteRecommendedAppCatalogGateway: response.json.return_value = _page_payload() http_get = MagicMock(return_value=response) monkeypatch.setattr(gateway_module.httpx, "get", http_get) - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL", 600) + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL=600) gateway = RemoteRecommendedAppCatalogGateway() - monkeypatch.setattr(gateway_module.dify_config, "CONSOLE_WEB_URL", "https://cloud-a.example.com") + apply_config_overrides(monkeypatch, CONSOLE_WEB_URL="https://cloud-a.example.com") gateway.list_recommended("en-US") - monkeypatch.setattr(gateway_module.dify_config, "CONSOLE_WEB_URL", "https://cloud-b.example.com") + apply_config_overrides(monkeypatch, CONSOLE_WEB_URL="https://cloud-b.example.com") gateway.list_recommended("en-US") assert http_get.call_count == 2 @@ -543,7 +542,7 @@ class TestRemoteRecommendedAppCatalogGateway: response.json.return_value = _detail_payload() http_get = MagicMock(return_value=response) monkeypatch.setattr(gateway_module.httpx, "get", http_get) - monkeypatch.setattr(gateway_module.dify_config, "CONSOLE_WEB_URL", console_web_url) + apply_config_overrides(monkeypatch, CONSOLE_WEB_URL=console_web_url) gateway = RemoteRecommendedAppCatalogGateway() gateway.get_detail("app-1") @@ -565,10 +564,9 @@ class TestRemoteRecommendedAppCatalogGateway: response = MagicMock(status_code=500) http_get = MagicMock(return_value=response) monkeypatch.setattr(gateway_module.httpx, "get", http_get) - monkeypatch.setattr( - gateway_module.dify_config, - "HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN", - "https://catalog.example.com", + apply_config_overrides( + monkeypatch, + HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN="https://catalog.example.com", ) fallback = MagicMock() database = MagicMock() @@ -603,7 +601,7 @@ class TestRecommendedAppCatalogRouter: database=MagicMock(), builtin=builtin, ) - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="remote") assert gateway.list_recommended("ja-JP") == expected_page remote.list_recommended.assert_called_once_with("ja-JP") @@ -619,13 +617,13 @@ class TestRecommendedAppCatalogRouter: builtin=builtin, ) - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="remote") gateway.list_recommended("en-US") - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "db") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="db") gateway.list_learn_dify("en-US") - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "builtin") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="builtin") gateway.get_detail("app-1") - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="remote") gateway.contains("app-1") remote.list_recommended.assert_called_once_with("en-US") @@ -643,7 +641,7 @@ class TestRecommendedAppCatalogRouter: database=database, builtin=builtin, ) - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "builtin") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="builtin") assert gateway.list_learn_dify("en-US") == expected_page builtin.list_learn_dify.assert_called_once_with("en-US") @@ -655,7 +653,7 @@ class TestRecommendedAppCatalogRouter: database=MagicMock(), builtin=MagicMock(), ) - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "invalid") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="invalid") with pytest.raises(ValueError, match="invalid fetch recommended apps mode: invalid"): gateway.list_recommended("en-US") diff --git a/api/tests/unit_tests/services/test_skill_management_service.py b/api/tests/unit_tests/services/test_skill_management_service.py index 3ce4a745b7b..8e9fd12529b 100644 --- a/api/tests/unit_tests/services/test_skill_management_service.py +++ b/api/tests/unit_tests/services/test_skill_management_service.py @@ -64,6 +64,7 @@ from services.skill_management_service import ( validate_skill_description, validate_skill_name, ) +from tests.unit_tests.config_override import apply_config_overrides TENANT = "11111111-1111-1111-1111-111111111111" AGENT = "22222222-2222-2222-2222-222222222222" @@ -2682,7 +2683,7 @@ def test_import_skill_package_rejects_missing_frontmatter_description() -> None: def test_import_skill_package_rejects_archive_larger_than_upload_skill_limit(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("services.skill_management_service.dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT", 0) + apply_config_overrides(monkeypatch, UPLOAD_SKILL_FILE_SIZE_LIMIT=0) service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) diff --git a/api/tests/unit_tests/services/test_snippet_dsl_service.py b/api/tests/unit_tests/services/test_snippet_dsl_service.py index f213dd95267..b1318b1edce 100644 --- a/api/tests/unit_tests/services/test_snippet_dsl_service.py +++ b/api/tests/unit_tests/services/test_snippet_dsl_service.py @@ -1,9 +1,15 @@ +import json from types import SimpleNamespace from unittest.mock import Mock import pytest +from sqlalchemy import event +from sqlalchemy.orm import Session from graphon.nodes import BuiltinNodeTypes +from models import Account, Tenant +from models.snippet import CustomizedSnippet, SnippetType +from models.workflow import Workflow, WorkflowType from services.snippet_dsl_service import ( ImportMode, ImportStatus, @@ -12,6 +18,62 @@ from services.snippet_dsl_service import ( _check_version_compatibility, ) +SQLITE_MODELS = (CustomizedSnippet,) +pytestmark = [ + pytest.mark.usefixtures("sqlite_session"), + pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True), +] + + +@pytest.fixture +def service(sqlite_session: Session) -> SnippetDslService: + """Create the service with a real caller-owned SQLite session.""" + return SnippetDslService(session=sqlite_session) + + +def _account(*, account_id: str = "account-1", tenant_id: str = "tenant-1") -> Account: + account = Account(name="Snippet author", email=f"{account_id}@example.com") + account.id = account_id + tenant = Tenant(name="Snippet workspace") + tenant.id = tenant_id + account._current_tenant = tenant + return account + + +def _snippet( + *, + snippet_id: str = "snippet-1", + tenant_id: str = "tenant-1", + name: str = "Snippet", + description: str | None = None, + snippet_type: SnippetType = SnippetType.NODE, + icon_info: dict | None = None, + input_fields: list[dict] | None = None, +) -> CustomizedSnippet: + return CustomizedSnippet( + id=snippet_id, + tenant_id=tenant_id, + name=name, + description=description, + type=snippet_type.value, + icon_info=icon_info, + input_fields=json.dumps(input_fields) if input_fields else None, + created_by="account-1", + ) + + +def _workflow(*, graph: dict | None = None) -> Workflow: + return Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="snippet-1", + type=WorkflowType.WORKFLOW, + version="draft", + graph=json.dumps(graph or {"nodes": [], "edges": []}), + _features="{}", + created_by="account-1", + ) + @pytest.mark.parametrize( ("version", "expected"), @@ -29,18 +91,14 @@ def test_check_version_compatibility_returns_pending_for_older_major() -> None: assert _check_version_compatibility("0.0.9") == ImportStatus.COMPLETED_WITH_WARNINGS -def test_import_snippet_rejects_invalid_mode(): - service = SnippetDslService(session=SimpleNamespace()) - +def test_import_snippet_rejects_invalid_mode(service: SnippetDslService): with pytest.raises(ValueError, match="Invalid import_mode"): - service.import_snippet(account=SimpleNamespace(current_tenant_id="tenant-1"), import_mode="bad-mode") + service.import_snippet(account=_account(), import_mode="bad-mode") -def test_import_snippet_requires_yaml_content(): - service = SnippetDslService(session=SimpleNamespace()) - +def test_import_snippet_requires_yaml_content(service: SnippetDslService): result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, ) @@ -48,11 +106,9 @@ def test_import_snippet_requires_yaml_content(): assert result.error == "yaml_content is required when import_mode is yaml-content" -def test_import_snippet_requires_yaml_url() -> None: - service = SnippetDslService(session=SimpleNamespace()) - +def test_import_snippet_requires_yaml_url(service: SnippetDslService) -> None: result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_URL.value, ) @@ -60,11 +116,9 @@ def test_import_snippet_requires_yaml_url() -> None: assert result.error == "yaml_url is required when import_mode is yaml-url" -def test_import_snippet_rejects_invalid_yaml_url_scheme() -> None: - service = SnippetDslService(session=SimpleNamespace()) - +def test_import_snippet_rejects_invalid_yaml_url_scheme(service: SnippetDslService) -> None: result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_URL.value, yaml_url="file:///tmp/snippet.yaml", ) @@ -73,15 +127,16 @@ def test_import_snippet_rejects_invalid_yaml_url_scheme() -> None: assert result.error == "Invalid URL scheme, only http and https are allowed" -def test_import_snippet_returns_failed_when_yaml_url_fetch_fails(monkeypatch: pytest.MonkeyPatch) -> None: - service = SnippetDslService(session=SimpleNamespace()) +def test_import_snippet_returns_failed_when_yaml_url_fetch_fails( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.setattr( "services.snippet_dsl_service.ssrf_proxy.get", Mock(return_value=SimpleNamespace(status_code=404, text="not found")), ) result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_URL.value, yaml_url="https://example.com/snippet.yaml", ) @@ -90,8 +145,9 @@ def test_import_snippet_returns_failed_when_yaml_url_fetch_fails(monkeypatch: py assert result.error == "Failed to fetch YAML from URL: 404" -def test_import_snippet_rejects_oversized_yaml_url_content(monkeypatch: pytest.MonkeyPatch) -> None: - service = SnippetDslService(session=SimpleNamespace()) +def test_import_snippet_rejects_oversized_yaml_url_content( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 3) monkeypatch.setattr( "services.snippet_dsl_service.ssrf_proxy.get", @@ -99,7 +155,7 @@ def test_import_snippet_rejects_oversized_yaml_url_content(monkeypatch: pytest.M ) result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_URL.value, yaml_url="https://example.com/snippet.yaml", ) @@ -108,8 +164,9 @@ def test_import_snippet_rejects_oversized_yaml_url_content(monkeypatch: pytest.M assert "YAML content size exceeds maximum limit" in result.error -def test_import_snippet_rejects_oversized_yaml_url_bytes_before_decode(monkeypatch: pytest.MonkeyPatch) -> None: - service = SnippetDslService(session=SimpleNamespace()) +def test_import_snippet_rejects_oversized_yaml_url_bytes_before_decode( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 1) monkeypatch.setattr( "services.snippet_dsl_service.ssrf_proxy.get", @@ -117,7 +174,7 @@ def test_import_snippet_rejects_oversized_yaml_url_bytes_before_decode(monkeypat ) result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_URL.value, yaml_url="https://example.com/snippet.yaml", ) @@ -127,16 +184,15 @@ def test_import_snippet_rejects_oversized_yaml_url_bytes_before_decode(monkeypat def test_import_snippet_returns_decode_error_for_invalid_yaml_url_bytes( - monkeypatch: pytest.MonkeyPatch, + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch ) -> None: - service = SnippetDslService(session=SimpleNamespace()) monkeypatch.setattr( "services.snippet_dsl_service.ssrf_proxy.get", Mock(return_value=SimpleNamespace(status_code=200, content=b"\xff")), ) result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_URL.value, yaml_url="https://example.com/snippet.yaml", ) @@ -145,15 +201,16 @@ def test_import_snippet_returns_decode_error_for_invalid_yaml_url_bytes( assert "utf-8" in result.error -def test_import_snippet_returns_failed_when_yaml_url_fetch_raises(monkeypatch: pytest.MonkeyPatch) -> None: - service = SnippetDslService(session=SimpleNamespace()) +def test_import_snippet_returns_failed_when_yaml_url_fetch_raises( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.setattr( "services.snippet_dsl_service.ssrf_proxy.get", Mock(side_effect=RuntimeError("network down")), ) result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_URL.value, yaml_url="https://example.com/snippet.yaml", ) @@ -162,12 +219,13 @@ def test_import_snippet_returns_failed_when_yaml_url_fetch_raises(monkeypatch: p assert result.error == "Failed to fetch YAML from URL: network down" -def test_import_snippet_rejects_oversized_yaml_content(monkeypatch: pytest.MonkeyPatch) -> None: - service = SnippetDslService(session=SimpleNamespace()) +def test_import_snippet_rejects_oversized_yaml_content( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 1) result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, yaml_content="é", ) @@ -188,11 +246,9 @@ def test_import_snippet_rejects_oversized_yaml_content(monkeypatch: pytest.Monke ("version: 0.1.0\nkind: snippet\n", "Missing snippet data in YAML content"), ], ) -def test_import_snippet_rejects_invalid_yaml_shapes(yaml_content, expected_error) -> None: - service = SnippetDslService(session=SimpleNamespace()) - +def test_import_snippet_rejects_invalid_yaml_shapes(service: SnippetDslService, yaml_content, expected_error) -> None: result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, yaml_content=yaml_content, ) @@ -201,11 +257,9 @@ def test_import_snippet_rejects_invalid_yaml_shapes(yaml_content, expected_error assert expected_error in result.error -def test_import_snippet_returns_failed_for_invalid_version_type() -> None: - service = SnippetDslService(session=SimpleNamespace(rollback=Mock())) - +def test_import_snippet_returns_failed_for_invalid_version_type(service: SnippetDslService) -> None: result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, yaml_content="version: 1\nkind: snippet\nsnippet:\n name: Bad Version\n", ) @@ -214,11 +268,9 @@ def test_import_snippet_returns_failed_for_invalid_version_type() -> None: assert "Invalid version type" in result.error -def test_import_snippet_returns_failed_for_invalid_yaml_syntax() -> None: - service = SnippetDslService(session=SimpleNamespace()) - +def test_import_snippet_returns_failed_for_invalid_yaml_syntax(service: SnippetDslService) -> None: result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, yaml_content="kind: snippet\nsnippet: [", ) @@ -227,8 +279,7 @@ def test_import_snippet_returns_failed_for_invalid_yaml_syntax() -> None: assert result.error.startswith("Invalid YAML format:") -def test_import_snippet_rejects_forbidden_nodes(): - service = SnippetDslService(session=SimpleNamespace()) +def test_import_snippet_rejects_forbidden_nodes(service: SnippetDslService): yaml_content = """ version: 0.3.0 kind: snippet @@ -244,7 +295,7 @@ workflow: """ result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, yaml_content=yaml_content, ) @@ -253,8 +304,7 @@ workflow: assert result.error == "Snippet cannot contain the following node types: start" -def test_import_snippet_stores_pending_data_for_newer_dsl(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None))) +def test_import_snippet_stores_pending_data_for_newer_dsl(service: SnippetDslService, monkeypatch: pytest.MonkeyPatch): setex = Mock() monkeypatch.setattr("services.snippet_dsl_service.redis_client.setex", setex) yaml_content = """ @@ -269,7 +319,7 @@ workflow: """ result = service.import_snippet( - account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, yaml_content=yaml_content, name="Override", @@ -286,8 +336,7 @@ workflow: assert pending.description == "Override description" -def test_import_snippet_returns_failed_when_update_target_missing(): - service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None))) +def test_import_snippet_returns_failed_when_update_target_missing(service: SnippetDslService): yaml_content = """ version: 0.1.0 kind: snippet @@ -300,7 +349,7 @@ workflow: """ result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, yaml_content=yaml_content, snippet_id="missing-snippet", @@ -310,9 +359,10 @@ workflow: assert result.error == "Snippet not found" -def test_import_snippet_passes_dependencies_to_create_or_update(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None))) - snippet = SimpleNamespace(id="snippet-1") +def test_import_snippet_passes_dependencies_to_create_or_update( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +): + snippet = _snippet() create_or_update = Mock(return_value=snippet) monkeypatch.setattr(service, "_create_or_update_snippet", create_or_update) yaml_content = """ @@ -331,7 +381,7 @@ workflow: """ result = service.import_snippet( - account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, yaml_content=yaml_content, ) @@ -342,50 +392,50 @@ workflow: assert dependencies[0].value.plugin_unique_identifier == "langgenius/openai:0.0.1" -def test_import_snippet_rolls_back_when_create_or_update_raises(monkeypatch: pytest.MonkeyPatch): - session = SimpleNamespace(scalar=Mock(return_value=None), rollback=Mock()) - service = SnippetDslService(session=session) +def test_import_snippet_rolls_back_when_create_or_update_raises( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +): + rollback_events: list[str] = [] + event.listen(sqlite_session, "after_rollback", lambda _session: rollback_events.append("rollback")) + sqlite_session.begin() monkeypatch.setattr(service, "_create_or_update_snippet", Mock(side_effect=RuntimeError("boom"))) result = service.import_snippet( - account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, yaml_content="version: 0.1.0\nkind: snippet\nsnippet:\n name: Bad\n", ) assert result.status == ImportStatus.FAILED assert result.error == "boom" - session.rollback.assert_called_once() + assert rollback_events == ["rollback"] -def test_confirm_import_returns_failed_when_pending_data_missing(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace()) +def test_confirm_import_returns_failed_when_pending_data_missing( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +): monkeypatch.setattr("services.snippet_dsl_service.redis_client.get", Mock(return_value=None)) - result = service.confirm_import( - import_id="missing", account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1") - ) + result = service.confirm_import(import_id="missing", account=_account()) assert result.status == ImportStatus.FAILED assert result.error == "Import information expired or does not exist" -def test_confirm_import_returns_failed_for_invalid_pending_payload(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace()) +def test_confirm_import_returns_failed_for_invalid_pending_payload( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +): monkeypatch.setattr("services.snippet_dsl_service.redis_client.get", Mock(return_value=object())) - result = service.confirm_import( - import_id="bad", account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1") - ) + result = service.confirm_import(import_id="bad", account=_account()) assert result.status == ImportStatus.FAILED assert result.error == "Invalid import information" -def test_confirm_import_is_scoped_to_its_owner(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None))) - account = SimpleNamespace(id="account-1", current_tenant_id="tenant-1") - snippet = SimpleNamespace(id="snippet-new") +def test_confirm_import_is_scoped_to_its_owner(service: SnippetDslService, monkeypatch: pytest.MonkeyPatch): + account = _account() + snippet = _snippet(snippet_id="snippet-new") yaml_content = """ version: 9.0.0 kind: snippet @@ -417,8 +467,8 @@ workflow: monkeypatch.setattr("services.snippet_dsl_service.redis_client.delete", redis_delete) for other_account in ( - SimpleNamespace(id="account-1", current_tenant_id="tenant-2"), - SimpleNamespace(id="account-2", current_tenant_id="tenant-1"), + _account(tenant_id="tenant-2"), + _account(account_id="account-2"), ): assert service.confirm_import(import_id="import-1", account=other_account).status == ImportStatus.FAILED @@ -437,8 +487,9 @@ workflow: redis_delete.assert_called_once_with(redis_key) -def test_confirm_import_returns_failed_for_non_mapping_yaml(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace()) +def test_confirm_import_returns_failed_for_non_mapping_yaml( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +): pending = SnippetPendingData( import_mode="yaml-content", yaml_content="- item", @@ -446,17 +497,17 @@ def test_confirm_import_returns_failed_for_non_mapping_yaml(monkeypatch: pytest. ) monkeypatch.setattr("services.snippet_dsl_service.redis_client.get", Mock(return_value=pending.model_dump_json())) - result = service.confirm_import( - import_id="import-1", account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1") - ) + result = service.confirm_import(import_id="import-1", account=_account()) assert result.status == ImportStatus.FAILED assert result.error == "Invalid YAML format: expected a dictionary" -def test_confirm_import_returns_failed_when_create_or_update_raises(monkeypatch: pytest.MonkeyPatch): - session = SimpleNamespace(scalar=Mock(return_value=None), rollback=Mock()) - service = SnippetDslService(session=session) +def test_confirm_import_returns_failed_when_create_or_update_raises( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +): + rollback_events: list[str] = [] + event.listen(sqlite_session, "after_rollback", lambda _session: rollback_events.append("rollback")) pending = SnippetPendingData( import_mode="yaml-content", yaml_content="version: 0.1.0\nkind: snippet\nsnippet:\n name: Bad\n", @@ -467,29 +518,29 @@ def test_confirm_import_returns_failed_when_create_or_update_raises(monkeypatch: result = service.confirm_import( import_id="import-1", - account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1"), + account=_account(), ) assert result.status == ImportStatus.FAILED assert result.error == "boom" - session.rollback.assert_called_once() + assert rollback_events == ["rollback"] -def test_check_dependencies_returns_empty_without_draft_workflow(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace(get_bind=Mock())) +def test_check_dependencies_returns_empty_without_draft_workflow( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +): monkeypatch.setattr( "services.snippet_dsl_service.SnippetService", lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=None)), ) - result = service.check_dependencies(SimpleNamespace(id="snippet-1", tenant_id="tenant-1")) + result = service.check_dependencies(_snippet()) assert result.leaked_dependencies == [] -def test_check_dependencies_returns_generated_dependencies(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace(get_bind=Mock())) - workflow = SimpleNamespace(graph_dict={"nodes": []}) +def test_check_dependencies_returns_generated_dependencies(service: SnippetDslService, monkeypatch: pytest.MonkeyPatch): + workflow = _workflow() leaked_dependencies = [ { "type": "marketplace", @@ -506,29 +557,25 @@ def test_check_dependencies_returns_generated_dependencies(monkeypatch: pytest.M Mock(return_value=leaked_dependencies), ) - result = service.check_dependencies(SimpleNamespace(id="snippet-1", tenant_id="tenant-1")) + result = service.check_dependencies(_snippet()) assert result.leaked_dependencies[0].value.plugin_unique_identifier == "langgenius/openai:0.0.1" -def test_create_or_update_snippet_updates_existing_snippet_and_syncs_workflow(monkeypatch: pytest.MonkeyPatch): - snippet = SimpleNamespace( - id="snippet-1", - tenant_id="tenant-1", +def test_create_or_update_snippet_updates_existing_snippet_and_syncs_workflow( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +): + snippet = _snippet( name="Old", description="Old", - type="node", icon_info=None, - input_fields=None, - updated_by=None, - updated_at=None, ) - session = SimpleNamespace(add=Mock(), flush=Mock(), commit=Mock(), get_bind=Mock()) - service = SnippetDslService(session=session) - draft_workflow = SimpleNamespace(unique_hash="hash-1") + sqlite_session.add(snippet) + sqlite_session.commit() + draft_workflow = _workflow() snippet_service = SimpleNamespace( get_draft_workflow=Mock(return_value=draft_workflow), - sync_draft_workflow=Mock(), + sync_draft_workflow=Mock(return_value=draft_workflow), ) monkeypatch.setattr("services.snippet_dsl_service.SnippetService", lambda *_args, **_kwargs: snippet_service) monkeypatch.setattr( @@ -557,7 +604,7 @@ def test_create_or_update_snippet_updates_existing_snippet_and_syncs_workflow(mo }, "workflow": {"graph": {"nodes": [], "edges": []}}, }, - account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1"), + account=_account(), ) assert result is snippet @@ -565,7 +612,10 @@ def test_create_or_update_snippet_updates_existing_snippet_and_syncs_workflow(mo assert snippet.type == "node" assert snippet.icon_info == {"icon": "x"} snippet_service.sync_draft_workflow.assert_called_once() - session.commit.assert_called_once() + assert not sqlite_session.in_transaction() + persisted = sqlite_session.get(CustomizedSnippet, snippet.id) + assert persisted is not None + assert persisted.name == "New" retire_unowned.assert_called_once_with( tenant_id="tenant-1", agent_ids={"retired-agent"}, @@ -573,10 +623,13 @@ def test_create_or_update_snippet_updates_existing_snippet_and_syncs_workflow(mo ) -def test_create_or_update_snippet_creates_new_snippet_and_flushes(monkeypatch: pytest.MonkeyPatch): - session = SimpleNamespace(add=Mock(), flush=Mock(), commit=Mock(), get_bind=Mock()) - service = SnippetDslService(session=session) - snippet_service = SimpleNamespace(get_draft_workflow=Mock(return_value=None), sync_draft_workflow=Mock()) +def test_create_or_update_snippet_creates_new_snippet_and_flushes( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +): + snippet_service = SimpleNamespace( + get_draft_workflow=Mock(return_value=None), + sync_draft_workflow=Mock(return_value=_workflow()), + ) monkeypatch.setattr("services.snippet_dsl_service.SnippetService", lambda *_args, **_kwargs: snippet_service) monkeypatch.setattr( "services.snippet_dsl_service.WorkflowAgentPublishService.sync_agent_bindings_for_draft", @@ -598,41 +651,33 @@ def test_create_or_update_snippet_creates_new_snippet_and_flushes(monkeypatch: p }, "workflow": {"graph": {"nodes": [], "edges": []}}, }, - account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1"), + account=_account(), ) assert result.name == "New Snippet" assert result.type == "group" - session.add.assert_called_once_with(result) - session.flush.assert_called_once() + assert sqlite_session.get(CustomizedSnippet, result.id) is result snippet_service.sync_draft_workflow.assert_called_once() - session.commit.assert_called_once() + assert not sqlite_session.in_transaction() -def test_export_snippet_dsl_raises_without_draft_workflow(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace(get_bind=Mock())) +def test_export_snippet_dsl_raises_without_draft_workflow(service: SnippetDslService, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "services.snippet_dsl_service.SnippetService", lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=None)), ) with pytest.raises(ValueError, match="Missing draft workflow"): - service.export_snippet_dsl(SimpleNamespace()) + service.export_snippet_dsl(_snippet()) -def test_export_snippet_dsl_returns_yaml(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace(get_bind=Mock())) - workflow = SimpleNamespace( - to_dict=Mock(return_value={"graph": {"nodes": []}}), - graph_dict={"nodes": []}, - ) - snippet = SimpleNamespace( - tenant_id="tenant-1", +def test_export_snippet_dsl_returns_yaml(service: SnippetDslService, monkeypatch: pytest.MonkeyPatch): + workflow = _workflow() + snippet = _snippet( name="Exported", description=None, - type="node", icon_info=None, - input_fields_list=[{"variable": "query"}], + input_fields=[{"variable": "query"}], ) monkeypatch.setattr( "services.snippet_dsl_service.SnippetService", @@ -650,20 +695,11 @@ def test_export_snippet_dsl_returns_yaml(monkeypatch: pytest.MonkeyPatch): assert "input_fields:" in result -def test_export_snippet_dsl_uses_requested_published_workflow(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace(get_bind=Mock())) - workflow = SimpleNamespace( - to_dict=Mock(return_value={"graph": {"nodes": []}}), - graph_dict={"nodes": []}, - ) - snippet = SimpleNamespace( - tenant_id="tenant-1", - name="Exported", - description=None, - type="node", - icon_info=None, - input_fields_list=[], - ) +def test_export_snippet_dsl_uses_requested_published_workflow( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +): + workflow = _workflow(graph={"nodes": [], "edges": []}) + snippet = _snippet(name="Exported") get_published_workflow_by_id = Mock(return_value=workflow) get_draft_workflow = Mock() monkeypatch.setattr( @@ -684,8 +720,9 @@ def test_export_snippet_dsl_uses_requested_published_workflow(monkeypatch: pytes get_draft_workflow.assert_not_called() -def test_append_workflow_export_data_filters_credentials_and_extracts_dependencies(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace()) +def test_append_workflow_export_data_filters_credentials_and_extracts_dependencies( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +): workflow_dict = { "graph": { "nodes": [ @@ -718,10 +755,7 @@ def test_append_workflow_export_data_filters_credentials_and_extracts_dependenci "environment_variables": [{"name": "SECRET"}], "conversation_variables": [{"name": "memory"}], } - workflow = SimpleNamespace( - to_dict=Mock(return_value=workflow_dict), - graph_dict=workflow_dict["graph"], - ) + workflow = _workflow(graph=workflow_dict["graph"]) monkeypatch.setattr( "services.snippet_dsl_service.DependenciesAnalysisService.generate_dependencies", Mock(return_value=[]), @@ -730,7 +764,7 @@ def test_append_workflow_export_data_filters_credentials_and_extracts_dependenci service._append_workflow_export_data( export_data=export_data, - snippet=SimpleNamespace(tenant_id="tenant-1"), + snippet=_snippet(), workflow=workflow, include_secret=False, ) @@ -742,8 +776,9 @@ def test_append_workflow_export_data_filters_credentials_and_extracts_dependenci assert "credential_id" not in nodes[2]["data"]["agent_parameters"]["tools"]["value"][0] -def test_append_workflow_export_data_rewrites_knowledge_dataset_ids(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace()) +def test_append_workflow_export_data_rewrites_knowledge_dataset_ids( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +): workflow_dict = { "graph": { "nodes": [ @@ -756,7 +791,7 @@ def test_append_workflow_export_data_rewrites_knowledge_dataset_ids(monkeypatch: ] }, } - workflow = SimpleNamespace(to_dict=Mock(return_value=workflow_dict), graph_dict=workflow_dict["graph"]) + workflow = _workflow(graph=workflow_dict["graph"]) monkeypatch.setattr( service, "_encrypt_dataset_id", @@ -770,7 +805,7 @@ def test_append_workflow_export_data_rewrites_knowledge_dataset_ids(monkeypatch: service._append_workflow_export_data( export_data=export_data, - snippet=SimpleNamespace(tenant_id="tenant-1"), + snippet=_snippet(), workflow=workflow, include_secret=True, ) diff --git a/api/tests/unit_tests/services/test_snippet_service.py b/api/tests/unit_tests/services/test_snippet_service.py index caac531bdbc..10c1622ab6e 100644 --- a/api/tests/unit_tests/services/test_snippet_service.py +++ b/api/tests/unit_tests/services/test_snippet_service.py @@ -940,15 +940,14 @@ def test_delete_draft_variable_files_removes_storage_objects( def test_delete_archived_workflow_run_files_removes_prefixed_objects(monkeypatch: pytest.MonkeyPatch) -> None: - from configs import dify_config + from tests.unit_tests.config_override import apply_config_overrides snippet = _snippet() archive_storage = SimpleNamespace( list_objects=Mock(return_value=["tenant-1/app_id=snippet-1/run.json"]), delete_object=Mock(), ) - monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) - monkeypatch.setattr(dify_config, "ARCHIVE_STORAGE_ENABLED", True) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, ARCHIVE_STORAGE_ENABLED=True) monkeypatch.setattr("libs.archive_storage.get_archive_storage", Mock(return_value=archive_storage)) SnippetService._delete_archived_workflow_run_files(snippet=snippet) diff --git a/api/tests/unit_tests/services/test_step_by_step_tour_service.py b/api/tests/unit_tests/services/test_step_by_step_tour_service.py index 94203183ef6..40017bb7798 100644 --- a/api/tests/unit_tests/services/test_step_by_step_tour_service.py +++ b/api/tests/unit_tests/services/test_step_by_step_tour_service.py @@ -1,227 +1,213 @@ from __future__ import annotations -from datetime import UTC, datetime +from collections.abc import Callable +from dataclasses import replace +from datetime import datetime +from unittest.mock import Mock import pytest -from sqlalchemy import event, select -from sqlalchemy.orm import Session, sessionmaker -from enums import DeploymentEdition -from models.account import Account, AccountStatus -from models.onboarding import AccountStepByStepTourState -from services import step_by_step_tour_service as service_module +from machinery.context import RequestContext +from services.account_ports import AccountRepository +from services.entities.account_entities import AccountSnapshot +from services.entities.onboarding_entities import StepByStepTourPatch, StepByStepTourResult, StepByStepTourState from services.step_by_step_tour_service import StepByStepTourService -def _account(*, initialized_at: datetime | None = None, created_at: datetime | None = None) -> Account: - account = Account(name="User", email="user@example.com", status=AccountStatus.ACTIVE) - account.id = "account-1" - account.initialized_at = initialized_at - account.created_at = created_at or datetime(2026, 6, 28) - return account - - -def _state() -> AccountStepByStepTourState: - state = AccountStepByStepTourState(account_id="account-1") - state.updated_at = datetime(2026, 6, 28, tzinfo=UTC) - return state - - -def _persist_state(session: Session, state: AccountStepByStepTourState) -> None: - session.add(state) - session.commit() - - -def _load_state(session: Session) -> AccountStepByStepTourState | None: - return session.scalar( - select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == "account-1") +def _context(*, workspace_id: str | None = "workspace-1") -> RequestContext: + return RequestContext( + request_id="request-1", + trace_id="trace-1", + account_id="account-1", + active_workspace_id=workspace_id, ) -def _set_tour_config(monkeypatch: pytest.MonkeyPatch, *, enabled: bool, rollout_started_at: datetime | None) -> None: - monkeypatch.setattr(service_module.dify_config, "ENABLE_STEP_BY_STEP_TOUR", enabled) - monkeypatch.setattr(service_module.dify_config, "STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT", rollout_started_at) +class StateRepositoryStub: + def __init__(self, state: StepByStepTourState | None = None) -> None: + self.state = state + self.get_account_ids: list[str] = [] + self.initialize_calls: list[tuple[str, str]] = [] + self.mutation_account_ids: list[str] = [] + + def get(self, account_id: str) -> StepByStepTourState | None: + self.get_account_ids.append(account_id) + return self.state + + def initialize(self, account_id: str, first_workspace_id: str) -> StepByStepTourState: + self.initialize_calls.append((account_id, first_workspace_id)) + if self.state is None: + self.state = StepByStepTourState(account_id=account_id, first_workspace_id=first_workspace_id) + elif self.state.first_workspace_id is None: + self.state = replace(self.state, first_workspace_id=first_workspace_id) + return self.state + + def mutate( + self, + account_id: str, + mutation: Callable[[StepByStepTourState], StepByStepTourState], + ) -> StepByStepTourState: + self.mutation_account_ids.append(account_id) + if self.state is None: + self.state = StepByStepTourState(account_id=account_id) + self.state = mutation(self.state) + return self.state -def test_get_state_creates_state_and_records_first_workspace_for_eligible_account( - monkeypatch: pytest.MonkeyPatch, - sqlite_session: Session, - sqlite_session_factory: sessionmaker[Session], -) -> None: - _set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1)) - - result = StepByStepTourService.get_state( - account=_account(initialized_at=datetime(2026, 6, 28)), - current_tenant_id="workspace-1", - session=sqlite_session, +def _account(*, started_at: datetime = datetime(2026, 6, 28)) -> AccountSnapshot: + return AccountSnapshot( + id="account-1", + name="Account", + email="account@example.com", + avatar=None, + is_password_set=False, + interface_language="en-US", + interface_theme="light", + timezone="UTC", + last_login_at=None, + last_login_ip=None, + status="active", + initialized_at=started_at, + created_at=started_at, ) - assert result["first_workspace_id"] == "workspace-1" - assert result["completed_task_ids"] == [] - with sqlite_session_factory() as observer: - persisted = _load_state(observer) - assert persisted is not None - assert persisted.account_id == "account-1" - assert persisted.first_workspace_id == "workspace-1" + +def _accounts(account: AccountSnapshot | None) -> Mock: + accounts = Mock(spec=AccountRepository) + accounts.get.return_value = account + return accounts -def test_is_eligible_does_not_depend_on_cloud_edition(monkeypatch: pytest.MonkeyPatch) -> None: - _set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1)) - monkeypatch.setattr(service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) - - result = StepByStepTourService.is_eligible(_account(initialized_at=datetime(2026, 6, 28))) - - assert result is True - - -def test_get_state_does_not_create_state_for_ineligible_account_without_existing_state( - monkeypatch: pytest.MonkeyPatch, - sqlite_session: Session, - sqlite_session_factory: sessionmaker[Session], -) -> None: - _set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1)) - - result = StepByStepTourService.get_state( - account=_account(initialized_at=datetime(2026, 5, 31)), - current_tenant_id="workspace-1", - session=sqlite_session, +def _service( + *, + states: StateRepositoryStub, + account: AccountSnapshot | None = None, + enabled: bool = True, + rollout_started_at: datetime | None = datetime(2026, 6, 1), +) -> StepByStepTourService: + return StepByStepTourService( + accounts=_accounts(account or _account()), + states=states, + enabled=enabled, + rollout_started_at=rollout_started_at, ) - assert result == { - "first_workspace_id": None, - "skipped": False, - "completed_task_ids": [], - "manually_enabled_workspace_ids": [], - "manually_disabled_workspace_ids": [], - "updated_at": None, - } - with sqlite_session_factory() as observer: - assert _load_state(observer) is None + +def test_get_state_creates_state_and_records_first_workspace_for_eligible_account() -> None: + states = StateRepositoryStub() + + result = _service(states=states).get_state(_context()) + + assert result.first_workspace_id == "workspace-1" + assert states.get_account_ids == [] + assert states.initialize_calls == [("account-1", "workspace-1")] + assert states.mutation_account_ids == [] -def test_patch_state_persists_even_when_account_is_not_eligible( - monkeypatch: pytest.MonkeyPatch, - sqlite_session: Session, - sqlite_session_factory: sessionmaker[Session], -) -> None: - _set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1)) +def test_get_state_returns_existing_state_without_rewriting_first_workspace() -> None: + state = StepByStepTourState(account_id="account-1", first_workspace_id="workspace-original") + states = StateRepositoryStub(state) - result = StepByStepTourService.patch_state( - account=_account(initialized_at=datetime(2026, 6, 28)), - current_tenant_id="workspace-2", - patch={"action": "enable_current_workspace"}, - session=sqlite_session, + result = _service(states=states).get_state(_context(workspace_id="workspace-current")) + + assert result.first_workspace_id == "workspace-original" + assert states.initialize_calls == [("account-1", "workspace-current")] + assert states.mutation_account_ids == [] + + +def test_get_state_does_not_create_state_for_ineligible_account() -> None: + states = StateRepositoryStub() + service = _service(states=states, account=_account(started_at=datetime(2026, 5, 31))) + + result = service.get_state(_context()) + + assert result == StepByStepTourResult() + assert states.get_account_ids == ["account-1"] + assert states.mutation_account_ids == [] + + +def test_get_state_does_not_create_state_when_tour_is_disabled() -> None: + states = StateRepositoryStub() + + result = _service(states=states, enabled=False).get_state(_context()) + + assert result == StepByStepTourResult() + assert states.get_account_ids == ["account-1"] + + +def test_patch_state_persists_even_when_tour_is_disabled() -> None: + states = StateRepositoryStub() + service = _service(states=states, enabled=False) + + result = service.patch_state(_context(workspace_id="workspace-2"), StepByStepTourPatch("enable_current_workspace")) + + assert result.manually_enabled_workspace_ids == ("workspace-2",) + assert states.mutation_account_ids == ["account-1"] + + +def test_patch_state_skip_removes_current_workspace_enable() -> None: + states = StateRepositoryStub( + StepByStepTourState( + account_id="account-1", + manually_enabled_workspace_ids=("workspace-1", "workspace-2"), + ) ) - assert result["skipped"] is False - assert result["manually_enabled_workspace_ids"] == ["workspace-2"] - assert result["manually_disabled_workspace_ids"] == [] - with sqlite_session_factory() as observer: - persisted = _load_state(observer) - assert persisted is not None - assert persisted.manually_enabled_workspace_ids == ["workspace-2"] + result = _service(states=states).patch_state(_context(), StepByStepTourPatch("skip")) + + assert result.skipped is True + assert result.manually_enabled_workspace_ids == ("workspace-2",) -def test_patch_state_skip_action_sets_skipped_and_removes_current_workspace_enable( - monkeypatch: pytest.MonkeyPatch, - sqlite_session: Session, -) -> None: - _set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1)) - state = _state() - state.manually_enabled_workspace_ids = ["workspace-1", "workspace-2"] - _persist_state(sqlite_session, state) - - result = StepByStepTourService.patch_state( - account=_account(initialized_at=datetime(2026, 6, 28)), - current_tenant_id="workspace-1", - patch={"action": "skip"}, - session=sqlite_session, +def test_patch_state_disable_moves_current_workspace_to_disabled() -> None: + states = StateRepositoryStub( + StepByStepTourState( + account_id="account-1", + manually_enabled_workspace_ids=("workspace-1", "workspace-2"), + ) ) - assert result["skipped"] is True - assert result["manually_enabled_workspace_ids"] == ["workspace-2"] - assert result["manually_disabled_workspace_ids"] == [] - assert _load_state(sqlite_session) is state - - -def test_patch_state_disable_action_moves_current_workspace_to_disabled( - monkeypatch: pytest.MonkeyPatch, - sqlite_session: Session, -) -> None: - _set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1)) - state = _state() - state.manually_enabled_workspace_ids = ["workspace-1", "workspace-2"] - _persist_state(sqlite_session, state) - - result = StepByStepTourService.patch_state( - account=_account(initialized_at=datetime(2026, 6, 28)), - current_tenant_id="workspace-1", - patch={"action": "disable_current_workspace"}, - session=sqlite_session, + result = _service(states=states).patch_state( + _context(), + StepByStepTourPatch("disable_current_workspace"), ) - assert result["manually_enabled_workspace_ids"] == ["workspace-2"] - assert result["manually_disabled_workspace_ids"] == ["workspace-1"] - assert _load_state(sqlite_session) is state + assert result.manually_enabled_workspace_ids == ("workspace-2",) + assert result.manually_disabled_workspace_ids == ("workspace-1",) -def test_patch_state_complete_and_uncomplete_task( - monkeypatch: pytest.MonkeyPatch, - sqlite_session: Session, -) -> None: - _set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1)) - state = _state() - state.completed_task_ids = ["home"] - _persist_state(sqlite_session, state) +def test_patch_state_complete_and_uncomplete_task() -> None: + states = StateRepositoryStub(StepByStepTourState(account_id="account-1", completed_task_ids=("home",))) + service = _service(states=states) - StepByStepTourService.patch_state( - account=_account(initialized_at=datetime(2026, 6, 28)), - current_tenant_id="workspace-1", - patch={"action": "complete_task", "task_id": "studio"}, - session=sqlite_session, - ) - result = StepByStepTourService.patch_state( - account=_account(initialized_at=datetime(2026, 6, 28)), - current_tenant_id="workspace-1", - patch={"action": "uncomplete_task", "task_id": "home"}, - session=sqlite_session, + service.patch_state(_context(), StepByStepTourPatch("complete_task", "studio")) + result = service.patch_state(_context(), StepByStepTourPatch("uncomplete_task", "home")) + + assert result.completed_task_ids == ("studio",) + + +def test_rejects_unsupported_task_id() -> None: + with pytest.raises(ValueError, match="Unsupported task_id"): + StepByStepTourService._require_task_id("unknown") + + +def test_rejects_missing_workspace_before_using_state_repository() -> None: + states = StateRepositoryStub() + + with pytest.raises(RuntimeError, match="did not resolve an active workspace"): + _service(states=states).patch_state(_context(workspace_id=None), StepByStepTourPatch("skip")) + + assert states.mutation_account_ids == [] + + +def test_get_state_rejects_unknown_admitted_account() -> None: + states = StateRepositoryStub() + service = StepByStepTourService( + accounts=_accounts(None), + states=states, + enabled=True, + rollout_started_at=datetime(2026, 6, 1), ) - assert result["completed_task_ids"] == ["studio"] - - -def test_patch_state_recovers_when_concurrent_request_created_state( - monkeypatch: pytest.MonkeyPatch, - sqlite_session: Session, - sqlite_session_factory: sessionmaker[Session], -) -> None: - _set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1)) - existing_state = _state() - existing_state.manually_enabled_workspace_ids = ["workspace-1"] - lifecycle_events: list[str] = [] - - @event.listens_for(sqlite_session, "before_flush", once=True) - def add_conflicting_pending_state(session: Session, _flush_context, _instances) -> None: - lifecycle_events.append("before_flush") - session.add(AccountStepByStepTourState(account_id="account-1")) - - @event.listens_for(sqlite_session, "after_soft_rollback", once=True) - def persist_winning_request(_session: Session, _previous_transaction) -> None: - lifecycle_events.append("after_soft_rollback") - with sqlite_session_factory() as winner: - winner.add(existing_state) - winner.commit() - - result = StepByStepTourService.patch_state( - account=_account(initialized_at=datetime(2026, 6, 28)), - current_tenant_id="workspace-2", - patch={"action": "enable_current_workspace"}, - session=sqlite_session, - ) - - assert result["manually_enabled_workspace_ids"] == ["workspace-1", "workspace-2"] - assert lifecycle_events == ["before_flush", "after_soft_rollback"] - with sqlite_session_factory() as observer: - persisted = _load_state(observer) - assert persisted is not None - assert persisted.manually_enabled_workspace_ids == ["workspace-1", "workspace-2"] + with pytest.raises(RuntimeError, match="unknown account"): + service.get_state(_context()) diff --git a/api/tests/unit_tests/services/test_telemetry_service.py b/api/tests/unit_tests/services/test_telemetry_service.py index d931be80364..8b11e3a2ff7 100644 --- a/api/tests/unit_tests/services/test_telemetry_service.py +++ b/api/tests/unit_tests/services/test_telemetry_service.py @@ -1,4 +1,5 @@ import uuid +from collections.abc import Callable from datetime import datetime from unittest.mock import Mock @@ -14,22 +15,20 @@ from services.telemetry_service import CommunityTelemetryService @pytest.fixture -def telemetry_enabled(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(telemetry_service.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) - monkeypatch.setattr(telemetry_service.dify_config, "DISABLE_TELEMETRY", False) - monkeypatch.setattr(telemetry_service.dify_config, "DO_NOT_TRACK", False) - monkeypatch.setattr(telemetry_service.dify_config, "CI", False) - monkeypatch.setattr(telemetry_service.dify_config, "TELEMETRY_ENDPOINT", "https://telemetry.example.test/v1/events") - monkeypatch.setattr( - telemetry_service.dify_config, - "TELEMETRY_FALLBACK_ENDPOINT", - "https://telemetry-cn.example.test/v1/events", +def telemetry_enabled(config_overrides: Callable[..., None]) -> None: + config_overrides( + DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, + DISABLE_TELEMETRY=False, + DO_NOT_TRACK=False, + CI=False, + TELEMETRY_ENDPOINT="https://telemetry.example.test/v1/events", + TELEMETRY_FALLBACK_ENDPOINT="https://telemetry-cn.example.test/v1/events", + TELEMETRY_TIMEOUT_SECONDS=2, ) - monkeypatch.setattr(telemetry_service.dify_config, "TELEMETRY_TIMEOUT_SECONDS", 2) -def test_telemetry_is_disabled_for_enterprise(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(telemetry_service.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) +def test_telemetry_is_disabled_for_enterprise(config_overrides: Callable[..., None]): + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) assert CommunityTelemetryService._is_enabled() is False @@ -45,9 +44,9 @@ def test_telemetry_is_disabled_for_enterprise(monkeypatch: pytest.MonkeyPatch): ], ) def test_telemetry_is_disabled_when_a_required_condition_is_not_met( - telemetry_enabled, monkeypatch: pytest.MonkeyPatch, setting: str, value: str | bool + telemetry_enabled, config_overrides: Callable[..., None], setting: str, value: str | bool ): - monkeypatch.setattr(telemetry_service.dify_config, setting, value) + config_overrides(**{setting: value}) assert CommunityTelemetryService._is_enabled() is False @@ -59,11 +58,16 @@ def test_reporting_without_setup_is_skipped(sqlite_session: Session, telemetry_e @pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True) -def test_report_install_marks_reported_at(sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch): +def test_report_install_marks_reported_at( + sqlite_session: Session, + telemetry_enabled, + monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], +): setup = DifySetup(version="installed-version", instance_id="d246c3a1-350b-406c-92c7-6043df680758") sqlite_session.add(setup) sqlite_session.commit() - monkeypatch.setattr(telemetry_service.dify_config.project, "version", "running-version") + config_overrides(project=telemetry_service.dify_config.project.model_copy(update={"version": "running-version"})) sent_payloads: list[dict[str, str | int]] = [] @@ -191,12 +195,15 @@ def test_report_install_does_not_use_fallback_endpoint_after_http_error( @pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True) def test_report_heartbeat_retries_pending_install_before_heartbeat( - sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch + sqlite_session: Session, + telemetry_enabled, + monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], ): setup = DifySetup(version="installed-version", instance_id="d246c3a1-350b-406c-92c7-6043df680758") sqlite_session.add(setup) sqlite_session.commit() - monkeypatch.setattr(telemetry_service.dify_config.project, "version", "running-version") + config_overrides(project=telemetry_service.dify_config.project.model_copy(update={"version": "running-version"})) sent_payloads: list[dict[str, str | int]] = [] @@ -263,8 +270,10 @@ def test_report_heartbeat_failure_does_not_mark_the_day_reported( assert setup.last_heartbeat_at is None -def test_send_event_skips_when_telemetry_is_disabled(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(telemetry_service.dify_config, "DISABLE_TELEMETRY", True) +def test_send_event_skips_when_telemetry_is_disabled( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +): + config_overrides(DISABLE_TELEMETRY=True) post_mock = Mock() monkeypatch.setattr(telemetry_service.httpx, "post", post_mock) @@ -272,8 +281,10 @@ def test_send_event_skips_when_telemetry_is_disabled(monkeypatch: pytest.MonkeyP post_mock.assert_not_called() -def test_send_event_skips_an_empty_fallback_endpoint(telemetry_enabled, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(telemetry_service.dify_config, "TELEMETRY_FALLBACK_ENDPOINT", "") +def test_send_event_skips_an_empty_fallback_endpoint( + telemetry_enabled, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +): + config_overrides(TELEMETRY_FALLBACK_ENDPOINT="") def fake_post(url: str, json: dict[str, str], timeout: int): raise httpx.ConnectError("offline", request=httpx.Request("POST", url)) @@ -283,12 +294,10 @@ def test_send_event_skips_an_empty_fallback_endpoint(telemetry_enabled, monkeypa assert CommunityTelemetryService._send_event({"event": "heartbeat"}) is False -def test_send_event_does_not_retry_the_same_endpoint(telemetry_enabled, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr( - telemetry_service.dify_config, - "TELEMETRY_FALLBACK_ENDPOINT", - telemetry_service.dify_config.TELEMETRY_ENDPOINT, - ) +def test_send_event_does_not_retry_the_same_endpoint( + telemetry_enabled, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +): + config_overrides(TELEMETRY_FALLBACK_ENDPOINT=telemetry_service.dify_config.TELEMETRY_ENDPOINT) post_mock = Mock( return_value=httpx.Response( 204, diff --git a/api/tests/unit_tests/services/test_turnstile_service.py b/api/tests/unit_tests/services/test_turnstile_service.py index 5c2173b5eb8..e812e30fdbc 100644 --- a/api/tests/unit_tests/services/test_turnstile_service.py +++ b/api/tests/unit_tests/services/test_turnstile_service.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from unittest.mock import MagicMock import httpx @@ -13,9 +14,8 @@ from services.turnstile_service import ( @pytest.fixture(autouse=True) -def configure_turnstile(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("services.turnstile_service.dify_config.TURNSTILE_SECRET_KEY", SecretStr("test-secret")) - monkeypatch.setattr("services.turnstile_service.dify_config.TURNSTILE_ALLOWED_HOSTNAMES", "dify.dev") +def configure_turnstile(config_overrides: Callable[..., None]) -> None: + config_overrides(TURNSTILE_SECRET_KEY=SecretStr("test-secret"), TURNSTILE_ALLOWED_HOSTNAMES="dify.dev") def mock_response(monkeypatch: pytest.MonkeyPatch, *, status_code: int = 200, payload: object) -> MagicMock: @@ -125,12 +125,11 @@ def test_verify_maps_timeout_to_upstream_error(monkeypatch: pytest.MonkeyPatch) [(None, "dify.dev"), (SecretStr("test-secret"), "")], ) def test_verify_fails_closed_when_cloud_configuration_is_missing( - monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], secret: SecretStr | None, allowed_hostnames: str, ) -> None: - monkeypatch.setattr("services.turnstile_service.dify_config.TURNSTILE_SECRET_KEY", secret) - monkeypatch.setattr("services.turnstile_service.dify_config.TURNSTILE_ALLOWED_HOSTNAMES", allowed_hostnames) + config_overrides(TURNSTILE_SECRET_KEY=secret, TURNSTILE_ALLOWED_HOSTNAMES=allowed_hostnames) with pytest.raises(TurnstileUpstreamError): TurnstileService.verify(token="verified-token", remote_ip=None) diff --git a/api/tests/unit_tests/services/test_variable_truncator_additional.py b/api/tests/unit_tests/services/test_variable_truncator_additional.py index 50644d42946..7e08ca75474 100644 --- a/api/tests/unit_tests/services/test_variable_truncator_additional.py +++ b/api/tests/unit_tests/services/test_variable_truncator_additional.py @@ -7,13 +7,17 @@ from graphon.variables.segments import IntegerSegment, ObjectSegment, StringSegm from graphon.variables.types import SegmentType from services import variable_truncator as truncator_module from services.variable_truncator import VariableTruncator +from tests.unit_tests.config_override import apply_config_overrides class TestVariableTruncatorAdditionalBehavior: def test_default_should_use_dify_config_limits(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(truncator_module.dify_config, "WORKFLOW_VARIABLE_TRUNCATION_MAX_SIZE", 111) - monkeypatch.setattr(truncator_module.dify_config, "WORKFLOW_VARIABLE_TRUNCATION_ARRAY_LENGTH", 7) - monkeypatch.setattr(truncator_module.dify_config, "WORKFLOW_VARIABLE_TRUNCATION_STRING_LENGTH", 33) + apply_config_overrides( + monkeypatch, + WORKFLOW_VARIABLE_TRUNCATION_MAX_SIZE=111, + WORKFLOW_VARIABLE_TRUNCATION_ARRAY_LENGTH=7, + WORKFLOW_VARIABLE_TRUNCATION_STRING_LENGTH=33, + ) truncator = VariableTruncator.default() diff --git a/api/tests/unit_tests/services/test_vector_space_admission_service.py b/api/tests/unit_tests/services/test_vector_space_admission_service.py index e66c60874c4..b29eb45f1be 100644 --- a/api/tests/unit_tests/services/test_vector_space_admission_service.py +++ b/api/tests/unit_tests/services/test_vector_space_admission_service.py @@ -1,5 +1,6 @@ import json import threading +from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from types import TracebackType from unittest.mock import call, patch @@ -8,7 +9,6 @@ from uuid import uuid4 import pytest from sqlalchemy.orm import Session -from configs import dify_config from core.rag.datasource.vdb.vector_type import VectorType from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType from core.rag.models.document import AttachmentDocument, ChildDocument, Document @@ -31,6 +31,14 @@ _MEBIBYTE = 1024 * 1024 _ESTIMATE_LIMITS = "sandbox:60,professional:6400,team:25600" +@pytest.fixture(autouse=True) +def _vector_admission_config(config_overrides: Callable[..., None]) -> None: + config_overrides( + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB=_ESTIMATE_LIMITS, + ) + + class _FakeRedisLock: def __init__(self, lock: threading.Lock) -> None: self._lock = lock @@ -101,11 +109,6 @@ def _check_estimate( with ( patch.object(service, "_get_plan", return_value=plan), patch.object(service, "_get_embedding_dimension", return_value=3072), - patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch( - "services.vector_space_admission_service.dify_config.TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB", - _ESTIMATE_LIMITS, - ), patch( "services.vector_space_admission_service.Vector.resolve_vector_type", return_value=VectorType.TIDB_ON_QDRANT, @@ -237,10 +240,10 @@ def test_pipeline_qa_workload_counts_question_vectors_without_summaries() -> Non assert workload.summary_points == 0 -def test_admission_is_cloud_only(sqlite_session: Session) -> None: +def test_admission_is_cloud_only(sqlite_session: Session, config_overrides: Callable[..., None]) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) service = VectorSpaceAdmissionService() with ( - patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), patch("services.vector_space_admission_service.Vector.resolve_vector_type") as resolve_vector_type, patch("services.vector_space_admission_service.BillingService.get_info") as get_info, ): @@ -258,7 +261,6 @@ def test_admission_is_cloud_only(sqlite_session: Session) -> None: def test_admission_skips_non_tidb_vector_backends(sqlite_session: Session) -> None: service = VectorSpaceAdmissionService() with ( - patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("services.vector_space_admission_service.Vector.resolve_vector_type", return_value=VectorType.QDRANT), patch("services.vector_space_admission_service.BillingService.get_info") as get_info, ): @@ -363,11 +365,6 @@ def test_usage_lookup_is_refreshed_for_each_document(sqlite_session: Session) -> with ( patch.object(service, "_get_plan", return_value=CloudPlan.SANDBOX), patch.object(service, "_get_embedding_dimension", return_value=3072), - patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch( - "services.vector_space_admission_service.dify_config.TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB", - _ESTIMATE_LIMITS, - ), patch( "services.vector_space_admission_service.Vector.resolve_vector_type", return_value=VectorType.TIDB_ON_QDRANT, diff --git a/api/tests/unit_tests/services/test_webhook_service_additional.py b/api/tests/unit_tests/services/test_webhook_service_additional.py index 44c13149088..9bf7f9d50b6 100644 --- a/api/tests/unit_tests/services/test_webhook_service_additional.py +++ b/api/tests/unit_tests/services/test_webhook_service_additional.py @@ -62,7 +62,9 @@ class TestWebhookServiceExtractionFallbacks: flask_app: Flask, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(service_module.dify_config, "WEBHOOK_REQUEST_BODY_MAX_SIZE", 1) + from tests.unit_tests.config_override import apply_config_overrides + + apply_config_overrides(monkeypatch, WEBHOOK_REQUEST_BODY_MAX_SIZE=1) with flask_app.test_request_context("/webhook", method="POST", data="ab"): with pytest.raises(RequestEntityTooLarge): diff --git a/api/tests/unit_tests/services/test_workflow_app_service_metadata.py b/api/tests/unit_tests/services/test_workflow_app_service_metadata.py index ded4fab1c7a..c51de1abaa9 100644 --- a/api/tests/unit_tests/services/test_workflow_app_service_metadata.py +++ b/api/tests/unit_tests/services/test_workflow_app_service_metadata.py @@ -2,10 +2,12 @@ import json import uuid -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest +from sqlalchemy.orm import Session +from models.account import Account from models.enums import AppTriggerType, CreatorUserRole from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom from services.workflow_app_service import LogView, WorkflowAppService @@ -24,11 +26,32 @@ class TestLogView: ) log.id = "log-1" - view = LogView(log=log, details={"trigger_metadata": {"type": "plugin"}}) + view = LogView(log=log, details={"trigger_metadata": {"type": "plugin"}}, session=MagicMock()) assert view.details == {"trigger_metadata": {"type": "plugin"}} assert view.id == "log-1" + def test_account_accessors_resolve_via_wrapped_session(self, sqlite_session: Session) -> None: + account = Account(name="Test Account", email="test@example.com") + sqlite_session.add(account) + sqlite_session.flush() + log = WorkflowAppLog( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_run_id="run-1", + created_from=WorkflowAppLogCreatedFrom.WEB_APP, + created_by_role=CreatorUserRole.ACCOUNT, + created_by=account.id, + ) + + view = LogView(log=log, details=None, session=sqlite_session) + + resolved = view.created_by_account + assert resolved is not None + assert resolved.id == account.id + assert view.created_by_end_user is None + class TestHandleTriggerMetadata: def test_returns_empty_dict_when_metadata_missing(self) -> None: diff --git a/api/tests/unit_tests/services/test_workflow_collaboration_service.py b/api/tests/unit_tests/services/test_workflow_collaboration_service.py index 3cf1f3c0a21..943677502f7 100644 --- a/api/tests/unit_tests/services/test_workflow_collaboration_service.py +++ b/api/tests/unit_tests/services/test_workflow_collaboration_service.py @@ -14,6 +14,7 @@ from models.base import TypeBase from models.model import App, AppMode, IconType from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository from services.workflow_collaboration_service import SYNC_REQUEST_TIMEOUT_SECONDS, WorkflowCollaborationService +from tests.unit_tests.config_override import config_overrides_context @pytest.fixture @@ -71,7 +72,7 @@ class TestWorkflowCollaborationService: db_session.commit() with ( - patch("services.workflow_collaboration_service.dify_config.RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch( "services.workflow_collaboration_service.RBACService.CheckAccess.check", return_value=True ) as check_access, @@ -140,7 +141,7 @@ class TestWorkflowCollaborationService: db_session.commit() with ( - patch("services.workflow_collaboration_service.dify_config.RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch( "services.workflow_collaboration_service.RBACService.CheckAccess.check", return_value=False ) as check_access, @@ -195,7 +196,7 @@ class TestWorkflowCollaborationService: ) db_session.commit() - with patch("services.workflow_collaboration_service.dify_config.RBAC_ENABLED", False): + with config_overrides_context(RBAC_ENABLED=False): result = collaboration_service._can_access_workflow("wf-1", "tenant-1", "user-1", session=db_session) assert result is True @@ -216,7 +217,7 @@ class TestWorkflowCollaborationService: db_session.commit() with ( - patch("services.workflow_collaboration_service.dify_config.RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch("services.workflow_collaboration_service.RBACService.CheckAccess.check") as check_access, ): result = collaboration_service._can_access_workflow("wf-1", "tenant-1", "owner-1", session=db_session) diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index 16357514227..5d4e3d92ced 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -11,6 +11,7 @@ This test suite covers: import json import uuid +from collections.abc import Callable from datetime import datetime, timedelta from types import SimpleNamespace from typing import Any, cast @@ -215,6 +216,10 @@ class TestWorkflowAssociatedDataFactory: @pytest.mark.usefixtures("sqlite_session") class TestWorkflowService: + @pytest.fixture(autouse=True) + def _community_edition(self, config_overrides: Callable[..., None]) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) + """ Comprehensive unit tests for WorkflowService methods. @@ -1272,7 +1277,6 @@ class TestWorkflowService: with ( patch("services.workflow_service.app_published_workflow_was_updated"), - patch("services.workflow_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), patch( "services.workflow_service.register_new_agent_beta_workflow_publish_after_commit" ) as register_workflow_publish, @@ -1306,7 +1310,6 @@ class TestWorkflowService: with ( patch("services.workflow_service.app_published_workflow_was_updated"), - patch("services.workflow_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), patch( "services.agent.workflow_publish_service.WorkflowAgentPublishService.copy_agent_node_bindings_to_published", return_value=True, @@ -1346,10 +1349,6 @@ class TestWorkflowService: with ( patch("services.workflow_service.app_published_workflow_was_updated"), - patch( - "services.workflow_service.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ), ): first = workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account) second = workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account) @@ -1376,10 +1375,6 @@ class TestWorkflowService: with ( patch("services.workflow_service.app_published_workflow_was_updated"), - patch( - "services.workflow_service.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ), ): published = workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account) sqlite_session.flush() @@ -1415,10 +1410,6 @@ class TestWorkflowService: with ( patch("services.workflow_service.app_published_workflow_was_updated"), - patch( - "services.workflow_service.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ), ): workflow = workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account) published.append(workflow) @@ -1485,7 +1476,13 @@ class TestWorkflowService: ): workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account) - def test_publish_workflow_trigger_limit_exceeded(self, workflow_service: WorkflowService, sqlite_session: Session): + def test_publish_workflow_trigger_limit_exceeded( + self, + workflow_service: WorkflowService, + sqlite_session: Session, + config_overrides: Callable[..., None], + ): + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) """ Test publish_workflow raises error when trigger node limit exceeded in SANDBOX plan. @@ -1511,7 +1508,6 @@ class TestWorkflowService: sqlite_session.commit() with ( - patch("services.workflow_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("services.workflow_service.BillingService") as MockBillingService, ): MockBillingService.get_info.return_value = {"subscription": {"plan": "sandbox"}} diff --git a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py index c6926a310ed..bcaabd7b815 100644 --- a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py +++ b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py @@ -296,7 +296,9 @@ class TestGetOauthClientSchema: monkeypatch.setattr(BuiltinToolManageService, "is_oauth_custom_client_enabled", MagicMock(return_value=True)) monkeypatch.setattr(BuiltinToolManageService, "is_oauth_system_client_exists", MagicMock(return_value=False)) monkeypatch.setattr(BuiltinToolManageService, "get_custom_oauth_client_params", MagicMock(return_value={})) - monkeypatch.setattr(service_module.dify_config, "CONSOLE_API_URL", "https://api.example.com") + from tests.unit_tests.config_override import apply_config_overrides + + apply_config_overrides(monkeypatch, CONSOLE_API_URL="https://api.example.com") result = BuiltinToolManageService.get_builtin_tool_provider_oauth_client_schema("t", "google") diff --git a/api/tests/unit_tests/services/workflow/test_workflow_converter_additional.py b/api/tests/unit_tests/services/workflow/test_workflow_converter_additional.py index 706ea415e2a..3cc8739623d 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_converter_additional.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_converter_additional.py @@ -2,7 +2,7 @@ from __future__ import annotations import json from types import SimpleNamespace -from typing import Any, cast +from typing import Any from unittest.mock import MagicMock, call import pytest @@ -45,15 +45,36 @@ def converter() -> WorkflowConverter: def _app_model(**kwargs: Any) -> App: - return cast(App, SimpleNamespace(**kwargs)) + defaults: dict[str, Any] = { + "id": "app-1", + "tenant_id": "tenant-1", + "name": "Source App", + "description": "", + "mode": AppMode.CHAT, + "enable_site": True, + "enable_api": True, + "max_active_requests": 0, + } + defaults.update(kwargs) + return App(**defaults) def _account(**kwargs: Any) -> Account: - return cast(Account, SimpleNamespace(**kwargs)) + account_id = kwargs.pop("id", "account-1") + account = Account( + name=kwargs.pop("name", "Converter user"), + email=kwargs.pop("email", "user@example.com"), + **kwargs, + ) + account.id = account_id + return account def _app_model_config(**kwargs: Any) -> AppModelConfig: - return cast(AppModelConfig, SimpleNamespace(**kwargs)) + config_id = kwargs.pop("id", "config-1") + config = AppModelConfig(app_id=kwargs.pop("app_id", "app-1"), **kwargs) + config.id = config_id + return config def _build_start_graph() -> dict[str, Any]: @@ -94,10 +115,7 @@ def test__convert_to_start_node(default_variables: list[VariableEntity]) -> None def test__convert_to_http_request_node_for_chatbot( default_variables: list[VariableEntity], unbound_session: Session ) -> None: - app_model = MagicMock() - app_model.id = "app_id" - app_model.tenant_id = "tenant_id" - app_model.mode = AppMode.CHAT + app_model = _app_model(id="app_id", tenant_id="tenant_id", mode=AppMode.CHAT) extension = APIBasedExtension( tenant_id="tenant_id", @@ -139,10 +157,7 @@ def test__convert_to_http_request_node_for_chatbot( def test__convert_to_http_request_node_for_workflow_app( default_variables: list[VariableEntity], unbound_session: Session ) -> None: - app_model = MagicMock() - app_model.id = "app_id" - app_model.tenant_id = "tenant_id" - app_model.mode = AppMode.WORKFLOW + app_model = _app_model(id="app_id", tenant_id="tenant_id", mode=AppMode.WORKFLOW) extension = APIBasedExtension( tenant_id="tenant_id", @@ -593,7 +608,7 @@ def test_convert_app_model_config_to_workflow_should_build_workflow_mode_with_en def test_convert_to_app_config_should_route_to_correct_manager( converter: WorkflowConverter, monkeypatch: pytest.MonkeyPatch, - unbound_session: Session, + sqlite_session: Session, ) -> None: agent_result = SimpleNamespace(kind="agent") chat_result = SimpleNamespace(kind="chat") @@ -606,47 +621,61 @@ def test_convert_to_app_config_should_route_to_correct_manager( monkeypatch.setattr(converter_module.ChatAppConfigManager, "get_app_config", chat_get_app_config) monkeypatch.setattr(converter_module.CompletionAppConfigManager, "get_app_config", completion_get_app_config) monkeypatch.setattr(converter_module, "load_annotation_reply_config", load_annotation_reply) - agent_mode_app = _app_model(mode=AppMode.AGENT_CHAT, is_agent_with_session=MagicMock(return_value=False)) - agent_flag_app = _app_model(mode=AppMode.CHAT, is_agent_with_session=MagicMock(return_value=True)) - chat_app = _app_model(mode=AppMode.CHAT, is_agent_with_session=MagicMock(return_value=False)) - completion_app = _app_model(mode=AppMode.COMPLETION, is_agent_with_session=MagicMock(return_value=False)) + agent_mode_app = _app_model(id="app-1", mode=AppMode.AGENT_CHAT, app_model_config_id="cfg-1") + agent_flag_app = _app_model(id="app-2", mode=AppMode.CHAT, app_model_config_id="cfg-2") + chat_app = _app_model(id="app-3", mode=AppMode.CHAT, app_model_config_id="cfg-3") + completion_app = _app_model(id="app-4", mode=AppMode.COMPLETION, app_model_config_id="cfg-4") agent_mode_config = _app_model_config(id="cfg-1", app_id="app-1") - agent_flag_config = _app_model_config(id="cfg-2", app_id="app-2") + agent_flag_config = _app_model_config( + id="cfg-2", app_id="app-2", agent_mode=json.dumps({"enabled": True, "strategy": "react"}) + ) chat_config = _app_model_config(id="cfg-3", app_id="app-3") completion_config = _app_model_config(id="cfg-4", app_id="app-4") + sqlite_session.add_all( + [ + agent_mode_app, + agent_flag_app, + chat_app, + completion_app, + agent_mode_config, + agent_flag_config, + chat_config, + completion_config, + ] + ) + sqlite_session.commit() from_agent_mode = converter._convert_to_app_config( app_model=agent_mode_app, app_model_config=agent_mode_config, - session=unbound_session, + session=sqlite_session, ) from_agent_flag = converter._convert_to_app_config( app_model=agent_flag_app, app_model_config=agent_flag_config, - session=unbound_session, + session=sqlite_session, ) from_chat_mode = converter._convert_to_app_config( app_model=chat_app, app_model_config=chat_config, - session=unbound_session, + session=sqlite_session, ) from_completion_mode = converter._convert_to_app_config( app_model=completion_app, app_model_config=completion_config, - session=unbound_session, + session=sqlite_session, ) assert from_agent_mode is agent_result assert from_agent_flag is agent_result assert from_chat_mode is chat_result assert from_completion_mode is completion_result - agent_flag_app.is_agent_with_session.assert_called_once_with(session=unbound_session) load_annotation_reply.assert_has_calls( [ - call(unbound_session, "app-1"), - call(unbound_session, "app-2"), - call(unbound_session, "app-3"), - call(unbound_session, "app-4"), + call(sqlite_session, "app-1"), + call(sqlite_session, "app-2"), + call(sqlite_session, "app-3"), + call(sqlite_session, "app-4"), ] ) assert all( @@ -659,7 +688,7 @@ def test_convert_to_app_config_should_route_to_correct_manager( def test_convert_to_app_config_should_raise_for_invalid_app_mode( converter: WorkflowConverter, unbound_session: Session ) -> None: - app_model = _app_model(mode=AppMode.WORKFLOW, is_agent_with_session=MagicMock(return_value=False)) + app_model = _app_model(mode=AppMode.WORKFLOW) with pytest.raises(ValueError, match="Invalid app mode"): converter._convert_to_app_config( @@ -845,25 +874,35 @@ def test_graph_helpers_should_create_edges_append_nodes_and_choose_mode(converte def test_get_api_based_extension_should_raise_when_extension_not_found( converter: WorkflowConverter, - monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, ) -> None: - db_session = SimpleNamespace(scalar=MagicMock(return_value=None)) - with pytest.raises(ValueError, match="API Based Extension not found"): - converter._get_api_based_extension(tenant_id="tenant-1", api_based_extension_id="ext-1", session=db_session) - db_session.scalar.assert_called_once() + converter._get_api_based_extension(tenant_id="tenant-1", api_based_extension_id="ext-1", session=sqlite_session) def test_get_api_based_extension_should_return_entity_when_found( converter: WorkflowConverter, - monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, ) -> None: - extension = SimpleNamespace(id="ext-1") - db_session = SimpleNamespace(scalar=MagicMock(return_value=extension)) + extension = APIBasedExtension( + tenant_id="tenant-1", + name="API extension", + api_key="encrypted", + api_endpoint="https://example.com", + ) + extension.id = "ext-1" + decoy = APIBasedExtension( + tenant_id="other-tenant", + name="Other tenant API extension", + api_key="encrypted", + api_endpoint="https://example.com", + ) + decoy.id = "ext-other" + sqlite_session.add_all([extension, decoy]) + sqlite_session.commit() result = converter._get_api_based_extension( - tenant_id="tenant-1", api_based_extension_id="ext-1", session=db_session + tenant_id="tenant-1", api_based_extension_id="ext-1", session=sqlite_session ) assert result is extension - db_session.scalar.assert_called_once() diff --git a/api/tests/unit_tests/tasks/test_dataset_indexing_task.py b/api/tests/unit_tests/tasks/test_dataset_indexing_task.py index 25dd16b2d58..e12e8b1b243 100644 --- a/api/tests/unit_tests/tasks/test_dataset_indexing_task.py +++ b/api/tests/unit_tests/tasks/test_dataset_indexing_task.py @@ -27,6 +27,7 @@ from tasks.document_indexing_task import ( normal_document_indexing_task, priority_document_indexing_task, ) +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture @@ -318,7 +319,7 @@ class TestDocumentIndexing: document_ids=[control_document_id], ) _patch_features(monkeypatch, features) - monkeypatch.setattr("tasks.document_indexing_task.dify_config.BATCH_UPLOAD_LIMIT", str(batch_limit)) + apply_config_overrides(monkeypatch, BATCH_UPLOAD_LIMIT=str(batch_limit)) _document_indexing(dataset_id, document_ids) diff --git a/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py b/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py index b8cca3a1171..3b320f4a0ec 100644 --- a/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py +++ b/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py @@ -14,6 +14,7 @@ from core.workflow.nodes.human_input.entities import FormDefinition from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus from models.human_input import HumanInputForm from tasks import human_input_timeout_tasks as task_module +from tests.unit_tests.config_override import apply_config_overrides class _FakeService: @@ -103,7 +104,7 @@ def test_check_and_handle_human_input_timeouts_marks_and_routes( ): now = datetime(2025, 1, 1, 12, 0, 0) monkeypatch.setattr(task_module, "naive_utc_now", lambda: now) - monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 3600) + apply_config_overrides(monkeypatch, HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS=3600) forms = [ _build_form( @@ -180,7 +181,7 @@ def test_check_and_handle_human_input_timeouts_orders_by_id_before_limit( ): now = datetime(2025, 1, 1, 12, 0, 0) monkeypatch.setattr(task_module, "naive_utc_now", lambda: now) - monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0) + apply_config_overrides(monkeypatch, HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS=0) forms = [ _build_form( @@ -222,7 +223,7 @@ def test_check_and_handle_human_input_timeouts_omits_global_filter_when_disabled ): now = datetime(2025, 1, 1, 12, 0, 0) monkeypatch.setattr(task_module, "naive_utc_now", lambda: now) - monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0) + apply_config_overrides(monkeypatch, HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS=0) old_unexpired_form = _build_form( form_id="form-old", @@ -263,7 +264,7 @@ def test_check_and_handle_human_input_timeouts_routes_conversation_owned_form_to # workflow_run_id — which previously raised and was swallowed by the except. now = datetime(2025, 1, 1, 12, 0, 0) monkeypatch.setattr(task_module, "naive_utc_now", lambda: now) - monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 3600) + apply_config_overrides(monkeypatch, HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS=3600) form = _build_form( form_id="form-chat", diff --git a/api/tests/unit_tests/tasks/test_initialize_created_app_rbac_access_task.py b/api/tests/unit_tests/tasks/test_initialize_created_app_rbac_access_task.py index 54eaf46f37b..be96c1d6285 100644 --- a/api/tests/unit_tests/tasks/test_initialize_created_app_rbac_access_task.py +++ b/api/tests/unit_tests/tasks/test_initialize_created_app_rbac_access_task.py @@ -2,12 +2,13 @@ from unittest.mock import MagicMock import pytest +from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task +from tests.unit_tests.config_override import apply_config_overrides + APP_RBAC_QUEUE = "app_rbac" def test_initialize_created_app_rbac_access_task_uses_rbac_queue(): - from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task - assert initialize_created_app_rbac_access_task.queue == APP_RBAC_QUEUE @@ -21,7 +22,7 @@ def test_initialize_created_app_rbac_access_task_batches_workspace_members(monke import tasks.initialize_created_app_rbac_access_task as task_module from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task - monkeypatch.setattr(task_module.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr( task_module.TenantService, "iter_member_account_id_batches", @@ -54,7 +55,7 @@ def test_initialize_created_app_rbac_access_task_retries_on_failure(monkeypatch: import tasks.initialize_created_app_rbac_access_task as task_module from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task - monkeypatch.setattr(task_module.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr( task_module.TenantService, "iter_member_account_id_batches", @@ -110,7 +111,7 @@ def test_sync_joined_workspace_member_rbac_access_task_appends_auto_included_res app_append = MagicMock() dataset_append = MagicMock() - monkeypatch.setattr(task_module.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr(task_module, "_iter_resource_config_batches", lambda tenant_id, batch_size: iter([resources])) monkeypatch.setattr(rbac.RBACService.ResourceWhitelistConfigs, "batch_get", batch_get) monkeypatch.setattr(rbac.RBACService.AppAccess, "append_whitelist_members_batch", app_append) diff --git a/api/tests/unit_tests/tasks/test_install_default_plugins_task.py b/api/tests/unit_tests/tasks/test_install_default_plugins_task.py index 3a0357c24c5..f7e44be1a50 100644 --- a/api/tests/unit_tests/tasks/test_install_default_plugins_task.py +++ b/api/tests/unit_tests/tasks/test_install_default_plugins_task.py @@ -5,6 +5,8 @@ from unittest.mock import MagicMock, call import pytest from celery.exceptions import Retry +from tests.unit_tests.config_override import apply_config_overrides + def test_install_default_plugins_task_uses_plugin_queue() -> None: from tasks.install_default_plugins_task import install_default_plugins_task @@ -71,7 +73,7 @@ def test_install_default_plugins_task_queues_model_configuration_after_daemon_in plugin_id = "langgenius/openai" plugin_identifier = "langgenius/openai:1.0.0@aaa" - monkeypatch.setattr(task_module.dify_config, "NEW_USER_DEFAULT_MODELS", "llm:provider:model") + apply_config_overrides(monkeypatch, NEW_USER_DEFAULT_MODELS="llm:provider:model") monkeypatch.setattr( task_module.marketplace, "batch_fetch_plugin_manifests", @@ -96,7 +98,7 @@ def test_configure_default_models_task_retries_while_plugins_are_installing( import tasks.install_default_plugins_task as task_module from tasks.install_default_plugins_task import configure_default_models_task - monkeypatch.setattr(task_module.dify_config, "NEW_USER_DEFAULT_MODELS", "llm:provider:model") + apply_config_overrides(monkeypatch, NEW_USER_DEFAULT_MODELS="llm:provider:model") monkeypatch.setattr( task_module.PluginService, "fetch_install_task", @@ -115,10 +117,11 @@ def test_configure_default_models_task_sets_each_explicit_model(monkeypatch: pyt import tasks.install_default_plugins_task as task_module from tasks.install_default_plugins_task import configure_default_models_task - monkeypatch.setattr( - task_module.dify_config, - "NEW_USER_DEFAULT_MODELS", - ("llm:langgenius/openai/openai:gpt-4o-mini,text-embedding:langgenius/openai/openai:text-embedding-3-small"), + apply_config_overrides( + monkeypatch, + NEW_USER_DEFAULT_MODELS=( + "llm:langgenius/openai/openai:gpt-4o-mini,text-embedding:langgenius/openai/openai:text-embedding-3-small" + ), ) fetch_install_task = MagicMock( return_value=SimpleNamespace( diff --git a/api/tests/unit_tests/tasks/test_new_agent_beta_task.py b/api/tests/unit_tests/tasks/test_new_agent_beta_task.py index f6fba0cb7ed..18fbe774117 100644 --- a/api/tests/unit_tests/tasks/test_new_agent_beta_task.py +++ b/api/tests/unit_tests/tasks/test_new_agent_beta_task.py @@ -18,6 +18,7 @@ from tasks.new_agent_beta_task import ( schedule_new_agent_beta_ensure, schedule_new_agent_beta_workflow_ensure, ) +from tests.unit_tests.config_override import apply_config_overrides class _TaskWithQueue(Protocol): @@ -25,9 +26,12 @@ class _TaskWithQueue(Protocol): def _configure_cloud_publish(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(task_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) - monkeypatch.setattr(task_module.dify_config, "NEW_AGENT_BETA_ACTIVITY_START_AT", datetime(2026, 8, 12, tzinfo=UTC)) - monkeypatch.setattr(task_module.dify_config, "NEW_AGENT_BETA_ACTIVITY_END_AT", datetime(2026, 8, 13, tzinfo=UTC)) + apply_config_overrides( + monkeypatch, + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + NEW_AGENT_BETA_ACTIVITY_START_AT=datetime(2026, 8, 12, tzinfo=UTC), + NEW_AGENT_BETA_ACTIVITY_END_AT=datetime(2026, 8, 13, tzinfo=UTC), + ) @pytest.mark.parametrize("sqlite_session", [(AgentConfigRevision,)], indirect=True) @@ -132,7 +136,7 @@ def test_rolled_back_workflow_publish_is_never_dispatched( def test_non_cloud_publish_skips_revision_lookup(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(task_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) session = MagicMock() register_new_agent_beta_publish_after_commit( @@ -161,8 +165,11 @@ def test_publish_activity_window_is_inclusive_start_exclusive_end( published_at: datetime, expected: bool, ) -> None: - monkeypatch.setattr(task_module.dify_config, "NEW_AGENT_BETA_ACTIVITY_START_AT", start) - monkeypatch.setattr(task_module.dify_config, "NEW_AGENT_BETA_ACTIVITY_END_AT", end) + apply_config_overrides( + monkeypatch, + NEW_AGENT_BETA_ACTIVITY_START_AT=start, + NEW_AGENT_BETA_ACTIVITY_END_AT=end, + ) assert task_module._is_publish_in_activity_window(published_at) is expected diff --git a/api/tests/unit_tests/tasks/test_refresh_billing_vector_space_task.py b/api/tests/unit_tests/tasks/test_refresh_billing_vector_space_task.py index 63bf6fcedfd..93136228da3 100644 --- a/api/tests/unit_tests/tasks/test_refresh_billing_vector_space_task.py +++ b/api/tests/unit_tests/tasks/test_refresh_billing_vector_space_task.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from unittest.mock import patch import pytest @@ -9,9 +10,13 @@ from tasks.refresh_billing_vector_space_task import ( ) +@pytest.fixture(autouse=True) +def _cloud_edition(config_overrides: Callable[..., None]) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) + + def test_refresh_invalidates_vector_space_cache(): with ( - patch("tasks.refresh_billing_vector_space_task.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch( "tasks.refresh_billing_vector_space_task.BillingService.invalidate_vector_space_cache" ) as invalidate_cache, @@ -25,7 +30,6 @@ def test_refresh_failure_schedules_retry(): error = RuntimeError("billing unavailable") with ( - patch("tasks.refresh_billing_vector_space_task.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch( "tasks.refresh_billing_vector_space_task.BillingService.invalidate_vector_space_cache", side_effect=error, @@ -40,7 +44,6 @@ def test_refresh_failure_schedules_retry(): def test_dispatch_failure_does_not_propagate(): with ( - patch("tasks.refresh_billing_vector_space_task.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch.object(refresh_billing_vector_space_task, "delay", side_effect=RuntimeError("broker unavailable")), ): schedule_billing_vector_space_refresh("tenant-1") diff --git a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py index 125e447a10e..8774760f2c4 100644 --- a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py @@ -23,6 +23,7 @@ from tasks.remove_app_and_related_data_task import ( _delete_workflow_agent_node_bindings, delete_draft_variables_batch, ) +from tests.unit_tests.config_override import apply_config_overrides def test_delete_workflow_agent_node_bindings_is_scoped_to_tenant_and_app(sqlite_session: Session) -> None: @@ -75,7 +76,7 @@ def test_delete_workflow_agent_node_bindings_is_scoped_to_tenant_and_app(sqlite_ def test_app_cleanup_removes_agent_bindings_before_workflows(monkeypatch: pytest.MonkeyPatch) -> None: events: list[str] = [] - monkeypatch.setattr(remove_app_task_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) other_cleanup_names = ( "_delete_app_model_configs", "_delete_app_site", diff --git a/api/tests/unit_tests/tasks/test_resume_agent_app_task.py b/api/tests/unit_tests/tasks/test_resume_agent_app_task.py index 684ee06edf3..c9d20849af7 100644 --- a/api/tests/unit_tests/tasks/test_resume_agent_app_task.py +++ b/api/tests/unit_tests/tasks/test_resume_agent_app_task.py @@ -1,154 +1,260 @@ -"""Unit tests for the ``resume_agent_app_execution`` celery task (ENG-635). - -Every DB access (``db.session.get``) and the generator are patched at the module -level, so the task's branch logic is exercised without a database or live stack. -""" +"""Unit tests for the ``resume_agent_app_execution`` Celery task (ENG-635).""" from __future__ import annotations -from unittest.mock import MagicMock +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta +from uuid import uuid4 +import pytest from pytest_mock import MockerFixture +from sqlalchemy.orm import Session, scoped_session, sessionmaker from core.app.entities.app_invoke_entities import InvokeFrom -from models.account import Account +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus +from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole +from models.enums import ConversationFromSource, EndUserType +from models.enums import InvokeFrom as StoredInvokeFrom from models.human_input import HumanInputForm -from models.model import App, Conversation, EndUser +from models.model import App, AppMode, Conversation, EndUser from tasks.app_generate import resume_agent_app_task as mod MODULE = "tasks.app_generate.resume_agent_app_task" -def _form(conversation_id: str = "conv-1", app_id: str = "app-1") -> MagicMock: - return MagicMock(conversation_id=conversation_id, app_id=app_id) +@pytest.fixture +def task_session(mocker: MockerFixture, sqlite_session_factory: sessionmaker[Session]) -> Iterator[Session]: + """Bind the task's Flask-SQLAlchemy session proxy to the shared SQLite database.""" + registry = scoped_session(sqlite_session_factory) + mocker.patch.object(mod.db, "session", registry) + session = registry() + yield session + registry.remove() -def _wire_db( - mocker: MockerFixture, +def _app(*, app_id: str, tenant_id: str) -> App: + return App( + id=app_id, + tenant_id=tenant_id, + name="Agent app", + description="", + mode=AppMode.AGENT_CHAT, + icon_type=None, + icon=None, + icon_background=None, + enable_site=False, + enable_api=False, + ) + + +def _conversation( *, - form=None, - app=None, - conversation=None, - account=None, - end_user=None, -) -> MagicMock: - """Patch the module ``db`` so ``db.session.get(Model, id)`` dispatches by model.""" - table = { - HumanInputForm: form, - App: app, - Conversation: conversation, - Account: account, - EndUser: end_user, - } - db = mocker.patch(f"{MODULE}.db") - db.session.get.side_effect = lambda model, _id: table.get(model) - return db + conversation_id: str, + app_id: str, + account_id: str | None = None, + end_user_id: str | None = None, + invoke_from: StoredInvokeFrom = StoredInvokeFrom.WEB_APP, +) -> Conversation: + return Conversation( + id=conversation_id, + app_id=app_id, + mode=AppMode.AGENT_CHAT, + name="Agent conversation", + inputs={}, + invoke_from=invoke_from, + from_source=ConversationFromSource.API, + from_account_id=account_id, + from_end_user_id=end_user_id, + ) -def test_resume_happy_path_account_user_sets_tenant_and_runs(mocker: MockerFixture): - conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP) - account = MagicMock() - app = MagicMock(tenant_id="tenant-1") - db = _wire_db(mocker, form=_form(), app=app, conversation=conversation, account=account) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") - - account.set_tenant_id_with_session.assert_called_once_with("tenant-1", session=db.session.return_value) - gen.return_value.resume_after_form_submission.assert_called_once() - kwargs = gen.return_value.resume_after_form_submission.call_args.kwargs - assert kwargs["conversation_id"] == "conv-1" - assert kwargs["form_id"] == "form-1" - assert kwargs["user"] is account - assert kwargs["app_model"] is app - assert kwargs["invoke_from"] == InvokeFrom.WEB_APP - assert kwargs["session"] is db.session.return_value +def _form(*, form_id: str, conversation_id: str, app_id: str) -> HumanInputForm: + return HumanInputForm( + id=form_id, + tenant_id=str(uuid4()), + app_id=app_id, + workflow_run_id=None, + conversation_id=conversation_id, + form_kind=HumanInputFormKind.RUNTIME, + node_id="ask-human", + form_definition="{}", + rendered_content="Question", + status=HumanInputFormStatus.WAITING, + expiration_time=datetime.now(UTC).replace(tzinfo=None) + timedelta(hours=1), + ) -def test_resume_end_user_path(mocker: MockerFixture): - conversation = MagicMock(from_account_id=None, from_end_user_id="eu-1", invoke_from=InvokeFrom.WEB_APP) - end_user = MagicMock() - _wire_db(mocker, form=_form(), app=MagicMock(tenant_id="t"), conversation=conversation, end_user=end_user) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") - - assert gen.return_value.resume_after_form_submission.call_args.kwargs["user"] is end_user +def _seed_account(session: Session, *, tenant_id: str, account_id: str) -> Account: + tenant = Tenant(name="Tenant") + tenant.id = tenant_id + account = Account(name="Account", email="account@example.com") + account.id = account_id + join = TenantAccountJoin( + tenant_id=tenant_id, + account_id=account_id, + current=True, + role=TenantAccountRole.NORMAL, + ) + session.add_all([tenant, account, join]) + return account -def test_resume_preserves_debugger_invoke_from(mocker: MockerFixture): - conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None, invoke_from=InvokeFrom.DEBUGGER) - account = MagicMock() - app = MagicMock(tenant_id="tenant-1") - _wire_db(mocker, form=_form(), app=app, conversation=conversation, account=account) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") +def test_resume_happy_path_account_user_sets_tenant_and_runs(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id, account_id = (str(uuid4()) for _ in range(5)) + app = _app(app_id=app_id, tenant_id=tenant_id) + account = _seed_account(task_session, tenant_id=tenant_id, account_id=account_id) + conversation = _conversation(conversation_id=conversation_id, app_id=app_id, account_id=account_id) + task_session.add_all([app, conversation, _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id)]) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) - assert gen.return_value.resume_after_form_submission.call_args.kwargs["invoke_from"] == InvokeFrom.DEBUGGER + call = generator.return_value.resume_after_form_submission.call_args + assert call is not None + assert call.kwargs["conversation_id"] == conversation_id + assert call.kwargs["form_id"] == form_id + assert call.kwargs["user"] is account + assert call.kwargs["app_model"] is app + assert call.kwargs["invoke_from"] == InvokeFrom.WEB_APP + assert isinstance(call.kwargs["session"], Session) + assert account.current_tenant_id == tenant_id -def test_resume_returns_when_form_missing(mocker: MockerFixture): - _wire_db(mocker, form=None) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") +def test_resume_end_user_path(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id, end_user_id = (str(uuid4()) for _ in range(5)) + app = _app(app_id=app_id, tenant_id=tenant_id) + end_user = EndUser( + id=end_user_id, + tenant_id=tenant_id, + app_id=app_id, + type=EndUserType.BROWSER, + name="End user", + session_id="browser-session", + ) + task_session.add_all( + [ + app, + end_user, + _conversation(conversation_id=conversation_id, app_id=app_id, end_user_id=end_user_id), + _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id), + ] + ) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) - gen.assert_not_called() + assert generator.return_value.resume_after_form_submission.call_args.kwargs["user"] is end_user -def test_resume_returns_on_conversation_mismatch(mocker: MockerFixture): - _wire_db(mocker, form=_form(conversation_id="other-conv")) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") +def test_resume_preserves_debugger_invoke_from(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id, account_id = (str(uuid4()) for _ in range(5)) + app = _app(app_id=app_id, tenant_id=tenant_id) + _seed_account(task_session, tenant_id=tenant_id, account_id=account_id) + task_session.add_all( + [ + app, + _conversation( + conversation_id=conversation_id, + app_id=app_id, + account_id=account_id, + invoke_from=StoredInvokeFrom.DEBUGGER, + ), + _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id), + ] + ) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) - gen.assert_not_called() + assert generator.return_value.resume_after_form_submission.call_args.kwargs["invoke_from"] == InvokeFrom.DEBUGGER -def test_resume_returns_when_app_missing(mocker: MockerFixture): - _wire_db(mocker, form=_form(), app=None) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") - - gen.assert_not_called() +@pytest.mark.usefixtures("task_session") +def test_resume_returns_when_form_missing(mocker: MockerFixture) -> None: + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + mod.resume_agent_app_execution(conversation_id=str(uuid4()), form_id=str(uuid4())) + generator.assert_not_called() -def test_resume_returns_when_conversation_missing(mocker: MockerFixture): - _wire_db(mocker, form=_form(), app=MagicMock(), conversation=None) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") - - gen.assert_not_called() +def test_resume_returns_on_conversation_mismatch(mocker: MockerFixture, task_session: Session) -> None: + app_id, form_id = str(uuid4()), str(uuid4()) + task_session.add(_form(form_id=form_id, conversation_id=str(uuid4()), app_id=app_id)) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + mod.resume_agent_app_execution(conversation_id=str(uuid4()), form_id=form_id) + generator.assert_not_called() -def test_resume_returns_when_no_user_resolvable(mocker: MockerFixture): - conversation = MagicMock(from_account_id=None, from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP) - _wire_db(mocker, form=_form(), app=MagicMock(), conversation=conversation) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") - - gen.assert_not_called() +def test_resume_returns_when_app_missing(mocker: MockerFixture, task_session: Session) -> None: + conversation_id, form_id = str(uuid4()), str(uuid4()) + task_session.add(_form(form_id=form_id, conversation_id=conversation_id, app_id=str(uuid4()))) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) + generator.assert_not_called() -def test_resume_returns_when_account_id_set_but_account_gone(mocker: MockerFixture): - conversation = MagicMock(from_account_id="acct-x", from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP) - _wire_db(mocker, form=_form(), app=MagicMock(), conversation=conversation, account=None) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") - - gen.assert_not_called() +def test_resume_returns_when_conversation_missing(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id = (str(uuid4()) for _ in range(4)) + task_session.add_all( + [ + _app(app_id=app_id, tenant_id=tenant_id), + _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id), + ] + ) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) + generator.assert_not_called() -def test_resume_swallows_generator_exception(mocker: MockerFixture): - conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP) - _wire_db(mocker, form=_form(), app=MagicMock(tenant_id="t"), conversation=conversation, account=MagicMock()) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - gen.return_value.resume_after_form_submission.side_effect = RuntimeError("boom") +def test_resume_returns_when_no_user_resolvable(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id = (str(uuid4()) for _ in range(4)) + task_session.add_all( + [ + _app(app_id=app_id, tenant_id=tenant_id), + _conversation(conversation_id=conversation_id, app_id=app_id), + _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id), + ] + ) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) + generator.assert_not_called() - # The task must not propagate the failure (it is logged and the session closed). - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + +def test_resume_returns_when_account_id_set_but_account_gone(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id = (str(uuid4()) for _ in range(4)) + task_session.add_all( + [ + _app(app_id=app_id, tenant_id=tenant_id), + _conversation(conversation_id=conversation_id, app_id=app_id, account_id=str(uuid4())), + _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id), + ] + ) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) + generator.assert_not_called() + + +def test_resume_swallows_generator_exception(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id, account_id = (str(uuid4()) for _ in range(5)) + _seed_account(task_session, tenant_id=tenant_id, account_id=account_id) + task_session.add_all( + [ + _app(app_id=app_id, tenant_id=tenant_id), + _conversation(conversation_id=conversation_id, app_id=app_id, account_id=account_id), + _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id), + ] + ) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + generator.return_value.resume_after_form_submission.side_effect = RuntimeError("boom") + + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) + + generator.return_value.resume_after_form_submission.assert_called_once() diff --git a/api/tests/unit_tests/test_app_factory.py b/api/tests/unit_tests/test_app_factory.py index acdeecc07c0..64f0dc230f5 100644 --- a/api/tests/unit_tests/test_app_factory.py +++ b/api/tests/unit_tests/test_app_factory.py @@ -10,6 +10,7 @@ from app_factory import create_flask_app_with_configs from enums import DeploymentEdition from libs.external_api import ExternalApi from services.entities.feature_entities import LicenseStatus +from tests.unit_tests.config_override import config_overrides_context INVALID_STATUSES = [LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST] VALID_STATUSES = [LicenseStatus.ACTIVE, LicenseStatus.EXPIRING] @@ -20,11 +21,11 @@ def _license(status: LicenseStatus | None): def _enterprise(): - return patch("app_factory.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) + return config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) def _community(): - return patch("app_factory.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + return config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) @pytest.fixture diff --git a/api/tests/unit_tests/test_config_overrides.py b/api/tests/unit_tests/test_config_overrides.py index 39588272246..7c71518cea3 100644 --- a/api/tests/unit_tests/test_config_overrides.py +++ b/api/tests/unit_tests/test_config_overrides.py @@ -1,12 +1,101 @@ """Contract tests for the shared unit-test config override fixture.""" +import ast from collections.abc import Callable +from pathlib import Path +from typing import override import pytest from configs import dify_config from enums import DeploymentEdition +_UNIT_TEST_ROOT = Path(__file__).parent +_AUTHORIZED_MUTATION_FILE = _UNIT_TEST_ROOT / "config_override.py" + + +def _references_shared_config(node: ast.AST) -> bool: + """Return whether an expression resolves through the shared ``dify_config`` object.""" + return any( + (isinstance(child, ast.Name) and child.id == "dify_config") + or (isinstance(child, ast.Attribute) and child.attr == "dify_config") + for child in ast.walk(node) + ) + + +def _attribute_chain(node: ast.AST) -> tuple[str, ...]: + parts: list[str] = [] + while isinstance(node, ast.Attribute): + parts.append(node.attr) + node = node.value + if isinstance(node, ast.Name): + parts.append(node.id) + return tuple(reversed(parts)) + + +def _is_config_field(name: object) -> bool: + return isinstance(name, str) and bool(name) and name.isupper() + + +class _DirectConfigMutationVisitor(ast.NodeVisitor): + """Find test code that bypasses the validated config override helper.""" + + def __init__(self) -> None: + self.lines: list[int] = [] + + @override + def visit_Call(self, node: ast.Call) -> None: + chain = _attribute_chain(node.func) + if chain[-2:] == ("patch", "object") and len(node.args) >= 2: + field = node.args[1] + if ( + _references_shared_config(node.args[0]) + and isinstance(field, ast.Constant) + and _is_config_field(field.value) + ): + self.lines.append(node.lineno) + elif chain[-1:] == ("patch",) and node.args: + target = node.args[0] + if ( + isinstance(target, ast.Constant) + and isinstance(target.value, str) + and ".dify_config." in target.value + and _is_config_field(target.value.rsplit(".", 1)[-1]) + ): + self.lines.append(node.lineno) + elif chain[-2:] == ("monkeypatch", "setattr") and node.args: + target = node.args[0] + field = node.args[1] if len(node.args) >= 2 else None + string_target_is_config = ( + isinstance(target, ast.Constant) and isinstance(target.value, str) and ".dify_config." in target.value + ) + object_target_is_config = ( + field is not None + and _references_shared_config(target) + and isinstance(field, ast.Constant) + and _is_config_field(field.value) + ) + if string_target_is_config or object_target_is_config: + self.lines.append(node.lineno) + self.generic_visit(node) + + @override + def visit_Assign(self, node: ast.Assign) -> None: + for target in node.targets: + if ( + isinstance(target, ast.Attribute) + and _is_config_field(target.attr) + and _references_shared_config(target) + ): + self.lines.append(node.lineno) + self.generic_visit(node) + + +def _find_direct_config_mutations(path: Path) -> list[int]: + visitor = _DirectConfigMutationVisitor() + visitor.visit(ast.parse(path.read_text(), filename=str(path))) + return visitor.lines + def test_config_overrides_updates_shared_config(config_overrides: Callable[..., None]) -> None: config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) @@ -17,3 +106,14 @@ def test_config_overrides_updates_shared_config(config_overrides: Callable[..., def test_config_overrides_rejects_unknown_fields(config_overrides: Callable[..., None]) -> None: with pytest.raises(ValueError, match=r"Unknown DifyConfig fields: \['NOT_A_CONFIG_FIELD'\]"): config_overrides(NOT_A_CONFIG_FIELD=True) + + +def test_unit_tests_use_validated_config_overrides() -> None: + """Keep global application config mutations centralized and automatically restored.""" + violations = { + str(path.relative_to(_UNIT_TEST_ROOT)): lines + for path in _UNIT_TEST_ROOT.rglob("*.py") + if path != _AUTHORIZED_MUTATION_FILE and (lines := _find_direct_config_mutations(path)) + } + + assert violations == {}, f"Use config_overrides or config_overrides_context instead: {violations}" diff --git a/api/tests/unit_tests/test_constants.py b/api/tests/unit_tests/test_constants.py index e40744a894a..d66cdff65db 100644 --- a/api/tests/unit_tests/test_constants.py +++ b/api/tests/unit_tests/test_constants.py @@ -4,6 +4,7 @@ import pytest import constants from configs import dify_config +from tests.unit_tests.config_override import apply_config_overrides @pytest.mark.parametrize("etl_type", ["SelfHosted", "Unstructured"]) @@ -12,14 +13,16 @@ def test_document_extensions_include_odt_for_document_etl_modes(monkeypatch: pyt original_unstructured_api_url = dify_config.UNSTRUCTURED_API_URL try: - monkeypatch.setattr(dify_config, "ETL_TYPE", etl_type) - monkeypatch.setattr(dify_config, "UNSTRUCTURED_API_URL", None) + apply_config_overrides(monkeypatch, ETL_TYPE=etl_type, UNSTRUCTURED_API_URL=None) reloaded_constants = importlib.reload(constants) assert "odt" in reloaded_constants.DOCUMENT_EXTENSIONS assert "ODT" in reloaded_constants.DOCUMENT_EXTENSIONS finally: - monkeypatch.setattr(dify_config, "ETL_TYPE", original_etl_type) - monkeypatch.setattr(dify_config, "UNSTRUCTURED_API_URL", original_unstructured_api_url) + apply_config_overrides( + monkeypatch, + ETL_TYPE=original_etl_type, + UNSTRUCTURED_API_URL=original_unstructured_api_url, + ) importlib.reload(constants) diff --git a/cli/AGENTS.md b/cli/AGENTS.md index 446c85abdcc..2b579f401ff 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -17,6 +17,7 @@ Run package scripts from `cli/`: - Source CLI: `pnpm dev [args...]` - Tests: `pnpm test` - Build: `pnpm build` +- Build a local binary: `pnpm build:bin:local` (pins `DIFYCTL_CHANNEL=dev` so it does not self-report the release channel) - Regenerate and verify the registry: `pnpm tree:gen` and `pnpm tree:check` Run the scoped static check from the repository root with `vp check cli`. diff --git a/cli/bin/dev.js b/cli/bin/dev.js index c0a1f3b9775..0a071a1764e 100755 --- a/cli/bin/dev.js +++ b/cli/bin/dev.js @@ -2,7 +2,9 @@ import { resolveBuildInfo } from '../scripts/lib/resolve-buildinfo.ts' -const info = resolveBuildInfo() +const info = resolveBuildInfo({ + env: { ...process.env, DIFYCTL_CHANNEL: process.env.DIFYCTL_CHANNEL ?? 'dev' }, +}) globalThis.__DIFYCTL_VERSION__ = info.version globalThis.__DIFYCTL_COMMIT__ = info.commit globalThis.__DIFYCTL_BUILD_DATE__ = info.buildDate diff --git a/cli/package.json b/cli/package.json index 6e0a20bfb1c..1c49f13137a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@langgenius/difyctl", - "version": "0.2.0-alpha", + "version": "1.17.0", "description": "Dify command-line interface", "license": "Apache-2.0", "files": [ @@ -32,7 +32,8 @@ "ci": "pnpm tree:check && pnpm test:coverage && pnpm build", "clean": "rm -rf dist node_modules/.cache", "version:info": "bun scripts/print-buildinfo.ts", - "build:bin": "scripts/release-build.sh" + "build:bin": "scripts/release-build.sh", + "build:bin:local": "DIFYCTL_CHANNEL=dev scripts/release-build.sh" }, "dependencies": { "@dify/contracts": "workspace:*", @@ -68,7 +69,7 @@ "node": "^22.22.1" }, "difyctl": { - "channel": "alpha", + "channel": "stable", "compat": { "minDify": "1.16.0", "maxDify": "1.17.0" diff --git a/cli/scripts/install-local.sh b/cli/scripts/install-local.sh index 892bddf9d90..2add943cd4c 100755 --- a/cli/scripts/install-local.sh +++ b/cli/scripts/install-local.sh @@ -1,6 +1,6 @@ #!/bin/sh # install-local.sh — install difyctl from locally built standalone binaries. -# Run via: pnpm install:local (after `pnpm build:bin`) +# Run via: pnpm install:local (after `pnpm build:bin:local`) # # Consumes the raw, self-contained binaries emitted by scripts/release-build.sh # into dist/bin (difyctl-v--). No GitHub Release needed: build on @@ -30,7 +30,7 @@ BINARY="$(ls "${ARTIFACT_DIR}"/difyctl-v*-${os}-${arch} 2>/dev/null | sort -V | if [ -z "$BINARY" ]; then echo "no binary found for ${os}-${arch} in ${ARTIFACT_DIR:-}" >&2 - echo "run: pnpm build:bin" >&2 + echo "run: pnpm build:bin:local" >&2 exit 1 fi diff --git a/cli/scripts/release-build.sh b/cli/scripts/release-build.sh index b7f5f52792b..16dd9a89081 100755 --- a/cli/scripts/release-build.sh +++ b/cli/scripts/release-build.sh @@ -14,8 +14,8 @@ # Env (all optional; defaults derived from cli/package.json + git): # CLI_VERSION — package.json `version` # DIFYCTL_CHANNEL — package.json `difyctl.channel` -# DIFYCTL_MIN_DIFY — package.json `difyctl.compat.minDify` -# DIFYCTL_MAX_DIFY — package.json `difyctl.compat.maxDify` +# DIFYCTL_MIN_DIFY — package.json `difyctl.compat.minDify`; must be X.Y.Z +# DIFYCTL_MAX_DIFY — package.json `difyctl.compat.maxDify`; must be X.Y.Z # DIFYCTL_COMMIT — `git rev-parse HEAD` (or "unknown") # DIFYCTL_BUILD_DATE — current UTC time # @@ -35,6 +35,10 @@ out_dir="${cli_root}/dist/bin" read_pkg() { node -p "require('${cli_root}/package.json').$1" 2>/dev/null; } naming() { node "${_dir}/release-naming.mjs" "$@"; } +require_bound() { + [[ "$2" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + || die "$1 must be a plain X.Y.Z version, got '$2'" +} CLI_VERSION="${CLI_VERSION:-$(read_pkg version)}" DIFYCTL_CHANNEL="${DIFYCTL_CHANNEL:-$(read_pkg difyctl.channel)}" @@ -43,6 +47,9 @@ DIFYCTL_MAX_DIFY="${DIFYCTL_MAX_DIFY:-$(read_pkg difyctl.compat.maxDify)}" DIFYCTL_COMMIT="${DIFYCTL_COMMIT:-$(git -C "$cli_root" rev-parse HEAD 2>/dev/null || echo unknown)}" DIFYCTL_BUILD_DATE="${DIFYCTL_BUILD_DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" +require_bound DIFYCTL_MIN_DIFY "$DIFYCTL_MIN_DIFY" +require_bound DIFYCTL_MAX_DIFY "$DIFYCTL_MAX_DIFY" + [[ "$CLI_VERSION" != "undefined" ]] || die "CLI_VERSION could not be derived from package.json" [[ -f "$entry" ]] || die "entry not found: $entry" diff --git a/cli/scripts/release-guards.test.ts b/cli/scripts/release-guards.test.ts new file mode 100644 index 00000000000..a6fea0f9e45 --- /dev/null +++ b/cli/scripts/release-guards.test.ts @@ -0,0 +1,120 @@ +import { spawnSync } from 'node:child_process' +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vite-plus/test' + +const posix = (p: string) => p.replace(/\\/g, '/') + +const SCRIPTS_DIR = posix(fileURLToPath(new URL('.', import.meta.url))).replace(/\/$/, '') + +const BUILD_SH = 'release-build.sh' + +type Run = { code: number; stderr: string } + +const STUB_BUN = ['#!/bin/sh', 'echo "release-guards: bun must not run" >&2', 'exit 90', ''].join( + '\n', +) + +const FAKE_MANIFEST = { + version: '7.7.7', + difyctl: { + channel: 'stable', + compat: { minDify: '2.0.0', maxDify: '2.5.0' }, + release: { + tagPrefix: 'difyctl-v', + binName: 'difyctl', + checksumsSuffix: '-checksums.txt', + targets: [{ id: 'linux-x64', bunTarget: 'bun-linux-x64', exe: false }], + }, + }, +} + +function tempDir(prefix: string): string { + return posix(mkdtempSync(join(tmpdir(), prefix))) +} + +function runScript( + script: string, + cliVersion?: string, + extraEnv: Record = {}, +): Run { + const stubDir = tempDir('difyctl-stub-bun-') + writeFileSync(`${stubDir}/bun`, STUB_BUN) + chmodSync(`${stubDir}/bun`, 0o755) + try { + const merged: Record = { + ...process.env, + PATH: `${stubDir}:${process.env.PATH ?? ''}`, + CLI_VERSION: cliVersion, + ...extraEnv, + } + const childEnv: Record = {} + for (const [key, value] of Object.entries(merged)) { + if (value !== undefined) childEnv[key] = value + } + const r = spawnSync('bash', [script], { encoding: 'utf8', env: childEnv }) + return { code: r.status ?? 1, stderr: r.stderr ?? '' } + } finally { + rmSync(stubDir, { recursive: true, force: true }) + } +} + +function fakeCliRoot(scriptName: string): string { + const root = tempDir('difyctl-release-guard-') + mkdirSync(`${root}/scripts/lib`, { recursive: true }) + cpSync(`${SCRIPTS_DIR}/${scriptName}`, `${root}/scripts/${scriptName}`) + cpSync(`${SCRIPTS_DIR}/lib/common.sh`, `${root}/scripts/lib/common.sh`) + cpSync(`${SCRIPTS_DIR}/release-naming.mjs`, `${root}/scripts/release-naming.mjs`) + writeFileSync(`${root}/package.json`, JSON.stringify(FAKE_MANIFEST)) + return root +} + +// Always against a throwaway root: a valid bound carries the script through to +// `rm -rf "$out_dir"`, which against the real cli root deletes a developer's build. +function runInFakeRoot(extraEnv: Record): Run { + const root = fakeCliRoot(BUILD_SH) + try { + return runScript(`${root}/scripts/${BUILD_SH}`, '2.4.0', extraEnv) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +describe.skipIf(process.platform === 'win32')('release-build.sh compat bounds', () => { + for (const bound of ['DIFYCTL_MIN_DIFY', 'DIFYCTL_MAX_DIFY']) { + it.each(['undefined', '1.16'])(`rejects ${bound}=%s`, (bad) => { + const r = runInFakeRoot({ [bound]: bad }) + expect(r.code).not.toBe(0) + expect(r.stderr).toContain(bound) + }) + } + + it('does not wipe dist/bin when a bound guard fires', () => { + const root = fakeCliRoot(BUILD_SH) + const sentinel = `${root}/dist/bin/prior-build` + try { + mkdirSync(`${root}/dist/bin`, { recursive: true }) + writeFileSync(sentinel, 'output from an earlier build') + mkdirSync(`${root}/bin`, { recursive: true }) + writeFileSync(`${root}/bin/run.ts`, 'export {}\n') + + const r = runScript(`${root}/scripts/${BUILD_SH}`, '2.4.0', { + DIFYCTL_MIN_DIFY: 'undefined', + }) + expect(r.code).not.toBe(0) + expect(existsSync(sentinel)).toBe(true) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/cli/scripts/release-naming.mjs b/cli/scripts/release-naming.mjs index d9fcc75c441..2af839b1f2e 100644 --- a/cli/scripts/release-naming.mjs +++ b/cli/scripts/release-naming.mjs @@ -14,7 +14,11 @@ // channels -> one channel name per line // prerelease -> "true" | "false" // github-env -> key=value lines (all fields CI needs) for $GITHUB_ENV -// validate -> exit 1 if difyctl.release, version, or channel is malformed +// edge-version -> -edge. +// validate -> exit 1 if difyctl.release, version, channel, or +// difyctl.compat is malformed +// validate-version +// -> exit 1 unless version matches the channel's form // compat-check -> exit 1 if difyVer outside compat.minDify..maxDify import { readFileSync, realpathSync } from 'node:fs' @@ -22,6 +26,8 @@ import { fileURLToPath } from 'node:url' const BUN_TARGET_RE = /^bun-(linux|darwin|windows)-(x64|arm64)$/ const SEMVER_CORE_LEN = 3 +const SEMVER_CORE_RE = /^\d+\.\d+\.\d+$/ +const COMPAT_BOUNDS = ['minDify', 'maxDify'] // Add channels here: { name, prerelease, versionForm }. const CHANNELS = [ @@ -51,7 +57,7 @@ function edgeVersion(sha) { die('edge-version requires a git short sha (7-40 hex chars)') const { version } = loadPkg() const core = versionCore(version) - if (!/^\d+\.\d+\.\d+$/.test(core)) die(`cannot derive edge base from version: ${version}`) + if (!SEMVER_CORE_RE.test(core)) die(`cannot derive edge base from version: ${version}`) return `${core}-edge.${sha}` } @@ -191,6 +197,15 @@ function validateVersionChannel(version, channel) { return problem ? [problem] : [] } +function validateCompat(compat) { + const problems = COMPAT_BOUNDS.filter((b) => !SEMVER_CORE_RE.test(compat[b] ?? '')).map( + (b) => `difyctl.compat.${b} must be a plain X.Y.Z version, found ${compat[b] ?? '(missing)'}`, + ) + if (problems.length === 0 && comparePrecedence(compat.minDify, compat.maxDify) > 0) + problems.push(`difyctl.compat.minDify (${compat.minDify}) is above maxDify (${compat.maxDify})`) + return problems +} + function main(argv) { const [cmd, ...rest] = argv switch (cmd) { @@ -236,11 +251,15 @@ function main(argv) { return String(ch.prerelease) } case 'validate': { - const { version, channel, release } = loadPkg() - const problems = [...validateRelease(release), ...validateVersionChannel(version, channel)] + const { version, channel, compat, release } = loadPkg() + const problems = [ + ...validateRelease(release), + ...validateCompat(compat), + ...validateVersionChannel(version, channel), + ] if (problems.length > 0) die(`invalid difyctl release config:\n - ${problems.join('\n - ')}`) - return `difyctl release valid: version=${version} channel=${channel} targets=${release.targets.length}` + return `difyctl release valid: version=${version} channel=${channel} compat=${compat.minDify}..${compat.maxDify} targets=${release.targets.length}` } case 'edge-version': return edgeVersion(rest[0]) diff --git a/cli/scripts/release-naming.test.ts b/cli/scripts/release-naming.test.ts index 306cf83f809..547fe55f1e4 100644 --- a/cli/scripts/release-naming.test.ts +++ b/cli/scripts/release-naming.test.ts @@ -1,4 +1,6 @@ +import type { PkgManifestOverrides } from '../test/fixtures/pkg-manifest' import { execFileSync } from 'node:child_process' +import { readFileSync, writeFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vite-plus/test' import { @@ -6,7 +8,6 @@ import { FIXTURE_COMPAT, FIXTURE_TAG_PREFIX, FIXTURE_VERSION, - FIXTURE_VERSION_CORE, pkgManifestEnv, } from '../test/fixtures/pkg-manifest' @@ -14,11 +15,13 @@ const SCRIPT = fileURLToPath(new URL('./release-naming.mjs', import.meta.url)) const PKG_ENV = pkgManifestEnv() -function run(args: string[]): { code: number; stdout: string; stderr: string } { +type RunResult = { code: number; stdout: string; stderr: string } + +function exec(args: string[], pkgEnv: Record): RunResult { try { const stdout = execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8', - env: { ...process.env, ...PKG_ENV }, + env: { ...process.env, ...pkgEnv }, }) return { code: 0, stdout, stderr: '' } } catch (e) { @@ -27,6 +30,38 @@ function run(args: string[]): { code: number; stdout: string; stderr: string } { } } +function run(args: string[]): RunResult { + return exec(args, PKG_ENV) +} + +function runWith(overrides: PkgManifestOverrides, args: string[]): RunResult { + return exec(args, pkgManifestEnv(overrides)) +} + +type FixtureManifest = { + version?: string + difyctl: { channel?: string; compat: { minDify?: string; maxDify?: string } } +} + +function runOnManifest(mutate: (manifest: FixtureManifest) => void, args: string[]): RunResult { + const pkgEnv = pkgManifestEnv() + const [pkgPath] = Object.values(pkgEnv) + if (!pkgPath) throw new Error('pkgManifestEnv returned no manifest path') + const manifest = JSON.parse(readFileSync(pkgPath, 'utf8')) as FixtureManifest + mutate(manifest) + writeFileSync(pkgPath, JSON.stringify(manifest)) + return exec(args, pkgEnv) +} + +function parseKeyValues(stdout: string): Record { + return Object.fromEntries( + stdout + .split('\n') + .filter(Boolean) + .map((line) => [line.slice(0, line.indexOf('=')), line.slice(line.indexOf('=') + 1)]), + ) +} + describe('release-naming compat-check', () => { const { minDify, maxDify } = FIXTURE_COMPAT // 2.0.0 .. 2.5.0 const compatCheck = (difyVersion?: string) => @@ -64,10 +99,6 @@ describe('release-naming compat-check', () => { expect(compatCheck(`${maxDify}+build123`)).toBe(0) }) - it('ignores build metadata when out of range', () => { - expect(compatCheck('2.5.1+build123')).not.toBe(0) - }) - it('requires a version argument', () => { expect(compatCheck()).not.toBe(0) }) @@ -75,16 +106,11 @@ describe('release-naming compat-check', () => { describe('release-naming github-env', () => { it('emits every manifest field for $GITHUB_ENV, plus a composed difyctlTag', () => { - const fields = Object.fromEntries( - run(['github-env']) - .stdout.split('\n') - .filter(Boolean) - .map((line) => [line.slice(0, line.indexOf('=')), line.slice(line.indexOf('=') + 1)]), - ) + const fields = parseKeyValues(run(['github-env']).stdout) expect(fields).toEqual({ version: FIXTURE_VERSION, channel: FIXTURE_CHANNEL, - prerelease: 'true', + prerelease: 'false', minDify: FIXTURE_COMPAT.minDify, maxDify: FIXTURE_COMPAT.maxDify, tagPrefix: FIXTURE_TAG_PREFIX, @@ -94,34 +120,101 @@ describe('release-naming github-env', () => { }) describe('release-naming edge channel', () => { - it('lists edge among channels', () => { - expect(run(['channels']).stdout).toMatch(/^edge$/m) - }) - - it('edge-version derives -edge. from the package version', () => { - expect(run(['edge-version', '2fd7b82']).stdout.trim()).toBe( - `${FIXTURE_VERSION_CORE}-edge.2fd7b82`, - ) + it('edge-version derives -edge. from the package version', () => { + expect(run(['edge-version', '2fd7b82']).stdout.trim()).toBe(`${FIXTURE_VERSION}-edge.2fd7b82`) }) it('edge-version accepts a 40-char sha', () => { const sha = '2fd7b829e1f0aaaabbbbccccddddeeeeffff0000' - expect(run(['edge-version', sha]).stdout.trim()).toBe(`${FIXTURE_VERSION_CORE}-edge.${sha}`) + expect(run(['edge-version', sha]).stdout.trim()).toBe(`${FIXTURE_VERSION}-edge.${sha}`) }) it('edge-version rejects a non-hex sha', () => { expect(run(['edge-version', 'nothex!']).code).not.toBe(0) }) - it('edge-version requires a sha argument', () => { - expect(run(['edge-version']).code).not.toBe(0) - }) - - it('the edge version form matches a computed edge version', () => { - expect(run(['validate-version', '0.1.0-edge.2fd7b82', 'edge']).code).toBe(0) + it('edge-version fails when the manifest carries no version', () => { + const { code, stderr } = runOnManifest( + (m) => { + delete m.version + }, + ['edge-version', '2fd7b82'], + ) + expect(code).not.toBe(0) + expect(stderr).toContain('cannot derive edge base from version') }) it('validate-version rejects an rc string under the edge channel', () => { expect(run(['validate-version', '0.1.0-rc.1', 'edge']).code).not.toBe(0) }) }) + +describe('release-naming validate channel', () => { + const validateChannel = (channel: string) => runWith({ channel }, ['validate']) + + it.each<[string, string]>([ + ['stable', FIXTURE_VERSION], + ['alpha', `${FIXTURE_VERSION}-alpha`], + ['rc', `${FIXTURE_VERSION}-rc.1`], + ['edge', `${FIXTURE_VERSION}-edge.2fd7b82`], + ])('accepts the %s channel with a version in its form', (channel, version) => { + expect(runWith({ channel, version }, ['validate']).code).toBe(0) + }) + + it('rejects a typo of a real channel and names it', () => { + const { code, stderr } = validateChannel('stabel') + expect(code).not.toBe(0) + expect(stderr).toContain('unknown channel: stabel') + }) + + it('rejects a manifest with no channel at all', () => { + const { code, stderr } = runOnManifest( + (m) => { + delete m.difyctl.channel + }, + ['validate'], + ) + expect(code).not.toBe(0) + expect(stderr).toContain('unknown channel') + }) +}) + +describe('release-naming validate compat bounds', () => { + const validateCompat = (minDify: string, maxDify: string) => + runWith({ compat: { minDify, maxDify } }, ['validate']) + + it('accepts a well-formed window', () => { + expect(validateCompat('1.16.0', '1.17.0').code).toBe(0) + }) + + it('accepts equal bounds', () => { + expect(validateCompat('1.17.0', '1.17.0').code).toBe(0) + }) + + it('rejects an inverted window', () => { + const { code, stderr } = validateCompat('1.18.0', '1.17.0') + expect(code).not.toBe(0) + expect(stderr).toContain('is above maxDify') + }) + + it.each(['1.x', '1.16', '1.16.0-rc1', ''])('rejects %s as a bound', (bad) => { + expect(validateCompat(bad, '2.9.0').code).not.toBe(0) + expect(validateCompat('1.0.0', bad).code).not.toBe(0) + }) + + it('names the offending bound', () => { + expect(validateCompat('1.x', '1.17.0').stderr).toContain('difyctl.compat.minDify') + expect(validateCompat('1.16.0', '1.x').stderr).toContain('difyctl.compat.maxDify') + }) +}) + +describe('release-naming validate-version', () => { + it('accepts build metadata on a stable version', () => { + expect(run(['validate-version', '1.16.1+r2', 'stable']).code).toBe(0) + }) + + it('accepts an alpha version under the alpha channel, with or without a counter', () => { + expect(run(['validate-version', '1.16.1-alpha', 'alpha']).code).toBe(0) + expect(run(['validate-version', '1.16.1-alpha.2', 'alpha']).code).toBe(0) + }) +}) diff --git a/cli/scripts/release-validate-manifest.sh b/cli/scripts/release-validate-manifest.sh deleted file mode 100755 index 61f5325080b..00000000000 --- a/cli/scripts/release-validate-manifest.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -# scripts/release-validate-manifest.sh — validate cli/package.json release fields. - -set -euo pipefail - -_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=lib/common.sh -source "${_dir}/lib/common.sh" - -cd "$(cli::root)" - -SEMVER_RE='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$' - -version=$(node -p "require('./package.json').version") -channel=$(node -p "require('./package.json').difyctl.channel") -min_dify=$(node -p "require('./package.json').difyctl.compat.minDify") -max_dify=$(node -p "require('./package.json').difyctl.compat.maxDify") - -# Version form (per channel) and channel validity are enforced by -# release-naming.mjs validate below — the single source for those rules. - -[[ "$min_dify" =~ $SEMVER_RE ]] || die "invalid difyctl.compat.minDify: ${min_dify}" -[[ "$max_dify" =~ $SEMVER_RE ]] || die "invalid difyctl.compat.maxDify: ${max_dify}" - -case "$min_dify" in *[xX*]*) die "wildcards not allowed in minDify: ${min_dify}" ;; esac -case "$max_dify" in *[xX*]*) die "wildcards not allowed in maxDify: ${max_dify}" ;; esac - -cmp=$(node -e " -const a = process.argv[1].split('-')[0].split('.').map(Number) -const b = process.argv[2].split('-')[0].split('.').map(Number) -for (let i = 0; i < 3; i++) { - if (a[i] !== b[i]) { console.log(a[i] < b[i] ? -1 : 1); process.exit(0) } -} -console.log(0) -" "$min_dify" "$max_dify") - -[[ "$cmp" -le 0 ]] || die "minDify (${min_dify}) > maxDify (${max_dify})" - -node "${_dir}/release-naming.mjs" validate >/dev/null - -log::info "manifest valid: version=${version} channel=${channel} compat=${min_dify}..${max_dify}" diff --git a/cli/scripts/release-write-checksums.sh b/cli/scripts/release-write-checksums.sh index b9e1cf6960e..1c7301a6125 100755 --- a/cli/scripts/release-write-checksums.sh +++ b/cli/scripts/release-write-checksums.sh @@ -19,7 +19,7 @@ cd "$(cli::root)/dist/bin" manifest="$(naming checksums "$CLI_VERSION")" asset_prefix="$(naming tag-prefix)${CLI_VERSION}-" -> "$manifest" +: > "$manifest" if command -v sha256sum >/dev/null 2>&1; then hash_cmd="sha256sum" diff --git a/cli/src/version/render.test.ts b/cli/src/version/render.test.ts index 46361bf85b0..4672bc4c7e1 100644 --- a/cli/src/version/render.test.ts +++ b/cli/src/version/render.test.ts @@ -69,18 +69,6 @@ describe('renderVersionText', () => { expect(text).toContain('install or wait for the stable channel') }) - it('appends warning when channel is alpha', () => { - const report: VersionReport = { - client: baseClient({ channel: 'alpha' }), - server: { endpoint: '', reachable: false }, - compat: { ...compatible(), status: 'unknown', detail: 'server probe skipped' }, - } - const text = renderVersionText(report) - - expect(text).toContain('WARNING: This build is a(n) alpha release') - expect(text).toContain('install or wait for the stable channel') - }) - it('appends warning when channel is edge', () => { const report: VersionReport = { client: baseClient({ channel: 'edge' }), diff --git a/cli/test/fixtures/pkg-manifest.ts b/cli/test/fixtures/pkg-manifest.ts index 7e65f1ae1bb..dd99cb6f02b 100644 --- a/cli/test/fixtures/pkg-manifest.ts +++ b/cli/test/fixtures/pkg-manifest.ts @@ -10,15 +10,14 @@ const PKG_PATH_ENV = 'DIFYCTL_PKG_PATH' // release-naming.mjs and release-r2-edge.mjs read their data from // cli/package.json. Tests spawn them against this fixture instead, so -// assertions can name exact versions without tracking the live release. +// assertions can name exact windows without tracking the live release. // Deliberately far from any real Dify version, and min != max so "inside the // window" is a case distinct from either bound. export const FIXTURE_COMPAT = { minDify: '2.0.0', maxDify: '2.5.0' } -export const FIXTURE_VERSION_CORE = '7.7.7' -export const FIXTURE_VERSION = `${FIXTURE_VERSION_CORE}-alpha` -export const FIXTURE_CHANNEL = 'alpha' +export const FIXTURE_VERSION = '7.7.7' +export const FIXTURE_CHANNEL = 'stable' export const FIXTURE_TAG_PREFIX = 'difyctl-v' export const FIXTURE_TARGET_IDS = [ diff --git a/cli/test/scripts/resolve-buildinfo.test.ts b/cli/test/scripts/resolve-buildinfo.test.ts index 17e07a02cca..e80f16ffdb3 100644 --- a/cli/test/scripts/resolve-buildinfo.test.ts +++ b/cli/test/scripts/resolve-buildinfo.test.ts @@ -1,5 +1,15 @@ -import { describe, expect, it } from 'vite-plus/test' -import { resolveBuildInfo } from '../../scripts/lib/resolve-buildinfo.js' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, describe, expect, it } from 'vite-plus/test' +import { BUILD_CHANNELS, resolveBuildInfo } from '../../scripts/lib/resolve-buildinfo.js' +import { ENV_CACHE_DIR, ENV_CONFIG_DIR } from '../../src/store/dir.js' + +const CLI_ROOT = new URL('../../', import.meta.url) +const RELEASE_NAMING = fileURLToPath(new URL('scripts/release-naming.mjs', CLI_ROOT)) +const DEV_ENTRY = fileURLToPath(new URL('bin/dev.js', CLI_ROOT)) const FIXED_DATE = new Date('2026-05-09T12:00:00.000Z') const fixedNow = () => FIXED_DATE @@ -80,6 +90,16 @@ describe('resolveBuildInfo', () => { ).toThrow(/invalid DIFYCTL_CHANNEL: nightly/) }) + it('accepts alpha channel', () => { + const info = resolveBuildInfo({ + env: { DIFYCTL_CHANNEL: 'alpha' }, + git: noGit, + now: fixedNow, + pkg: noPkg, + }) + expect(info.channel).toBe('alpha') + }) + it('accepts rc channel', () => { const info = resolveBuildInfo({ env: { @@ -161,3 +181,50 @@ describe('resolveBuildInfo', () => { expect(info.channel).toBe('stable') }) }) + +function releaseNamingChannels(): string[] { + return execFileSync('node', [RELEASE_NAMING, 'channels'], { encoding: 'utf8' }) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) +} + +const sorted = (names: readonly string[]) => [...names].sort() + +describe('channel list parity', () => { + const LOCAL_ONLY_CHANNEL = 'dev' + + it('released channels are the build channels minus the local-only one', () => { + expect(sorted(releaseNamingChannels())).toStrictEqual( + sorted(BUILD_CHANNELS.filter((name) => name !== LOCAL_ONLY_CHANNEL)), + ) + }) +}) + +type ClientVersionReport = { client: { channel: string } } + +describe('bin/dev.js pins the local build channel', () => { + const ENV_CHANNEL = 'DIFYCTL_CHANNEL' + const stateDir = mkdtempSync(join(tmpdir(), 'difyctl-dev-channel-')) + afterAll(() => rmSync(stateDir, { recursive: true, force: true })) + + function reportedChannel(channelOverride?: string): string { + const env: NodeJS.ProcessEnv = { + ...process.env, + [ENV_CONFIG_DIR]: stateDir, + [ENV_CACHE_DIR]: stateDir, + } + if (channelOverride === undefined) delete env[ENV_CHANNEL] + else env[ENV_CHANNEL] = channelOverride + const stdout = execFileSync('bun', [DEV_ENTRY, 'version', '--client', '--output', 'json'], { + cwd: fileURLToPath(CLI_ROOT), + encoding: 'utf8', + env, + }) + return (JSON.parse(stdout) as ClientVersionReport).client.channel + } + + it('reports dev when the env does not set a channel', { timeout: 30_000 }, () => { + expect(reportedChannel()).toBe('dev') + }) +}) diff --git a/cli/vite.config.ts b/cli/vite.config.ts index 28ba19f5689..85b0e264cbe 100644 --- a/cli/vite.config.ts +++ b/cli/vite.config.ts @@ -2,7 +2,9 @@ import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite-plus' import { resolveBuildInfo } from './scripts/lib/resolve-buildinfo.js' -const buildInfo = resolveBuildInfo() +const buildInfo = resolveBuildInfo({ + env: { ...process.env, DIFYCTL_CHANNEL: process.env.DIFYCTL_CHANNEL ?? 'dev' }, +}) export default defineConfig({ resolve: { diff --git a/dify-agent/.example.env b/dify-agent/.example.env index 1466a89d947..50294c9bf85 100644 --- a/dify-agent/.example.env +++ b/dify-agent/.example.env @@ -4,7 +4,7 @@ # Redis # Redis connection URL for run records and per-run event streams. -DIFY_AGENT_REDIS_URL=redis://:difyai123456localhost:6379/0 +DIFY_AGENT_REDIS_URL=redis://:difyai123456@localhost:6379/0 # Prefix for Redis run-record and event-stream keys. DIFY_AGENT_REDIS_PREFIX=dify-agent @@ -24,14 +24,14 @@ DIFY_AGENT_PLUGIN_DAEMON_API_KEY=lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc # Base URL for Dify API inner endpoints used by Agent Stub config and file requests. DIFY_AGENT_INNER_API_URL=http://localhost:5001 # Must match API/worker INNER_API_KEY_FOR_PLUGIN, not the generic INNER_API_KEY. -DIFY_AGENT_INNER_API_KEY= +DIFY_AGENT_INNER_API_KEY=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1 # Runtime resources # Select one coherent Home Snapshot + Execution Binding backend: local, enterprise, or e2b. DIFY_AGENT_RUNTIME_BACKEND=local # Local backend: shellctl data-plane URL and optional bearer token. # Leave the endpoint empty when this server will not provide dify.runtime or resource endpoints. -DIFY_AGENT_LOCAL_SANDBOX_ENDPOINT= +DIFY_AGENT_LOCAL_SANDBOX_ENDPOINT=http://localhost:5004 DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN= # Enterprise resource operations currently fail fast with NotImplementedError. # These names are retained for the configured Enterprise Gateway boundary. @@ -54,12 +54,12 @@ DIFY_AGENT_SHELL_REDACT_PATTERNS= # Public Agent Stub URL reachable from shellctl-managed remote machines. # Use an HTTP(S) service root or an explicit /agent-stub API root. # Leave empty to avoid injecting DIFY_AGENT_STUB_* into shell.run jobs. -DIFY_AGENT_STUB_API_BASE_URL=http://localhost:5050/agent-stub +DIFY_AGENT_STUB_API_BASE_URL=http://host.docker.internal:5050/agent-stub # Optional bind override used only when DIFY_AGENT_STUB_API_BASE_URL uses grpc://. DIFY_AGENT_STUB_GRPC_BIND_ADDRESS= # Dify API base URL reachable from the Sandbox for the signed /files/* data plane, # including Config file and skill pulls. -DIFY_AGENT_SANDBOX_FILES_BASE_URL=http://localhost:5001 +DIFY_AGENT_SANDBOX_FILES_BASE_URL=http://host.docker.internal:5001 # Maximum Agent Stub upload size in MiB; forwarded to Dify API as a signed byte limit. DIFY_AGENT_STUB_UPLOAD_FILE_SIZE_LIMIT=50 # Shell command deadline for converting a Binding file to a ToolFile. @@ -84,6 +84,6 @@ DIFY_AGENT_OUTBOUND_HTTP_POOL_TIMEOUT=10 DIFY_AGENT_OUTBOUND_HTTP_MAX_CONNECTIONS=100 DIFY_AGENT_OUTBOUND_HTTP_MAX_KEEPALIVE_CONNECTIONS=20 DIFY_AGENT_OUTBOUND_HTTP_KEEPALIVE_EXPIRY=30 -DIFY_AGENT_LOCAL_SANDBOX_MATERIALIZED_HOME_ROOT= -DIFY_AGENT_LOCAL_SANDBOX_WORKSPACE_ROOT= -DIFY_AGENT_LOCAL_SANDBOX_HOME_SNAPSHOT_ROOT= +DIFY_AGENT_LOCAL_SANDBOX_MATERIALIZED_HOME_ROOT=/home/dify +DIFY_AGENT_LOCAL_SANDBOX_WORKSPACE_ROOT=/workspace +DIFY_AGENT_LOCAL_SANDBOX_HOME_SNAPSHOT_ROOT=/home/dify/.snapshots diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml index 9fa3bc98f46..0f32cb0a6bf 100644 --- a/docker/docker-compose.middleware.yaml +++ b/docker/docker-compose.middleware.yaml @@ -127,6 +127,30 @@ services: networks: - ssrf_proxy_network + # Local sandbox for Dify Agent shell workspaces (shellctl data plane). + # Exposes port 5004 on the host so a locally-run agent backend can reach it + # at http://localhost:5004 (DIFY_AGENT_LOCAL_SANDBOX_ENDPOINT). + local_sandbox: + image: langgenius/dify-agent-local-sandbox:1.17.0 + restart: always + env_file: + - ./middleware.env + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + SHELLCTL_AUTH_TOKEN: ${DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN:-${DIFY_AGENT_SHELLCTL_AUTH_TOKEN:-}} + ports: + - "${EXPOSE_LOCAL_SANDBOX_PORT:-5004}:5004" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5004/healthz"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + volumes: + - dify_agent_local_sandbox_home:/home/dify + - dify_agent_local_sandbox_workspace:/workspace + # plugin daemon plugin_daemon: image: langgenius/dify-plugin-daemon:0.6.10-local @@ -259,3 +283,7 @@ networks: ssrf_proxy_network: driver: bridge internal: true + +volumes: + dify_agent_local_sandbox_home: + dify_agent_local_sandbox_workspace: diff --git a/docker/envs/middleware.env.example b/docker/envs/middleware.env.example index 3ff8139ad16..faf8307ff43 100644 --- a/docker/envs/middleware.env.example +++ b/docker/envs/middleware.env.example @@ -106,6 +106,12 @@ SANDBOX_HTTP_PROXY=http://ssrf_proxy:3128 SANDBOX_HTTPS_PROXY=http://ssrf_proxy:3128 SANDBOX_PORT=8194 +# ------------------------------ +# Environment Variables for local_sandbox Service (Dify Agent shell workspaces) +# ------------------------------ +# Leave empty to disable shellctl auth (local development default). +DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN= + # ------------------------------ # Environment Variables for ssrf_proxy Service # ------------------------------ @@ -145,6 +151,7 @@ EXPOSE_POSTGRES_PORT=5432 EXPOSE_MYSQL_PORT=3306 EXPOSE_REDIS_PORT=6379 EXPOSE_SANDBOX_PORT=8194 +EXPOSE_LOCAL_SANDBOX_PORT=5004 EXPOSE_SSRF_PROXY_PORT=3128 EXPOSE_WEAVIATE_PORT=8080 diff --git a/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts b/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts index 29cbc883992..0a30ed03150 100644 --- a/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts +++ b/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts @@ -86,7 +86,7 @@ When( const copyName = createE2EResourceName('Agent', 'copy') await page.goto('/agents') - const card = page.getByRole('article', { name: agentName, exact: true }) + const card = page.getByRole('listitem', { name: agentName, exact: true }) await expect(card).toBeVisible({ timeout: 30_000 }) await card.hover() diff --git a/e2e/features/step-definitions/apps/duplicate-app.steps.ts b/e2e/features/step-definitions/apps/duplicate-app.steps.ts index 707fdf13d8c..71bdf8bf0af 100644 --- a/e2e/features/step-definitions/apps/duplicate-app.steps.ts +++ b/e2e/features/step-definitions/apps/duplicate-app.steps.ts @@ -20,10 +20,13 @@ When('I open the options menu for the last created E2E app', async function (thi const page = this.getPage() await waitForAppsConsole(page, 30_000) const studio = page.getByRole('region', { name: 'Studio' }) - const appLink = studio.getByRole('link', { name: appName, exact: true }) + const appCard = studio.getByRole('listitem').filter({ + has: page.getByRole('link', { name: appName, exact: true }), + }) + const appLink = appCard.getByRole('link', { name: appName, exact: true }) await expect(appLink).toBeVisible() await appLink.hover() - await studio.getByRole('button', { name: `More actions for ${appName}`, exact: true }).click() + await appCard.getByRole('button', { name: `More actions for ${appName}`, exact: true }).click() }) When('I click {string} in the app options menu', async function (this: DifyWorld, label: string) { diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 8d1fd236808..f2c535172a1 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -533,14 +533,6 @@ "count": 2 } }, - "web/app/components/apps/import-from-marketplace-template-modal.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/base/agent-log-modal/detail.tsx": { "typescript/no-explicit-any": { "count": 1 diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 3991a7dfee0..1fb8deaf323 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -9,6 +9,7 @@ export type AgentAppPagination = { has_more: boolean limit: number page: number + publication_counts: AgentPublicationCountsResponse total: number } @@ -467,6 +468,11 @@ export type AgentAppPartial = { workflow?: WorkflowPartial | null } +export type AgentPublicationCountsResponse = { + drafts: number + published: number +} + export type IconType = 'emoji' | 'image' | 'link' export type DeletedTool = { @@ -1733,6 +1739,7 @@ export type AgentAppPaginationWritable = { has_more: boolean limit: number page: number + publication_counts: AgentPublicationCountsResponse total: number } @@ -1854,6 +1861,7 @@ export type GetAgentData = { | 'workflow' name?: string page?: number + publication_status?: 'drafts' | 'published' sort_by?: 'earliest_created' | 'last_modified' | 'recently_created' tag_ids?: Array } diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index d33b96e344b..a6cc2c12347 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -204,6 +204,14 @@ export const zAgentConfigSnapshotRestoreResponse = z.object({ result: z.literal('success'), }) +/** + * AgentPublicationCountsResponse + */ +export const zAgentPublicationCountsResponse = z.object({ + drafts: z.int().gte(0), + published: z.int().gte(0), +}) + /** * IconType */ @@ -871,6 +879,7 @@ export const zAgentAppPagination = z.object({ has_more: z.boolean(), limit: z.int(), page: z.int(), + publication_counts: zAgentPublicationCountsResponse, total: z.int(), }) @@ -2493,6 +2502,7 @@ export const zAgentAppPaginationWritable = z.object({ has_more: z.boolean(), limit: z.int(), page: z.int(), + publication_counts: zAgentPublicationCountsResponse, total: z.int(), }) @@ -2585,6 +2595,7 @@ export const zGetAgentQuery = z.object({ .default('all'), name: z.string().optional(), page: z.int().gte(1).lte(99999).optional().default(1), + publication_status: z.enum(['drafts', 'published']).optional(), sort_by: z .enum(['earliest_created', 'last_modified', 'recently_created']) .optional() diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index 04777b3ea3d..a7175f8b556 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -561,6 +561,7 @@ export type OpenApiErrorCode = | 'request_entity_too_large' | 'too_many_files' | 'too_many_requests' + | 'trigger_workflow_service_mode_unavailable' | 'unauthorized' | 'unknown' | 'unsupported_file_type' diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index 9c430198b2a..7c55f909170 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -738,6 +738,7 @@ export const zOpenApiErrorCode = z.enum([ 'request_entity_too_large', 'too_many_files', 'too_many_requests', + 'trigger_workflow_service_mode_unavailable', 'unauthorized', 'unknown', 'unsupported_file_type', diff --git a/packages/dify-ui/AGENTS.md b/packages/dify-ui/AGENTS.md index 5d02acdc1e0..2cd05f418f9 100644 --- a/packages/dify-ui/AGENTS.md +++ b/packages/dify-ui/AGENTS.md @@ -20,9 +20,9 @@ then read only the guide for the contract being changed. - Imports, exports, naming, public types, generics, and anatomy: [Public API authoring] - Button and icon-only action behavior: [Button contract] and [Icon Button contract] - Compound input behavior: [Input Group contract] -- Form structure and labels: [Forms] +- Form structure, labels, and value ownership: [Forms] - Picker choice and typed values: [Selection] -- Portals, layering, and floating-surface semantics: [Overlays] +- Portals, presence, layering, and floating-surface semantics: [Overlays] - Tailwind integration and radius mapping: [Styling] - Package test ownership and setup: [Testing and development] diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index 5825f310a82..622b4f556b4 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -70,9 +70,9 @@ Upstream behavior remains owned by the [Base UI documentation]. | Guide | Scope | | ------------------------- | ------------------------------------------------------------------------------ | -| [Forms] | Native submit boundaries, fields, labels, grouped controls, and errors. | +| [Forms] | Native submit boundaries, value ownership, fields, labels, and errors. | | [Selection] | Typed values and choosing among segmented controls, pickers, and radio groups. | -| [Overlays] | Portals, root isolation, layering, trigger composition, and semantics. | +| [Overlays] | Portals, presence lifecycles, layering, trigger composition, and semantics. | | [Styling] | Tailwind CSS integration and the Figma radius mapping. | | [Public API authoring] | Subpath exports, naming, public types, generics, and private helpers. | | [Testing and development] | Package commands, test ownership, accessibility, and animation setup. | diff --git a/packages/dify-ui/docs/forms.md b/packages/dify-ui/docs/forms.md index f3d3f21c1fe..862b207ed74 100644 --- a/packages/dify-ui/docs/forms.md +++ b/packages/dify-ui/docs/forms.md @@ -16,6 +16,22 @@ remains correct when another form library owns submission and validation; do not Set [`Button`] submit buttons to `type="submit"` explicitly. Keep every other button inside a form at `type="button"`. +## Value and state ownership + +`Form` owns the submission and validation boundary described above, not each field's draft. + +Choose controlledness from source-of-truth needs, independently from where a draft is stored. +Prefer `defaultValue` when application React code does not need to own the current value. Use +`value` and change handlers when application rendering or coordination must own it while editing. +Listening to change events, tracking dirty state, and native or primitive validation do not by +themselves require controlled state. + +Application code owns the draft in the narrowest component whose lifetime matches it. A value can +be controlled locally without being lifted. An uncontrolled field can participate in a persisted +workflow when that workflow captures its value at an explicit persistence boundary. The +surrounding surface defines its mount lifecycle; owner placement determines whether draft state +lives inside or outside that lifecycle. + ## Fields and labels Use `Field` when a control needs a shared name, label, validation, description, or error state. A @@ -61,9 +77,9 @@ option with `FieldItem` and give it its own label: Every radio belongs to a `RadioGroup`. Use `FieldsetLegend` to name the group and `FieldLabel` to name each option; do not render a standalone `Radio`. -Keep form state, schemas, server validation, and reset behavior outside these primitives. Pass -their observable state through the public field and control props instead of replacing the -semantic structure. +Keep form state, schemas, server validation, and reset behavior outside the primitive internals, +in the nearest application owner with the required lifetime. Pass observable state through the +public field and control props instead of replacing the semantic structure. [Base UI Slider anatomy]: https://base-ui.com/react/components/slider#anatomy [Base UI forms handbook]: https://base-ui.com/react/handbook/forms diff --git a/packages/dify-ui/docs/overlays.md b/packages/dify-ui/docs/overlays.md index 422ce4621f5..1274ce1b274 100644 --- a/packages/dify-ui/docs/overlays.md +++ b/packages/dify-ui/docs/overlays.md @@ -10,6 +10,25 @@ Floating surfaces render through [Base UI Portal] into `document.body`. Convenie such as `DialogContent`, `PopoverContent`, and `SelectContent` own their portals internally; primitives with explicit anatomy expose the constituent portal and content parts. +## Mounting and state lifetime + +An overlay root's React lifetime, its open state, and its portal subtree's presence are separate. +Presence is part of each primitive's contract. Convenience content follows its primitive's +default mount behavior; for example, `DialogContent` owns a [`Dialog.Portal`] whose subtree mounts +when the dialog opens and unmounts after any close transition completes. The `Dialog` root may +remain mounted and controlled independently of that content lifetime. Removing the controlled +root with the same condition that closes it bypasses the primitive's closing lifecycle. + +Application code can use an unmounting content subtree as the owner of state scoped to one mounted +content session. State that must survive the subtree's unmount belongs to an explicit longer-lived +feature owner. +Unmounting resets only DOM and component state owned inside that subtree; state declared by an +ancestor or external store survives. Portal placement alone is not a reset boundary. +Consumers using explicit anatomy may opt into `keepMounted` where that portal supports it; they +must then define which state persists and which state resets instead of relying on a remount. +Check the selected primitive's API rather than assuming every overlay portal has the same presence +options. + The host must establish an isolated stacking context at its application root: ```tsx @@ -58,6 +77,7 @@ spacing unless its API documents a measured exception. [Base UI Portal]: https://base-ui.com/react/overview/quick-start#portals [MDN `isolation`]: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/isolation +[`Dialog.Portal`]: https://base-ui.com/react/components/dialog#portal [`Popover`]: https://base-ui.com/react/components/popover [`PreviewCard`]: https://base-ui.com/react/components/preview-card [`Tooltip`]: https://base-ui.com/react/components/tooltip diff --git a/web/app/(commonLayout)/__tests__/external-service-sync.spec.tsx b/web/app/(commonLayout)/__tests__/external-service-sync.spec.tsx new file mode 100644 index 00000000000..c851acaf03b --- /dev/null +++ b/web/app/(commonLayout)/__tests__/external-service-sync.spec.tsx @@ -0,0 +1,106 @@ +import { render, waitFor } from '@testing-library/react' +import { REGISTRATION_SUCCESS_STORAGE_KEY } from '@/app/components/base/amplitude/registration-session-state' +import { rememberRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking' +import { AmplitudeIdentitySync } from '../external-service-sync' + +const { mockSetUserId, mockSetUserProperties, mockTrackEvent } = vi.hoisted(() => ({ + mockSetUserId: vi.fn(), + mockSetUserProperties: vi.fn(), + mockTrackEvent: vi.fn((..._args: unknown[]) => ({ + promise: Promise.resolve({ code: 200 }), + })), +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + useSuspenseQuery: () => ({ + data: { + id: 'account-id', + email: 'person@example.com', + name: 'Person', + is_password_set: true, + }, + }), + } +}) + +vi.mock('jotai', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + useAtomValue: () => ({ + id: 'workspace-id', + name: 'Workspace', + plan: 'professional', + role: 'owner', + }), + } +}) + +vi.mock('@/features/account-profile/client', () => ({ + userProfileQueryOptions: () => ({}), +})) + +vi.mock('@/app/components/base/amplitude', () => ({ + setUserId: (...args: unknown[]) => mockSetUserId(...args), + setUserProperties: (...args: unknown[]) => mockSetUserProperties(...args), +})) + +vi.mock('@/app/components/base/amplitude/utils', () => ({ + trackEvent: (...args: unknown[]) => mockTrackEvent(...args), +})) + +vi.mock('@/app/components/base/amplitude/init', () => ({ + getIsAmplitudeInitialized: () => true, +})) + +vi.mock('@/app/components/base/analytics-consent/consent-store', async (importOriginal) => { + const original = + await importOriginal() + return { + ...original, + getAnalyticsConsent: () => 'granted', + } +}) + +describe('AmplitudeIdentitySync', () => { + beforeEach(() => { + vi.clearAllMocks() + window.sessionStorage.clear() + vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue( + '11111111-1111-4111-8111-111111111111', + ) + }) + + it('sets identity before flushing a marker that already exists', async () => { + rememberRegistrationSuccess({ method: 'oauth' }) + + render() + + await waitFor(() => expect(mockTrackEvent).toHaveBeenCalledTimes(1)) + expect(mockSetUserId).toHaveBeenCalledWith('person@example.com') + expect(mockSetUserProperties).toHaveBeenCalledTimes(1) + expect(mockSetUserId.mock.invocationCallOrder[0]).toBeLessThan( + mockTrackEvent.mock.invocationCallOrder[0]!, + ) + expect(mockSetUserProperties.mock.invocationCallOrder[0]).toBeLessThan( + mockTrackEvent.mock.invocationCallOrder[0]!, + ) + }) + + it('flushes a marker created after identity sync without repeating unchanged identity updates', async () => { + render() + + await waitFor(() => expect(mockSetUserId).toHaveBeenCalledTimes(1)) + expect(mockTrackEvent).not.toHaveBeenCalled() + + rememberRegistrationSuccess({ method: 'email' }) + + await waitFor(() => expect(mockTrackEvent).toHaveBeenCalledTimes(1)) + expect(mockSetUserId).toHaveBeenCalledTimes(1) + expect(mockSetUserProperties).toHaveBeenCalledTimes(1) + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + }) +}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx index 8c6acc78866..9a52e32d0e7 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx @@ -238,9 +238,11 @@ describe('AppDetailLayout', () => { expect(useStore.getState().appDetail?.id).toBe('app-1') }) - it('should allow access point pages without app deploy or app ACL permissions', async () => { + it('should allow users with access point permission to open access point directly', async () => { mockPathname = '/app/app-1/access-point' - mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [] })) + mockFetchAppDetailDirect.mockResolvedValue( + createAppDetail({ permission_keys: [AppACLPermission.AccessPoint] }), + ) render( @@ -254,6 +256,44 @@ describe('AppDetailLayout', () => { expect(useStore.getState().appDetail?.id).toBe('app-1') }) + it('should redirect access point pages when access point permission is missing', async () => { + mockPathname = '/app/app-1/access-point' + mockFetchAppDetailDirect.mockResolvedValue( + createAppDetail({ permission_keys: [AppACLPermission.Monitor] }), + ) + + render( + +
App page content
+
, + ) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/app/app-1/overview') + }) + expect(screen.queryByText('App page content')).not.toBeInTheDocument() + expect(useStore.getState().appDetail).toBeUndefined() + }) + + it('should keep access point content hidden while redirecting cached app data without permission', async () => { + mockPathname = '/app/app-1/access-point' + useStore + .getState() + .setAppDetail(createAppDetail({ permission_keys: [AppACLPermission.Monitor] })) + + render( + +
App page content
+
, + ) + + expect(screen.queryByText('App page content')).not.toBeInTheDocument() + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/app/app-1/overview') + }) + expect(mockFetchAppDetailDirect).not.toHaveBeenCalled() + }) + it('should redirect deploy pages when app deploy ACL permission is missing', async () => { mockPathname = '/app/app-1/deploy' mockFetchAppDetailDirect.mockResolvedValue( @@ -317,7 +357,7 @@ describe('AppDetailLayout', () => { ) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/app/app-1/access-point') + expect(mockReplace).toHaveBeenCalledWith('/apps') }) expect(screen.queryByText('App page content')).not.toBeInTheDocument() expect(useStore.getState().appDetail).toBeUndefined() @@ -488,7 +528,7 @@ describe('AppDetailLayout', () => { ) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/app/app-1/access-point') + expect(mockReplace).toHaveBeenCalledWith('/apps') }) expect(screen.queryByText('App page content')).not.toBeInTheDocument() expect(useStore.getState().appDetail).toBeUndefined() diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index 7f9f6973ca8..df55021f289 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -78,8 +78,28 @@ const AppDetailLayout: FC = (props) => { appDetail?.id === appId ? appDetail : appDetailRes?.id === appId ? appDetailRes : null const pageTitle = appDetailPageTitle(pathname, t) const appName = routeAppDetail?.id === appId ? routeAppDetail.name : undefined + const isAppACLContextReady = + !!routeAppDetail && + !!currentWorkspace.id && + !isLoadingCurrentWorkspace && + !isLoadingWorkspacePermissionKeys && + !isLoadingAppDetail + const appACLCapabilities = React.useMemo( + () => + routeAppDetail && isAppACLContextReady + ? getAppACLCapabilities(routeAppDetail.permission_keys, { + currentUserId, + resourceMaintainer: routeAppDetail.maintainer, + workspacePermissionKeys, + isRbacEnabled, + }) + : null, + [currentUserId, isAppACLContextReady, isRbacEnabled, routeAppDetail, workspacePermissionKeys], + ) const shouldBlockAgentResourceAccess = routeAppDetail?.mode === AppModeEnum.AGENT && pathname.endsWith('/access-config') + const shouldBlockAccessPointAccess = + pathname.endsWith('/access-point') && !appACLCapabilities?.canAccessPoint useDocumentTitle(`${pageTitle} · ${appName || t(($) => $['menus.appDetail'], { ns: 'common' })}`) @@ -120,28 +140,16 @@ const AppDetailLayout: FC = (props) => { }, [appId, router, setAppDetail]) useEffect(() => { - if ( - !routeAppDetail || - !currentWorkspace.id || - isLoadingCurrentWorkspace || - isLoadingWorkspacePermissionKeys || - isLoadingAppDetail - ) - return + if (!routeAppDetail || !isAppACLContextReady || !appACLCapabilities) return if (routeAppDetail.id !== appId) return - const appACLCapabilities = getAppACLCapabilities(routeAppDetail.permission_keys, { - currentUserId, - resourceMaintainer: routeAppDetail.maintainer, - workspacePermissionKeys, - isRbacEnabled, - }) const isLayoutPath = pathname.endsWith('configuration') || pathname.endsWith('workflow') const isLogsPath = pathname.endsWith('logs') const isAnnotationsPath = pathname.endsWith('annotations') const isOverviewPath = pathname.endsWith('overview') const isAccessConfigPath = pathname.endsWith('access-config') const isDeployPath = pathname.endsWith('deploy') + const isAccessPointPath = pathname.endsWith('access-point') if ( (isLayoutPath && !appACLCapabilities.canAccessLayout) || (isLogsPath && !appACLCapabilities.canAccessLogAndAnnotation) || @@ -150,7 +158,8 @@ const AppDetailLayout: FC = (props) => { (isAccessConfigPath && (routeAppDetail.mode === AppModeEnum.AGENT || !appACLCapabilities.canAccessConfig)) || (isDeployPath && - (routeAppDetail.mode !== AppModeEnum.WORKFLOW || !appACLCapabilities.canDeploy)) + (routeAppDetail.mode !== AppModeEnum.WORKFLOW || !appACLCapabilities.canDeploy)) || + (isAccessPointPath && !appACLCapabilities.canAccessPoint) ) { router.replace( getRedirectionPath(routeAppDetail, { @@ -180,14 +189,12 @@ const AppDetailLayout: FC = (props) => { if (appDetailRes && appDetail?.id !== appDetailRes.id) setAppDetail({ ...appDetailRes, enable_sso: false }) }, [ + appACLCapabilities, appDetail?.id, appDetailRes, appId, currentUserId, - currentWorkspace.id, - isLoadingAppDetail, - isLoadingCurrentWorkspace, - isLoadingWorkspacePermissionKeys, + isAppACLContextReady, isRbacEnabled, pathname, routeAppDetail, @@ -198,7 +205,7 @@ const AppDetailLayout: FC = (props) => { const isWorkflowPage = pathname.endsWith('/workflow') const content = - !appDetail || shouldBlockAgentResourceAccess ? ( + !appDetail || shouldBlockAgentResourceAccess || shouldBlockAccessPointAccess ? (
diff --git a/web/app/(commonLayout)/external-service-sync.tsx b/web/app/(commonLayout)/external-service-sync.tsx index cec7155626e..08bb786d379 100644 --- a/web/app/(commonLayout)/external-service-sync.tsx +++ b/web/app/(commonLayout)/external-service-sync.tsx @@ -5,9 +5,13 @@ import type { DeploymentEdition } from '@dify/contracts/api/console/system-featu import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen' import { skipToken, useQuery, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' -import { Fragment, useEffect, useRef } from 'react' +import { Fragment, useEffect, useRef, useSyncExternalStore } from 'react' import { setUserId, setUserProperties } from '@/app/components/base/amplitude' -import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking' +import { + flushRegistrationSuccess, + getRegistrationSuccessSnapshot, + subscribeRegistrationSuccess, +} from '@/app/components/base/amplitude/registration-tracking' import { useAmplitudeInitialized } from '@/app/components/base/amplitude/use-amplitude-initialized' import { useAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store' import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils' @@ -43,13 +47,18 @@ function buildAmplitudeProperties({ return properties } -function AmplitudeIdentitySync() { +export function AmplitudeIdentitySync() { const { data: userProfile } = useSuspenseQuery({ ...userProfileQueryOptions(), select: (data) => data.profile, }) const currentWorkspace = useAtomValue(currentWorkspaceAtom) const lastIdentityRef = useRef(undefined) + const registrationSnapshot = useSyncExternalStore( + subscribeRegistrationSuccess, + getRegistrationSuccessSnapshot, + getRegistrationSuccessSnapshot, + ) useEffect(() => { if (!userProfile.id) return @@ -63,13 +72,14 @@ function AmplitudeIdentitySync() { properties, }) - if (identity === lastIdentityRef.current) return + if (identity !== lastIdentityRef.current) { + setUserId(userProfile.email) + setUserProperties(properties) + lastIdentityRef.current = identity + } - setUserId(userProfile.email) - setUserProperties(properties) - flushRegistrationSuccess() - lastIdentityRef.current = identity - }, [currentWorkspace, userProfile]) + void flushRegistrationSuccess() + }, [currentWorkspace, registrationSnapshot, userProfile]) return null } diff --git a/web/app/__tests__/layout.spec.tsx b/web/app/__tests__/layout.spec.tsx index ef8c38103fb..ea56e750817 100644 --- a/web/app/__tests__/layout.spec.tsx +++ b/web/app/__tests__/layout.spec.tsx @@ -73,6 +73,49 @@ describe('Root layout System Features bootstrap', () => { }) }) + it('points the icons at the branding favicon when one is configured', async () => { + mocks.getSystemFeatures.mockResolvedValue({ + branding: { + application_title: 'Acme AI', + enabled: true, + favicon: 'https://cdn.example.com/brand.ico', + }, + deployment_edition: 'CLOUD', + }) + const { generateMetadata } = await import('../layout') + + await expect(generateMetadata()).resolves.toMatchObject({ + icons: { + icon: 'https://cdn.example.com/brand.ico', + apple: 'https://cdn.example.com/brand.ico', + }, + }) + }) + + it('falls back to the static favicon without branding', async () => { + mocks.getSystemFeatures.mockResolvedValue({ + branding: { enabled: false }, + deployment_edition: 'CLOUD', + }) + const { generateMetadata } = await import('../layout') + + await expect(generateMetadata()).resolves.toMatchObject({ + icons: { icon: '/favicon.ico' }, + }) + }) + + it('falls back to the static favicon when branding is enabled without one', async () => { + mocks.getSystemFeatures.mockResolvedValue({ + branding: { application_title: 'Acme AI', enabled: true, favicon: '' }, + deployment_edition: 'CLOUD', + }) + const { generateMetadata } = await import('../layout') + + await expect(generateMetadata()).resolves.toMatchObject({ + icons: { icon: '/favicon.ico' }, + }) + }) + it('renders the client recovery path when the server prefetch fails', async () => { mocks.getSystemFeatures.mockRejectedValue(new Error('system features unavailable')) const { default: RootLayout, generateMetadata } = await import('../layout') diff --git a/web/app/components/__tests__/oauth-registration-analytics.spec.tsx b/web/app/components/__tests__/oauth-registration-analytics.spec.tsx index eb8f7deacdf..53d18f0d2b9 100644 --- a/web/app/components/__tests__/oauth-registration-analytics.spec.tsx +++ b/web/app/components/__tests__/oauth-registration-analytics.spec.tsx @@ -1,12 +1,31 @@ import { render, waitFor } from '@testing-library/react' import Cookies from 'js-cookie' +import { StrictMode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { useSearchParams } from '@/next/navigation' import { OAuthRegistrationAnalytics } from '../oauth-registration-analytics' -const { mockSendGAEvent, mockRememberRegistrationSuccess } = vi.hoisted(() => ({ - mockSendGAEvent: vi.fn(), +const { + mockConsent, + mockNormalizeRegistrationAttribution, + mockRememberRegistrationSuccess, + mockSendGAEvent, +} = vi.hoisted(() => ({ + mockConsent: { value: 'granted' as 'unknown' | 'denied' | 'granted' | 'disabled' }, + mockNormalizeRegistrationAttribution: vi.fn((value: Record | null) => { + if (!value) return null + const allowed = Object.fromEntries( + Object.entries(value).filter( + ([key, item]) => + ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'slug'].includes( + key, + ) && typeof item === 'string', + ), + ) + return Object.keys(allowed).length ? allowed : null + }), mockRememberRegistrationSuccess: vi.fn(), + mockSendGAEvent: vi.fn(), })) vi.mock('@/utils/gtag', () => ({ @@ -17,7 +36,14 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: vi.fn(), })) +vi.mock('../base/analytics-consent/consent-store', () => ({ + useAnalyticsConsent: () => mockConsent.value, +})) + vi.mock('../base/amplitude/registration-tracking', () => ({ + normalizeRegistrationAttribution: ( + ...args: Parameters + ) => mockNormalizeRegistrationAttribution(...args), rememberRegistrationSuccess: (...args: unknown[]) => mockRememberRegistrationSuccess(...args), })) @@ -33,22 +59,74 @@ const setSearchParams = (searchParams = '') => { describe('OAuthRegistrationAnalytics', () => { beforeEach(() => { vi.clearAllMocks() + window.sessionStorage.clear() + mockConsent.value = 'granted' + mockRememberRegistrationSuccess.mockReturnValue(true) Cookies.remove('utm_info') vi.spyOn(console, 'error').mockImplementation(() => {}) setSearchParams() }) - it('should track oauth registration with utm info and clear the query flag', async () => { + it('queues the Amplitude marker while consent is unknown and cleans the URL after persist', async () => { + mockConsent.value = 'unknown' + Cookies.set('utm_info', JSON.stringify({ utm_source: 'linkedin', slug: 'agent-launch' })) + setSearchParams('oauth_new_user=true&source=signin') + + render() + + await waitFor(() => { + expect(mockRememberRegistrationSuccess).toHaveBeenCalledWith({ + method: 'oauth', + utmInfo: { utm_source: 'linkedin', slug: 'agent-launch' }, + }) + }) + expect(mockSendGAEvent).toHaveBeenCalledTimes(1) + expect(Cookies.get('utm_info')).toBeUndefined() + expect(window.location.search).toBe('?source=signin') + }) + + it('keeps the recoverable OAuth signal when marker persistence fails', () => { + Cookies.set('utm_info', JSON.stringify({ utm_source: 'linkedin' })) + setSearchParams('oauth_new_user=true&source=signin') + mockRememberRegistrationSuccess.mockReturnValue(false) + + render() + + expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1) + expect(window.location.search).toBe('?oauth_new_user=true&source=signin') + expect(Cookies.get('utm_info')).toBeTruthy() + }) + + it('keeps the OAuth marker while consent is unknown, then cleans without a second Amplitude queue on denial', async () => { + mockConsent.value = 'unknown' + Cookies.set('utm_info', JSON.stringify({ utm_source: 'blog' })) + setSearchParams('oauth_new_user=true') + + const { rerender } = render() + + await waitFor(() => expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1)) + expect(mockSendGAEvent).toHaveBeenCalledTimes(1) + + mockConsent.value = 'denied' + rerender() + + await waitFor(() => expect(window.location.search).toBe('')) + expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1) + expect(mockSendGAEvent).toHaveBeenCalledTimes(1) + expect(Cookies.get('utm_info')).toBeUndefined() + }) + + it('queues immediately with pre-granted consent and keeps only allowlisted UTM fields', async () => { Cookies.set( 'utm_info', JSON.stringify({ utm_source: 'linkedin', slug: 'agent-launch', + arbitrary: 'discard-me', + utm_term: { nested: true }, }), ) - setSearchParams('oauth_new_user=true&source=signin') - const replaceStateSpy = vi.spyOn(window.history, 'replaceState') render() @@ -64,16 +142,13 @@ describe('OAuthRegistrationAnalytics', () => { slug: 'agent-launch', }) expect(Cookies.get('utm_info')).toBeUndefined() - - await waitFor(() => { - expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin?source=signin') - }) + expect(window.location.search).toBe('?source=signin') }) - it('should fall back to the base registration event when the utm cookie is invalid', async () => { + it('uses the base event and cleans up when the UTM cookie is malformed', async () => { Cookies.set('utm_info', '{invalid-json') - setSearchParams('oauth_new_user=true') + render() await waitFor(() => { @@ -89,23 +164,77 @@ describe('OAuthRegistrationAnalytics', () => { expect(Cookies.get('utm_info')).toBeUndefined() }) - it('should do nothing without the oauth registration query flag', () => { + it('cleans a false OAuth marker immediately without tracking or clearing utm_info', async () => { + mockConsent.value = 'unknown' + Cookies.set('utm_info', JSON.stringify({ utm_source: 'blog' })) + setSearchParams('oauth_new_user=false') + + render() + + await waitFor(() => expect(window.location.search).toBe('')) + expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled() + expect(mockSendGAEvent).not.toHaveBeenCalled() + expect(Cookies.get('utm_info')).toBe(JSON.stringify({ utm_source: 'blog' })) + }) + + it('tracks GA and Amplitude once across StrictMode effects and rerenders', async () => { + setSearchParams('oauth_new_user=true') + + const { rerender } = render( + + + , + ) + + rerender( + + + , + ) + + await waitFor(() => expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1)) + expect(mockSendGAEvent).toHaveBeenCalledTimes(1) + }) + + it('tracks GA once across an unknown-consent remount that simulates reload', async () => { + mockConsent.value = 'unknown' + setSearchParams('oauth_new_user=true') + + const firstRender = render() + await waitFor(() => expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1)) + firstRender.unmount() + render() + + expect(mockSendGAEvent).toHaveBeenCalledTimes(1) + }) + + it('treats analytics-disabled consent as terminal and cleans without Amplitude', async () => { + mockConsent.value = 'disabled' + Cookies.set('utm_info', JSON.stringify({ utm_source: 'blog' })) + setSearchParams('oauth_new_user=true') + + render() + + await waitFor(() => expect(window.location.search).toBe('')) + expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled() + expect(Cookies.get('utm_info')).toBeUndefined() + }) + + it('does nothing without the OAuth registration query marker', () => { render() expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled() expect(mockSendGAEvent).not.toHaveBeenCalled() }) - it('should clear a false oauth registration query flag without tracking', async () => { - setSearchParams('oauth_new_user=false') - const replaceStateSpy = vi.spyOn(window.history, 'replaceState') + it('clears an abandoned flow guard so a later OAuth registration can emit GA', () => { + window.sessionStorage.setItem('oauth_registration_ga_sent', 'true') + const abandonedFlow = render() + abandonedFlow.unmount() + setSearchParams('oauth_new_user=true') render() - await waitFor(() => { - expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin') - }) - expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled() - expect(mockSendGAEvent).not.toHaveBeenCalled() + expect(mockSendGAEvent).toHaveBeenCalledTimes(1) }) }) diff --git a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx index e84d925dbdc..0ff08042648 100644 --- a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx @@ -186,7 +186,10 @@ describe('AppDetailSection', () => { ).not.toBeInTheDocument() }) - it('should render access point navigation using its app route', () => { + it('should render access point navigation when access point permission is granted', () => { + // Arrange + mockAppPermissionKeys = [AppACLPermission.AccessPoint] + // Act render() @@ -200,6 +203,19 @@ describe('AppDetailSection', () => { ).not.toBeInTheDocument() }) + it('should hide access point navigation when access point permission is missing', () => { + // Arrange + mockAppPermissionKeys = [AppACLPermission.Monitor] + + // Act + render() + + // Assert + expect( + screen.queryByRole('link', { name: 'common.appMenus.accessPoint' }), + ).not.toBeInTheDocument() + }) + it('should render deploy navigation with app deploy ACL regardless of the legacy workspace role', () => { // Arrange mockAppMode = 'workflow' diff --git a/web/app/components/app-sidebar/app-detail-section.tsx b/web/app/components/app-sidebar/app-detail-section.tsx index 3176e2cac21..677ac50aea4 100644 --- a/web/app/components/app-sidebar/app-detail-section.tsx +++ b/web/app/components/app-sidebar/app-detail-section.tsx @@ -120,12 +120,16 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => { }, ] : []), - { - name: t(($) => $['appMenus.accessPoint'], { ns: 'common' }), - href: `/app/${appId}/access-point`, - icon: accessPointNavIcon, - selectedIcon: accessPointNavIcon, - }, + ...(appACLCapabilities.canAccessPoint + ? [ + { + name: t(($) => $['appMenus.accessPoint'], { ns: 'common' }), + href: `/app/${appId}/access-point`, + icon: accessPointNavIcon, + selectedIcon: accessPointNavIcon, + }, + ] + : []), ...(supportsAppDeploy && appACLCapabilities.canDeploy ? [ { diff --git a/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx b/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx index 8aa12ed5457..9c80ad25f11 100644 --- a/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx @@ -295,6 +295,7 @@ function renderFlow( return render( ({ default: ({ @@ -314,6 +315,7 @@ describe('app-publisher sections', () => { description: 'Workflow description', }} appURL="https://example.com/app" + canAccessPoint disabledFunctionButton={false} disabledFunctionTooltip="disabled" handleOpenRunConfig={handleOpenRunConfig} @@ -494,6 +496,7 @@ describe('app-publisher sections', () => { mode: AppModeEnum.WORKFLOW, }} appURL="https://example.com/app" + canAccessPoint disabledFunctionButton={false} hasHumanInputNode={false} hasTriggerNode @@ -517,11 +520,49 @@ describe('app-publisher sections', () => { ) }) + it('should hide the built-in Access Point action without permission', () => { + render( + , + ) + + expect(screen.queryByText(/(?:^|\.)appMenus\.accessPoint(?=$|:)/)).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: /appMenus\.deploy\b/ })).toHaveAttribute( + 'href', + '/app/workflow-app/deploy', + ) + }) + + it('should hide the environment Access Point action without permission', () => { + render( + , + ) + + expect(screen.queryByText(/(?:^|\.)appMenus\.accessPoint(?=$|:)/)).not.toBeInTheDocument() + expect(screen.getByText(/(?:^|\.)appMenus\.deploy(?=$|:)/)).toBeInTheDocument() + }) + it('should expose unavailable quick links as disabled buttons before the first publish', () => { render( void @@ -41,6 +42,7 @@ type PublisherActionsSectionProps = Pick< export function PublisherActionsSection({ appDetail, appURL, + canAccessPoint = false, disabledFunctionButton, disabledFunctionTooltip, handleOpenRunConfig, @@ -114,14 +116,16 @@ export function PublisherActionsSection({ {disabledFunctionTooltip} )} - $['common.accessPointDescription'], { ns: 'workflow' })} - link={appId ? `/app/${appId}/access-point` : undefined} - icon={} - > - {t(($) => $['appMenus.accessPoint'], { ns: 'common' })} - + {canAccessPoint && ( + $['common.accessPointDescription'], { ns: 'workflow' })} + link={appId ? `/app/${appId}/access-point` : undefined} + icon={} + > + {t(($) => $['appMenus.accessPoint'], { ns: 'common' })} + + )} {showDeploy && ( - $['common.accessPointDescription'], { ns: 'workflow' })} - link={accessPointHref} - icon={} - > - {t(($) => $['appMenus.accessPoint'], { ns: 'common' })} - + {canAccessPoint && ( + $['common.accessPointDescription'], { ns: 'workflow' })} + link={accessPointHref} + icon={} + > + {t(($) => $['appMenus.accessPoint'], { ns: 'common' })} + + )} $['common.deployDescription'], { ns: 'workflow' })} diff --git a/web/app/components/app/app-publisher/environment-deployment-flow/index.tsx b/web/app/components/app/app-publisher/environment-deployment-flow/index.tsx index dce08832bc0..9f0b1201993 100644 --- a/web/app/components/app/app-publisher/environment-deployment-flow/index.tsx +++ b/web/app/components/app/app-publisher/environment-deployment-flow/index.tsx @@ -15,6 +15,7 @@ import { PublisherEnvironmentSummarySection } from './summary-section' type PublisherEnvironmentFlowProps = { appId?: string + canAccessPoint?: boolean deployment?: EnvironmentDeployment environmentId: string environmentName: string @@ -28,6 +29,7 @@ type PublisherEnvironmentFlowProps = { export function PublisherEnvironmentFlow({ appId, + canAccessPoint = false, deployment, environmentId, environmentName, @@ -91,6 +93,7 @@ export function PublisherEnvironmentFlow({ /> diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 48278b77cfc..38e9b724d1e 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -17,12 +17,13 @@ export function AppPublisher(props: AppPublisherProps) { select: (data) => data.profile.id, }) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const canDeploy = getAppACLCapabilities(appDetail?.permission_keys, { + const appACLCapabilities = getAppACLCapabilities(appDetail?.permission_keys, { currentUserId, resourceMaintainer: appDetail?.maintainer, workspacePermissionKeys, - }).canDeploy - const supportsMultiEnvironment = appDetail?.mode === AppModeEnum.WORKFLOW && canDeploy + }) + const supportsMultiEnvironment = + appDetail?.mode === AppModeEnum.WORKFLOW && appACLCapabilities.canDeploy return ( void } export function PublisherContent({ + canAccessPoint, crossAxisOffset = 0, debugWithMultipleModel = false, disabled = false, @@ -212,6 +214,7 @@ export function PublisherContent({ actions: { appDetail, appURL, + canAccessPoint, disabledFunctionButton, disabledFunctionTooltip, handleOpenRunConfig: workflowLaunch.openDialog, @@ -236,6 +239,7 @@ export function PublisherContent({ disabled={disabled} environmentPublisher={{ appId: appDetail?.id, + canAccessPoint, deployment: selectedEnvironmentDeployment, environmentId: selectedEnvironmentId, environmentName: diff --git a/web/app/components/app/deploy/__tests__/index.spec.tsx b/web/app/components/app/deploy/__tests__/index.spec.tsx index 11888a09817..79c1f082294 100644 --- a/web/app/components/app/deploy/__tests__/index.spec.tsx +++ b/web/app/components/app/deploy/__tests__/index.spec.tsx @@ -650,7 +650,7 @@ function render( return renderWithConsoleQuery(ui, { queryClient }) } -let appPermissionKeys: string[] = [AppACLPermission.Deploy] +let appPermissionKeys: string[] = [AppACLPermission.AccessPoint, AppACLPermission.Deploy] let appDetailAvailable = true const mockConsoleState = vi.hoisted(() => ({ workspacePermissionKeys: [] as string[], @@ -744,7 +744,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ describe('AppDeploy', () => { beforeEach(() => { vi.clearAllMocks() - appPermissionKeys = [AppACLPermission.Deploy] + appPermissionKeys = [AppACLPermission.AccessPoint, AppACLPermission.Deploy] appDetailAvailable = true mockBuiltInEnvironment.appDetail.enable_api = false mockBuiltInEnvironment.appDetail.enable_site = true @@ -815,6 +815,18 @@ describe('AppDeploy', () => { ).toHaveAttribute('href', '/app/app-1/access-point?environment=canary&accessPoint=serviceApi') }) + it('keeps active access points non-navigable without access point permission', () => { + appPermissionKeys = [AppACLPermission.Deploy] + + render() + + const canaryRow = within(screen.getByRole('row', { name: /Canary/ })) + const webAppLabel = + 'agentV2.agentDetail.access.webApp.title · agentV2.agentDetail.access.status.inService' + expect(canaryRow.queryByRole('link', { name: webAppLabel })).not.toBeInTheDocument() + expect(canaryRow.getByRole('button', { name: webAppLabel })).toBeDisabled() + }) + it('renders the built-in version, access points, and publisher from live app data', () => { render() diff --git a/web/app/components/app/deploy/built-in-environment-card/index.tsx b/web/app/components/app/deploy/built-in-environment-card/index.tsx index d04dce259a8..607250e7ce6 100644 --- a/web/app/components/app/deploy/built-in-environment-card/index.tsx +++ b/web/app/components/app/deploy/built-in-environment-card/index.tsx @@ -19,7 +19,7 @@ function Divider() { return
} -export function BuiltInEnvironmentCard() { +export function BuiltInEnvironmentCard({ canAccessPoint = false }: { canAccessPoint?: boolean }) { const { t } = useTranslation('deployments') const { formatTime } = useTimestamp() const appDetail = useAppStore((state) => state.appDetail) @@ -90,7 +90,9 @@ export function BuiltInEnvironmentCard() { key={accessPoint} accessPoint={accessPoint} active={activeAccessPoints[accessPoint]} - href={getAccessPointHref(appId, 'built-in', accessPoint)} + href={ + canAccessPoint ? getAccessPointHref(appId, 'built-in', accessPoint) : undefined + } /> ))}
diff --git a/web/app/components/app/deploy/environment-table/index.tsx b/web/app/components/app/deploy/environment-table/index.tsx index cbee27225ed..cc9b9451e59 100644 --- a/web/app/components/app/deploy/environment-table/index.tsx +++ b/web/app/components/app/deploy/environment-table/index.tsx @@ -23,6 +23,7 @@ import { EnvironmentRow } from './row' type EnvironmentTableProps = { appId: string + canAccessPoint?: boolean onChangeVersion?: (deployment: EnvironmentDeployment) => void onDeployLatest?: (deployment: EnvironmentDeployment) => void onDeployToEnvironment?: (environment: AppEnvironment) => void @@ -32,6 +33,7 @@ type EnvironmentTableProps = { export function EnvironmentTable({ appId, + canAccessPoint = false, onChangeVersion, onDeployLatest, onDeployToEnvironment, @@ -132,6 +134,7 @@ export function EnvironmentTable({ void onDeployLatest?: (deployment: EnvironmentDeployment) => void @@ -60,7 +62,11 @@ export function EnvironmentRow({ key={accessPoint} accessPoint={accessPoint} active={isAccessPointActive(accessPoint)} - href={getAccessPointHref(appId, row.environment.id, accessPoint)} + href={ + canAccessPoint + ? getAccessPointHref(appId, row.environment.id, accessPoint) + : undefined + } /> ))} diff --git a/web/app/components/app/deploy/index.tsx b/web/app/components/app/deploy/index.tsx index c22034584dd..4f1bee0ed1e 100644 --- a/web/app/components/app/deploy/index.tsx +++ b/web/app/components/app/deploy/index.tsx @@ -22,7 +22,7 @@ import { useRefreshAppEnvironmentsAfterDeploymentPolling } from './use-refresh-a import { useUndeployWorkflow } from './use-undeploy-workflow' import { toDeploymentVersion } from './version' -function AppDeployContent({ appId }: { appId: string }) { +function AppDeployContent({ appId, canAccessPoint }: { appId: string; canAccessPoint: boolean }) { const { t } = useTranslation('deployments') const { t: tCommon } = useTranslation('common') const { t: tWorkflow } = useTranslation('workflow') @@ -86,9 +86,10 @@ function AppDeployContent({ appId }: { appId: string }) {
- + setDeploymentRequest({ environment: environment.display_name, @@ -139,17 +140,17 @@ export default function AppDeploy() { if (!appDetail) return - const canDeploy = getAppACLCapabilities(appDetail.permission_keys, { + const appACLCapabilities = getAppACLCapabilities(appDetail.permission_keys, { currentUserId, resourceMaintainer: appDetail.maintainer, workspacePermissionKeys, - }).canDeploy + }) - if (appDetail.mode !== AppModeEnum.WORKFLOW || !canDeploy) return null + if (appDetail.mode !== AppModeEnum.WORKFLOW || !appACLCapabilities.canDeploy) return null return ( - + ) } diff --git a/web/app/components/app/deploy/shared/__tests__/access-point-icon.spec.tsx b/web/app/components/app/deploy/shared/__tests__/access-point-icon.spec.tsx new file mode 100644 index 00000000000..80d18eaaee6 --- /dev/null +++ b/web/app/components/app/deploy/shared/__tests__/access-point-icon.spec.tsx @@ -0,0 +1,35 @@ +import { screen } from '@testing-library/react' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { AccessPointIcon } from '../access-point-icon' + +describe('AccessPointIcon', () => { + it('links active access points when navigation is allowed', () => { + render( + , + ) + + expect(screen.getByRole('link')).toHaveAttribute( + 'href', + '/app/app-1/access-point?environment=built-in&accessPoint=webApp', + ) + }) + + it('keeps active access points visually active when navigation is not allowed', () => { + render() + + expect(screen.queryByRole('link')).not.toBeInTheDocument() + expect(screen.getByRole('button')).toBeDisabled() + expect(screen.getByRole('button')).not.toHaveClass('opacity-30') + }) + + it('dims inactive access points', () => { + render() + + expect(screen.getByRole('button')).toBeDisabled() + expect(screen.getByRole('button')).toHaveClass('opacity-30') + }) +}) diff --git a/web/app/components/app/deploy/shared/access-point-icon.tsx b/web/app/components/app/deploy/shared/access-point-icon.tsx index 734e9b694ee..253429f03fc 100644 --- a/web/app/components/app/deploy/shared/access-point-icon.tsx +++ b/web/app/components/app/deploy/shared/access-point-icon.tsx @@ -32,7 +32,7 @@ export function AccessPointIcon({ }: { active: boolean accessPoint: AccessPoint - href: string + href?: string }) { const { t } = useTranslation('agentV2') const labels = useAccessPointLabels() @@ -40,9 +40,11 @@ export function AccessPointIcon({ ? t(($) => $['agentDetail.access.status.inService']) : t(($) => $['agentDetail.access.status.outOfService']) const label = `${labels[accessPoint]} · ${status}` + const canNavigate = active && Boolean(href) const triggerClassName = cn( 'flex size-5 shrink-0 items-center justify-center rounded-md border border-divider-regular text-text-secondary shadow-xs outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', - active ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-not-allowed opacity-30', + active && (canNavigate ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-default'), + !active && 'cursor-not-allowed opacity-30', ) const icon = ( @@ -52,7 +54,7 @@ export function AccessPointIcon({ {icon} diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index af53580c464..adb020b86b7 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -1,5 +1,5 @@ import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen' -import { fireEvent, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry' @@ -386,15 +386,18 @@ vi.mock('@/features/tag-management/components/app-card-tags', () => ({ AppCardTags: ({ tags, canBindOrUnbindTags, + appName, }: { tags?: { id: string; name: string }[] canBindOrUnbindTags?: boolean + appName: string }) => { return React.createElement( 'div', { 'aria-label': 'tag-selector', 'data-can-bind-or-unbind-tags': String(Boolean(canBindOrUnbindTags)), + 'data-app-name': appName, }, tags?.map((tag: { id: string; name: string }) => React.createElement('span', { key: tag.id }, tag.name), @@ -472,6 +475,8 @@ describe('AppCard', () => { const card = screen.getByRole('button', { name: 'Preview Only App' }) expect(card).toHaveClass('opacity-60') + expect(screen.getByRole('listitem')).toContainElement(card) + expect(card).toHaveAccessibleDescription('Only visible metadata') expect(card).not.toHaveAttribute('aria-disabled') expect(screen.getByText('Only visible metadata')).toBeInTheDocument() expect(screen.getByText('Readonly Author')).toBeInTheDocument() @@ -552,6 +557,17 @@ describe('AppCard', () => { const emojiIcon = container.querySelector(`em-emoji[id="${mockApp.icon}"]`) const imageIcon = container.querySelector('img') expect(emojiIcon || imageIcon).toBeTruthy() + expect(emojiIcon?.parentElement).toHaveAttribute('aria-hidden', 'true') + }) + + it('should treat a redundant image icon as decorative', () => { + const imageApp = createMockApp({ + icon_type: 'image', + icon_url: 'https://example.com/app-icon.png', + }) + const { container } = render() + + expect(container.querySelector('img')).toHaveAttribute('alt', '') }) it('should render app type icon', () => { @@ -579,7 +595,7 @@ describe('AppCard', () => { } render() // Verify the tag selector component renders - expect(screen.getByLabelText('tag-selector')).toBeInTheDocument() + expect(screen.getByLabelText('tag-selector')).toHaveAttribute('data-app-name', 'Test App') }) it('should display refreshed tag names from app props when tag ids stay the same', () => { @@ -670,21 +686,36 @@ describe('AppCard', () => { const cardLink = screen.getByRole('link', { name: 'Test App' }) expect(cardLink).toHaveAttribute('href', '/app/test-app-id/configuration') + expect(cardLink).toHaveAccessibleName('Test App') + expect(cardLink).toHaveAccessibleDescription('Test app description') + expect(cardLink).toHaveAttribute('aria-describedby') + expect(screen.getByRole('listitem')).toContainElement(cardLink) }) - it('should expose a visible focus ring on the card link', () => { + it('should keep card navigation and actions as sibling focus targets', async () => { + const user = userEvent.setup() render() - const cardLink = screen.getByRole('link', { name: 'Test App' }) - expect(cardLink).toHaveClass('focus-visible:ring-2') - expect(cardLink).toHaveClass('focus-visible:ring-state-accent-solid') + const cardLink = screen.getByRole('link', { name: 'Test App' }) + const starToggle = screen.getByRole('button', { name: 'app.studio.starApp: Test App' }) + const operationsTrigger = getOperationsTrigger() + + expect(cardLink).not.toContainElement(starToggle) + expect(cardLink).not.toContainElement(operationsTrigger) + + await user.tab() + expect(cardLink).toHaveFocus() + await user.tab() + expect(starToggle).toHaveFocus() + await user.tab() + expect(operationsTrigger).toHaveFocus() }) it('should star the app from the card action without navigating', async () => { const user = userEvent.setup() render() - const starToggle = screen.getByRole('button', { name: 'app.studio.starApp' }) + const starToggle = screen.getByRole('button', { name: 'app.studio.starApp: Test App' }) expect(starToggle).toHaveAttribute('aria-pressed', 'false') await user.click(starToggle) @@ -702,9 +733,13 @@ describe('AppCard', () => { const starredApp = createMockApp({ is_starred: true }) render() - const starToggle = screen.getByRole('button', { name: 'app.studio.starApp' }) + const starToggle = screen.getByRole('button', { name: 'app.studio.starApp: Test App' }) expect(starToggle).toHaveAttribute('aria-pressed', 'true') + await user.hover(starToggle) + + expect(await screen.findByText('app.studio.starApp')).toBeInTheDocument() + await user.click(starToggle) await waitFor(() => { @@ -716,20 +751,6 @@ describe('AppCard', () => { }) describe('Operations Menu', () => { - it('should reveal operations trigger when card receives keyboard focus', () => { - render() - const operationsTrigger = getOperationsTrigger() - const operationsTriggerWrapper = operationsTrigger.closest('.absolute') - - expect(operationsTriggerWrapper).toHaveClass('top-2') - expect(operationsTriggerWrapper).toHaveClass('right-2') - expect(operationsTriggerWrapper).toHaveClass('group-focus-within:pointer-events-auto') - expect(operationsTriggerWrapper).toHaveClass('group-focus-within:opacity-100') - expect(operationsTriggerWrapper).not.toHaveClass('w-[120px]') - expect(operationsTrigger).toHaveClass('focus-visible:ring-2') - expect(operationsTrigger).toHaveClass('focus-visible:ring-state-accent-solid') - }) - it('should show edit option when dropdown menu is opened', async () => { const user = userEvent.setup() render() @@ -742,6 +763,32 @@ describe('AppCard', () => { expect(mockPush).not.toHaveBeenCalled() }) + it('should expose the same operations from the card context menu', async () => { + const user = userEvent.setup() + render() + + await user.pointer({ + target: screen.getByRole('link', { name: 'Test App' }), + keys: '[MouseRight]', + }) + + expect(await screen.findByRole('menuitem', { name: 'app.editApp' })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'app.duplicate' })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'app.export' })).toBeInTheDocument() + }) + + it('should keep card actions outside the card context menu trigger', async () => { + const user = userEvent.setup() + render() + + await user.pointer({ + target: screen.getByRole('button', { name: 'app.studio.starApp: Test App' }), + keys: '[MouseRight]', + }) + + expect(screen.queryByRole('menuitem', { name: 'app.editApp' })).not.toBeInTheDocument() + }) + it('should show duplicate option when dropdown menu is opened', async () => { render() @@ -1179,6 +1226,7 @@ describe('AppCard', () => { render() const trigger = screen.getByRole('button', { name: 'common.operation.exporting' }) + expect(trigger).toBeDisabled() }) }) @@ -1243,6 +1291,21 @@ describe('AppCard', () => { expect(screen.getByText('app.openInExplore')).toBeInTheDocument() }) }) + + it('should hide open in explore for SSO-restricted apps', async () => { + mockWebappAuthEnabled = true + const user = userEvent.setup() + const ssoApp = createMockApp({ access_mode: AccessMode.EXTERNAL_MEMBERS }) + + render() + + await user.click(getOperationsTrigger()) + const menu = await screen.findByRole('menu') + + expect( + within(menu).queryByRole('menuitem', { name: 'app.openInExplore' }), + ).not.toBeInTheDocument() + }) }) describe('Workflow Export with Environment Variables', () => { diff --git a/web/app/components/apps/__tests__/creators-filter.spec.tsx b/web/app/components/apps/__tests__/creators-filter.spec.tsx index 9d905f39e38..98849d51f5f 100644 --- a/web/app/components/apps/__tests__/creators-filter.spec.tsx +++ b/web/app/components/apps/__tests__/creators-filter.spec.tsx @@ -1,4 +1,6 @@ -import { fireEvent, screen, within } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' import { createConsoleQueryWrapper } from '@/test/console/query-data' import { render as renderWithConsoleState } from '@/test/console/render' import CreatorsFilter from '../creators-filter' @@ -10,6 +12,11 @@ const render = (ui: Parameters[0]) => wrapper: createConsoleQueryWrapper({ accountProfile: { id: 'member-2' } }).wrapper, }) +const StatefulCreatorsFilter = ({ initialValue }: { initialValue: string[] }) => { + const [value, setValue] = useState(initialValue) + return +} + vi.mock('@/service/use-common', () => ({ useMembers: () => ({ data: { @@ -28,16 +35,13 @@ describe('CreatorsFilter', () => { vi.clearAllMocks() }) - it('should sort the current user first and filter out pending members', () => { + it('should sort the current user first and filter out pending members', async () => { + const user = userEvent.setup() render() - fireEvent.click(screen.getByRole('button', { name: /app\.studio\.filters\.creators/i })) + await user.click(screen.getByRole('combobox', { name: 'app.studio.filters.creators' })) - const options = screen - .getAllByRole('button') - .filter((button) => - ['Alice', 'Bob', 'Zoe'].some((name) => button.textContent?.includes(name)), - ) + const options = screen.getAllByRole('option') expect(options.map((option) => option.textContent)).toEqual([ expect.stringContaining('Alice'), @@ -48,52 +52,111 @@ describe('CreatorsFilter', () => { expect(screen.queryByText('Pending User')).not.toBeInTheDocument() }) - it('should search creators, clear keywords, and select a creator', () => { + it('should search creators, clear keywords, and select a creator', async () => { + const user = userEvent.setup() render() - fireEvent.click(screen.getByRole('button', { name: /app\.studio\.filters\.creators/i })) - fireEvent.change(screen.getByPlaceholderText('app.studio.filters.searchCreators'), { - target: { value: 'zo' }, + await user.click(screen.getByRole('combobox', { name: 'app.studio.filters.creators' })) + const searchInput = screen.getByRole('combobox', { + name: 'app.studio.filters.searchCreators', + }) + await user.type(searchInput, 'zo') + + const zoeOption = screen.getByRole('option', { name: /Zoe/ }) + expect(zoeOption).toBeInTheDocument() + expect(screen.queryByRole('option', { name: /Bob/ })).not.toBeInTheDocument() + await waitFor(() => { + expect(searchInput).toHaveAttribute('aria-activedescendant', zoeOption.id) }) - expect(screen.getByRole('button', { name: /Zoe/ })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /Bob/ })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'common.operation.clear' })) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' })) - - const searchInput = screen.getByPlaceholderText('app.studio.filters.searchCreators') expect(searchInput).toHaveValue('') expect(searchInput).toHaveFocus() - fireEvent.click(screen.getByRole('button', { name: /Bob/ })) + await user.click(screen.getByRole('option', { name: /Bob/ })) expect(mockOnChange).toHaveBeenCalledWith(['member-3']) }) - it('should remove selected creators from the trigger reset and menu reset controls', () => { - const { rerender } = render( - , + it('should clear only the search query from the input action', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('combobox', { name: 'app.studio.filters.creators' })) + const searchInput = screen.getByRole('combobox', { + name: 'app.studio.filters.searchCreators', + }) + await user.type(searchInput, 'zo') + await user.click(screen.getByRole('button', { name: 'common.operation.clear' })) + + expect(searchInput).toHaveValue('') + expect(searchInput).toHaveFocus() + expect(screen.getByRole('option', { name: /Alice/ })).toHaveAttribute('aria-selected', 'true') + expect(mockOnChange).not.toHaveBeenCalled() + }) + + it('should return focus to the trigger after clearing creators from the filter chip', async () => { + const user = userEvent.setup() + render() + + const trigger = screen.getByRole('combobox', { name: 'app.studio.filters.creators' }) + const triggerReset = screen.getByRole('button', { name: 'app.studio.filters.reset' }) + + expect(trigger).not.toContainElement(triggerReset) + + await user.click(triggerReset) + + expect(trigger).toHaveFocus() + expect( + screen.queryByRole('button', { name: 'app.studio.filters.reset' }), + ).not.toBeInTheDocument() + }) + + it('should preserve unavailable creator ids when removing an available creator', async () => { + const user = userEvent.setup() + render( + , ) - const trigger = screen.getByRole('button', { name: /app\.studio\.filters\.creators/i }) - fireEvent.click(within(trigger).getByRole('button', { name: 'app.studio.filters.reset' })) + await user.click(screen.getByRole('combobox', { name: 'app.studio.filters.creators' })) + const aliceOption = screen.getByRole('option', { name: /Alice/ }) + expect(aliceOption).toHaveAttribute('aria-selected', 'true') - expect(mockOnChange).toHaveBeenCalledWith([]) + await user.click(aliceOption) - rerender() - - fireEvent.click(screen.getByRole('button', { name: /app\.studio\.filters\.creators/i })) - fireEvent.click(screen.getAllByRole('button', { name: 'app.studio.filters.reset' }).at(-1)!) - - expect(mockOnChange).toHaveBeenCalledWith([]) + expect(mockOnChange).toHaveBeenCalledWith(['missing-member', 'member-3']) }) - it('should remove a selected creator when toggled from the menu', () => { + it('should expose the selected creator count from the closed trigger', () => { render() - fireEvent.click(screen.getByRole('button', { name: /app\.studio\.filters\.creators/i })) - fireEvent.click(screen.getByRole('button', { name: /Alice/ })) + const trigger = screen.getByRole('combobox', { name: 'app.studio.filters.creators' }) + const selectedCount = within(trigger).getByText('common.dynamicSelect.selected:{"count":2}') + expect(selectedCount).toHaveClass('sr-only') + expect(within(trigger).getByText('+2').parentElement).toHaveAttribute('aria-hidden', 'true') + }) - expect(mockOnChange).toHaveBeenCalledWith(['member-3']) + it('should expose the creator picker as a named combobox with keyboard-owned options', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('combobox', { name: 'app.studio.filters.creators' })) + + const popup = screen.getByRole('dialog', { name: 'app.studio.filters.creators' }) + const searchInput = within(popup).getByRole('combobox', { + name: 'app.studio.filters.searchCreators', + }) + expect(popup).toBeInTheDocument() + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + expect(within(popup).getByRole('option', { name: /Alice/ })).toHaveAttribute( + 'aria-selected', + 'false', + ) + + await waitFor(() => expect(searchInput).toHaveFocus()) + await user.keyboard('{ArrowDown}{Enter}') + + expect(mockOnChange).toHaveBeenCalledWith(['member-2']) }) }) diff --git a/web/app/components/apps/__tests__/import-from-marketplace-template-modal.spec.tsx b/web/app/components/apps/__tests__/import-from-marketplace-template-modal.spec.tsx index 3d02be678f3..865e6bbf4d3 100644 --- a/web/app/components/apps/__tests__/import-from-marketplace-template-modal.spec.tsx +++ b/web/app/components/apps/__tests__/import-from-marketplace-template-modal.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import ImportFromMarketplaceTemplateModal from '../import-from-marketplace-template-modal' const mockUseMarketplaceTemplateDetail = vi.fn() @@ -43,5 +43,60 @@ describe('ImportFromMarketplaceTemplateModal', () => { expect(screen.getByText('Human Input: Writing Assistant')).toBeInTheDocument() expect(screen.queryByText('technologist')).not.toBeInTheDocument() + expect(document.querySelector('em-emoji')?.parentElement).toHaveAttribute('aria-hidden', 'true') + expect( + screen.getByRole('dialog', { name: /marketplace\.template\.modalTitle/ }), + ).toBeInTheDocument() + }) + + it('exposes a named close control', () => { + const onClose = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /operation\.close/ })) + + expect(onClose).toHaveBeenCalledOnce() + }) + + it('exposes loading progress and announces the error state', () => { + mockUseMarketplaceTemplateDetail.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }) + const { rerender } = render( + , + ) + + expect(screen.getByText('common.loading').parentElement?.parentElement).toHaveAttribute( + 'aria-busy', + 'true', + ) + expect(screen.queryByRole('status')).not.toBeInTheDocument() + + mockUseMarketplaceTemplateDetail.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + }) + rerender( + , + ) + + expect(screen.getByRole('alert')).toHaveTextContent('app.marketplace.template.fetchFailed') }) }) diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index 79816ab08a8..f1cf813ea31 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -3,7 +3,7 @@ import type { StepByStepTourSessionState } from '@/app/components/step-by-step-t import type { App } from '@/models/explore' import type { TryAppSelection } from '@/types/try-app' import { keepPreviousData } from '@tanstack/react-query' -import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createStore, Provider as JotaiProvider } from 'jotai' import * as React from 'react' @@ -398,12 +398,11 @@ vi.mock('../app-card', () => ({ stepByStepTourCardHighlightPart?: string }) => { return React.createElement( - 'div', + 'li', { 'data-testid': `app-card-${app.id}`, 'data-step-by-step-tour-target': stepByStepTourCardTarget, 'data-step-by-step-tour-highlight-part': stepByStepTourCardHighlightPart, - role: 'article', }, app.name, React.createElement('button', { @@ -415,20 +414,27 @@ vi.mock('../app-card', () => ({ ) }, default: ({ app }: { app: { id: string; name: string } }) => { - return React.createElement( - 'div', - { 'data-testid': `app-card-${app.id}`, role: 'article' }, - app.name, - ) + return React.createElement('li', { 'data-testid': `app-card-${app.id}` }, app.name) }, })) -vi.mock('../app-card/action-bar', () => ({ - AppCardActionBar: ({ app }: { app: { id: string; name: string } }) => { - return React.createElement('button', { - 'aria-label': `Actions for ${app.name}`, - type: 'button', - }) +vi.mock('../app-card/interactions', () => ({ + AppCardInteractions: ({ + app, + children, + }: { + app: { id: string; name: string } + children: React.ReactElement + }) => { + return React.createElement( + React.Fragment, + null, + children, + React.createElement('button', { + 'aria-label': `Actions for ${app.name}`, + type: 'button', + }), + ) }, })) @@ -439,7 +445,6 @@ vi.mock('../empty', () => ({ { 'data-testid': 'empty-state', 'data-step-by-step-tour-target': stepByStepTourTarget, - role: 'status', }, 'No apps found', ) @@ -626,7 +631,7 @@ describe('List', () => { it('should render filters and search before the right aligned actions', () => { renderList() - const creatorsButton = screen.getByRole('button', { name: 'Creators' }) + const creatorsButton = screen.getByRole('combobox', { name: 'Creators' }) const searchInput = screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications', }) @@ -635,6 +640,7 @@ describe('List', () => { const createButton = screen.getByRole('button', { name: 'common.operation.create' }) expect(snippetsLink).toHaveAttribute('href', '/snippets') + expect(sortButton).toHaveTextContent('Sort by Last modified') expect( creatorsButton.compareDocumentPosition(sortButton) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy() @@ -689,14 +695,21 @@ describe('List', () => { renderList() - const starredLabel = screen.getByText('Starred') - const starredCard = screen.getByRole('link', { name: /Starred App/ }) - const allAppsLabel = screen.getByText('All Apps') + const starredLabel = screen.getByRole('heading', { level: 2, name: 'Starred' }) + const starredList = screen.getByRole('list', { name: 'Starred' }) + const starredCard = screen.getByRole('link', { name: 'Starred App' }) + const allAppsLabel = screen.getByRole('heading', { level: 2, name: 'All Apps' }) + const allAppsList = screen.getByRole('list', { name: 'All Apps' }) const firstAppCard = screen.getByTestId('app-card-app-1') const actionBar = screen.getByRole('button', { name: 'Actions for Starred App' }) expect(starredCard).toBeInTheDocument() expect(actionBar).toBeInTheDocument() + expect(screen.getAllByRole('list')).toHaveLength(2) + expect(starredList).toContainElement(starredCard) + expect(within(starredList).getAllByRole('listitem')).toHaveLength(1) + expect(firstAppCard.parentElement).toBe(allAppsList) + expect(within(allAppsList).getAllByRole('listitem')).toHaveLength(2) expect( starredLabel.compareDocumentPosition(starredCard) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy() @@ -739,7 +752,7 @@ describe('List', () => { const firstWorkspaceCard = screen.getByTestId('app-card-app-1') const firstWorkspaceActionBar = screen.getByTestId('app-card-action-bar-app-1') - const starredCard = screen.getByRole('link', { name: /Starred App/ }) + const starredCard = screen.getByRole('link', { name: 'Starred App' }) const starredActionBar = screen.getByRole('button', { name: 'Actions for Starred App', }) @@ -791,7 +804,7 @@ describe('List', () => { renderList() - const starredCard = screen.getByRole('link', { name: /Starred App/ }) + const starredCard = screen.getByRole('link', { name: 'Starred App' }) const firstWorkspaceCard = screen.getByTestId('app-card-app-1') const firstWorkspaceActionBar = screen.getByTestId('app-card-action-bar-app-1') @@ -862,6 +875,9 @@ describe('List', () => { it('should render drop DSL hint when app creation permission is available', () => { renderList() expect(screen.getByText('app.newApp.dropDSLToCreateApp'))!.toBeInTheDocument() + expect( + screen.queryByRole('region', { name: 'app.newApp.dropDSLToCreateApp' }), + ).not.toBeInTheDocument() }) it('should render first empty state when there are no apps and no active filters', () => { @@ -933,11 +949,44 @@ describe('List', () => { renderList('?keywords=missing+app') expect(screen.getByTestId('empty-state'))!.toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('app.filterEmpty.noApps') + expect(screen.getByRole('status')).toHaveClass('sr-only') + expect(screen.getByTestId('empty-state').closest('[aria-busy]')).toHaveAttribute( + 'aria-busy', + 'false', + ) expect(screen.getByRole('button', { name: 'Types' }))!.toBeInTheDocument() expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument() expect(screen.queryByText('app.firstEmpty.title')).not.toBeInTheDocument() }) + it('should keep the result status quiet while placeholder results are fetching', () => { + mockAppData = { pages: [{ data: [], total: 0 }] } + mockServiceState.isFetching = true + mockServiceState.isPlaceholderData = true + + renderList('?keywords=missing+app') + + expect(screen.getByRole('status')).toBeEmptyDOMElement() + expect(screen.getByTestId('empty-state').closest('[aria-busy]')).toHaveAttribute( + 'aria-busy', + 'true', + ) + }) + + it('should keep the settled empty status during a background refetch', () => { + mockAppData = { pages: [{ data: [], total: 0 }] } + mockServiceState.isFetching = true + + renderList('?keywords=missing+app') + + expect(screen.getByRole('status')).toHaveTextContent('app.filterEmpty.noApps') + expect(screen.getByTestId('empty-state').closest('[aria-busy]')).toHaveAttribute( + 'aria-busy', + 'true', + ) + }) + it('should leave the first empty state as soon as a filter changes', () => { mockAppData = { pages: [{ data: [], total: 0 }] } renderList() @@ -1073,11 +1122,12 @@ describe('List', () => { expect(scrollTo).toHaveBeenCalledWith({ top: 0 }) }) - it('should build paged query input from active filters', () => { + it('should build paged query input from active filters', async () => { + const user = userEvent.setup() renderList('?keywords=sales&category=workflow') - fireEvent.click(screen.getByRole('button', { name: 'Creators' })) - fireEvent.click(screen.getByText('Alice')) - fireEvent.click(screen.getByText('common.tag.placeholder')) + await user.click(screen.getByRole('combobox', { name: 'Creators' })) + await user.click(screen.getByRole('option', { name: /Alice/ })) + await user.click(screen.getByText('common.tag.placeholder')) const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions @@ -1096,11 +1146,12 @@ describe('List', () => { expect(options.getNextPageParam({ has_more: false, page: 2 })).toBeUndefined() }) - it('should build starred query input from active filters with the starred limit', () => { + it('should build starred query input from active filters with the starred limit', async () => { + const user = userEvent.setup() renderList('?keywords=sales&category=workflow') - fireEvent.click(screen.getByRole('button', { name: 'Creators' })) - fireEvent.click(screen.getByText('Alice')) - fireEvent.click(screen.getByText('common.tag.placeholder')) + await user.click(screen.getByRole('combobox', { name: 'Creators' })) + await user.click(screen.getByRole('option', { name: /Alice/ })) + await user.click(screen.getByText('common.tag.placeholder')) const options = mockAppStarredListQueryOptions.mock.calls.at( -1, @@ -1140,13 +1191,15 @@ describe('List', () => { }) describe('Creators Filter', () => { - it('should handle creator selection', () => { + it('should handle creator selection', async () => { + const user = userEvent.setup() renderList() - fireEvent.click(screen.getByRole('button', { name: 'Creators' })) - fireEvent.click(screen.getByRole('button', { name: /Bob/ })) + const trigger = screen.getByRole('combobox', { name: 'Creators' }) + await user.click(trigger) + await user.click(screen.getByRole('option', { name: /Bob/ })) - expect(screen.getByRole('button', { name: /Creators.*\+1/ })).toBeInTheDocument() + expect(trigger).toHaveTextContent('+1') }) }) diff --git a/web/app/components/apps/app-card-skeleton.tsx b/web/app/components/apps/app-card-skeleton.tsx index 977ff7a3407..c60429d914c 100644 --- a/web/app/components/apps/app-card-skeleton.tsx +++ b/web/app/components/apps/app-card-skeleton.tsx @@ -17,7 +17,8 @@ export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps) return ( <> {skeletonKeys.map((key) => ( -
@@ -34,7 +35,7 @@ export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps)
-
+ ))} ) diff --git a/web/app/components/apps/app-card/action-bar/index.tsx b/web/app/components/apps/app-card/action-bar/index.tsx deleted file mode 100644 index 0f50501b552..00000000000 --- a/web/app/components/apps/app-card/action-bar/index.tsx +++ /dev/null @@ -1,569 +0,0 @@ -'use client' - -import type { - AppPartial, - EnvironmentVariableItemResponse, -} from '@dify/contracts/api/console/apps/types.gen' -import type { FormEventHandler } from 'react' -import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' -import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' -import { zIconType } from '@dify/contracts/api/console/apps/zod.gen' -import { - AlertDialog, - AlertDialogActions, - AlertDialogCancelButton, - AlertDialogConfirmButton, - AlertDialogContent, - AlertDialogDescription, - AlertDialogTitle, -} from '@langgenius/dify-ui/alert-dialog' -import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from '@langgenius/dify-ui/dropdown-menu' -import { Field, FieldLabel } from '@langgenius/dify-ui/field' -import { IconButton } from '@langgenius/dify-ui/icon-button' -import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group' -import { toast } from '@langgenius/dify-ui/toast' -import { Toggle } from '@langgenius/dify-ui/toggle' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { useMutation, useSuspenseQuery } from '@tanstack/react-query' -import { useAtomValue } from 'jotai' -import { memo, useCallback, useMemo, useState } from 'react' -import { Trans, useTranslation } from 'react-i18next' -import { useExportAppDsl, useExportWorkflowAppDsl } from '@/app/components/app/use-export-app-dsl' -import StarIcon from '@/app/components/base/icons/src/vender/Star' -import { - getStepByStepTourDropdownMenuContentProps, - useStepByStepTourControlledDropdown, -} from '@/app/components/step-by-step-tour/dropdown-menu' -import { workspacePermissionKeysAtom } from '@/context/permission-state' -import { useProviderContext } from '@/context/provider-context' -import { userProfileQueryOptions } from '@/features/account-profile/client' -import { systemFeaturesQueryOptions } from '@/features/system-features/client' -import dynamic from '@/next/dynamic' -import { useRouter } from '@/next/navigation' -import { consoleQuery } from '@/service/client' -import { AppModeEnum } from '@/types/app' -import { getRedirection } from '@/utils/app-redirection' -import { - getAppACLCapabilities, - hasOnlyAppPreviewPermission, - hasPermission, -} from '@/utils/permission' -import { AppCardOperationsMenuContent } from '../operations-menu' - -const EditAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), { - ssr: false, -}) -const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), { - ssr: false, -}) -const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), { - ssr: false, -}) -const DSLExportConfirmModal = dynamic( - () => import('@/app/components/workflow/dsl-export-confirm-modal'), - { - ssr: false, - }, -) - -const OPERATIONS_MENU_POPUP_CLASS_NAME = 'min-w-[216px]' - -type AppCardActionBarProps = { - app: AppPartial - stepByStepTourActionMenuOpen?: boolean - stepByStepTourActionMenuHighlightPart?: string -} - -export const AppCardActionBar = memo( - ({ - app, - stepByStepTourActionMenuOpen = false, - stepByStepTourActionMenuHighlightPart, - }: AppCardActionBarProps) => { - const { t } = useTranslation() - const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const { data: currentUserId } = useSuspenseQuery({ - ...userProfileQueryOptions(), - select: (data) => data.profile.id, - }) - const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const isRbacEnabled = systemFeatures.rbac_enabled - const { onPlanInfoChanged } = useProviderContext() - const { push } = useRouter() - const { mutate: copyApp } = useMutation(consoleQuery.apps.byAppId.copy.post.mutationOptions()) - const { mutateAsync: updateApp } = useMutation(consoleQuery.apps.byAppId.put.mutationOptions()) - const { mutate: deleteApp, isPending: isDeleting } = useMutation( - consoleQuery.apps.byAppId.delete.mutationOptions(), - ) - const { mutate: starApp, isPending: isStarring } = useMutation( - consoleQuery.apps.byAppId.star.post.mutationOptions(), - ) - const { mutate: unstarApp, isPending: isUnstarring } = useMutation( - consoleQuery.apps.byAppId.star.delete.mutationOptions(), - ) - - const [showEditModal, setShowEditModal] = useState(false) - const [showDuplicateModal, setShowDuplicateModal] = useState(false) - const [showSwitchModal, setShowSwitchModal] = useState(false) - const [showConfirmDelete, setShowConfirmDelete] = useState(false) - const [confirmDeleteInput, setConfirmDeleteInput] = useState('') - const operationsMenu = useStepByStepTourControlledDropdown({ - allowTriggerCloseWhileControlled: false, - controlledOpen: stepByStepTourActionMenuOpen, - }) - const isOperationsMenuOpen = operationsMenu.open - const setIsOperationsMenuOpen = operationsMenu.onOpenChange - const [secretEnvList, setSecretEnvList] = useState([]) - const { exportAppDsl, isExporting: isAppDslExporting } = useExportAppDsl() - const { exportWorkflowAppDsl, isExporting: isWorkflowAppDslExporting } = - useExportWorkflowAppDsl() - const isExporting = isAppDslExporting || isWorkflowAppDslExporting - const isTogglingStar = isStarring || isUnstarring - const appIconType = zIconType.safeParse(app.icon_type).data ?? null - const resourceMaintainer = app.maintainer ?? undefined - const maintainerPermissionOptions = useMemo( - () => ({ - currentUserId, - resourceMaintainer, - workspacePermissionKeys, - isRbacEnabled, - }), - [currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys], - ) - const appACLCapabilities = useMemo( - () => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions), - [app.permission_keys, maintainerPermissionOptions], - ) - const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys) - const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') - - const onConfirmDelete = useCallback(() => { - try { - deleteApp( - { params: { app_id: app.id } }, - { - onSuccess: () => { - toast.success(t(($) => $.appDeleted, { ns: 'app' })) - onPlanInfoChanged() - setShowConfirmDelete(false) - setConfirmDeleteInput('') - }, - onError: (error) => { - const message = error instanceof Error ? error.message : '' - toast.error( - `${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`, - ) - }, - }, - ) - } catch (error) { - const message = error instanceof Error ? error.message : '' - toast.error(`${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`) - } - }, [app.id, deleteApp, onPlanInfoChanged, t]) - - const onDeleteDialogOpenChange = useCallback( - (open: boolean) => { - if (isDeleting) return - - setShowConfirmDelete(open) - if (!open) setConfirmDeleteInput('') - }, - [isDeleting], - ) - - const isDeleteConfirmDisabled = isDeleting || confirmDeleteInput !== app.name - - const onDeleteDialogSubmit: FormEventHandler = useCallback( - (e) => { - e.preventDefault() - if (isDeleteConfirmDisabled) return - - void onConfirmDelete() - }, - [isDeleteConfirmDisabled, onConfirmDelete], - ) - - const handleShowEditModal = useCallback(() => { - setIsOperationsMenuOpen(false) - queueMicrotask(() => { - setShowEditModal(true) - }) - }, [setIsOperationsMenuOpen]) - - const handleShowDuplicateModal = useCallback(() => { - setIsOperationsMenuOpen(false) - queueMicrotask(() => { - setShowDuplicateModal(true) - }) - }, [setIsOperationsMenuOpen]) - - const handleShowSwitchModal = useCallback(() => { - setIsOperationsMenuOpen(false) - queueMicrotask(() => { - setShowSwitchModal(true) - }) - }, [setIsOperationsMenuOpen]) - - const handleShowDeleteConfirm = useCallback(() => { - setIsOperationsMenuOpen(false) - queueMicrotask(() => { - setShowConfirmDelete(true) - }) - }, [setIsOperationsMenuOpen]) - - const handleOpenAccessConfig = useCallback(() => { - setIsOperationsMenuOpen(false) - push(`/app/${app.id}/access-config`) - }, [app.id, push, setIsOperationsMenuOpen]) - - const onEdit: CreateAppModalProps['onConfirm'] = useCallback( - async ({ - name, - icon_type, - icon, - icon_background, - description, - use_icon_as_answer_icon, - max_active_requests, - }) => { - try { - await updateApp({ - params: { app_id: app.id }, - body: { - name, - icon_type, - icon, - icon_background, - description, - use_icon_as_answer_icon, - max_active_requests, - }, - }) - setShowEditModal(false) - toast.success(t(($) => $.editDone, { ns: 'app' })) - } catch (e) { - toast.error(e instanceof Error ? e.message : t(($) => $.editFailed, { ns: 'app' })) - } - }, - [app.id, t, updateApp], - ) - - const onCopy: DuplicateAppModalProps['onConfirm'] = ({ - name, - icon_type, - icon, - icon_background, - }) => { - try { - copyApp( - { - params: { app_id: app.id }, - body: { - name, - icon_type, - icon, - icon_background, - }, - }, - { - onSuccess: (newApp) => { - if (!('mode' in newApp)) { - toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) - return - } - - setShowDuplicateModal(false) - toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' })) - onPlanInfoChanged() - getRedirection(newApp, push, { - currentUserId, - resourceMaintainer: newApp.maintainer ?? undefined, - workspacePermissionKeys, - isRbacEnabled, - }) - }, - onError: () => toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })), - }, - ) - } catch { - toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) - } - return Promise.resolve() - } - - const onExport = async (include = false) => { - await exportAppDsl({ appId: app.id, appName: app.name, includeSecret: include }) - } - - const exportCheck = async () => { - if (isExporting) return - - setIsOperationsMenuOpen(false) - const isWorkflowApp = - app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT - const result = isWorkflowApp - ? await exportWorkflowAppDsl({ appId: app.id, appName: app.name }) - : await exportAppDsl({ appId: app.id, appName: app.name }) - if (result?.status === 'confirmation-required') setSecretEnvList(result.secretEnvList) - } - - const handleToggleStar = useCallback( - (pressed: boolean) => { - if (isTogglingStar) return - - const mutateStar = pressed ? starApp : unstarApp - try { - mutateStar( - { params: { app_id: app.id } }, - { - onError: (error) => - toast.error( - error instanceof Error - ? error.message - : t(($) => $['studio.starFailed'], { ns: 'app' }), - ), - }, - ) - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t(($) => $['studio.starFailed'], { ns: 'app' }), - ) - } - }, - [app.id, isTogglingStar, starApp, t, unstarApp], - ) - - const shouldShowEditOption = appACLCapabilities.canEdit - const shouldShowDuplicateOption = canCreateApp - const shouldShowExportOption = appACLCapabilities.canImportExportDSL - const shouldShowSwitchOption = - appACLCapabilities.canEdit && - (app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) - const shouldShowAccessConfigOption = appACLCapabilities.canAccessConfig - const shouldShowDeleteOption = appACLCapabilities.canDelete - const shouldShowOperationsMenu = - shouldShowEditOption || - shouldShowDuplicateOption || - shouldShowExportOption || - shouldShowSwitchOption || - shouldShowAccessConfigOption || - shouldShowDeleteOption - const starActionLabel = app.is_starred - ? t(($) => $['studio.unstarApp'], { ns: 'app' }) - : t(($) => $['studio.starApp'], { ns: 'app' }) - const starToggleLabel = t(($) => $['studio.starApp'], { ns: 'app' }) - - return ( - <> - {!isPreviewOnly && ( -
- - - - - } - /> - } - /> - {starActionLabel} - - {shouldShowOperationsMenu && ( - - $['operation.exporting'], { ns: 'common' }) - : t(($) => $['operation.moreActionsFor'], { - ns: 'common', - name: app.name, - }) - } - disabled={isExporting} - className="data-popup-open:bg-state-base-hover" - > - - - } - /> - - - - - )} -
- )} - {showEditModal && ( - setShowEditModal(false)} - /> - )} - {showDuplicateModal && ( - setShowDuplicateModal(false)} - /> - )} - {showSwitchModal && ( - setShowSwitchModal(false)} - /> - )} - - -
-
- - {t(($) => $.deleteAppConfirmTitle, { ns: 'app' })} - - - {t(($) => $.deleteAppConfirmContent, { ns: 'app' })} - - - - $.deleteAppConfirmInputLabel} - ns="app" - values={{ appName: app.name }} - components={{ - appName: ( - - ), - }} - /> - - - $.deleteAppConfirmInputPlaceholder, { ns: 'app' })} - value={confirmDeleteInput} - onValueChange={setConfirmDeleteInput} - /> - - - - - -
- - - {t(($) => $['operation.cancel'], { ns: 'common' })} - - - {t(($) => $['operation.confirm'], { ns: 'common' })} - - -
-
-
- {secretEnvList.length > 0 && ( - setSecretEnvList([])} - /> - )} - - ) - }, -) diff --git a/web/app/components/apps/app-card/index.tsx b/web/app/components/apps/app-card/index.tsx index 8a1760a68f6..0d790f2dd9d 100644 --- a/web/app/components/apps/app-card/index.tsx +++ b/web/app/components/apps/app-card/index.tsx @@ -25,7 +25,7 @@ import { hasPermission, } from '@/utils/permission' import { formatTime } from '@/utils/time' -import { AppCardActionBar } from './action-bar' +import { AppCardInteractions } from './interactions' const EMPTY_ONLINE_USERS: WorkflowOnlineUser[] = [] @@ -120,10 +120,8 @@ export const AppCard = memo( const appIconType = zIconType.safeParse(app.icon_type).data ?? null const appHref = getRedirectionPath(app, maintainerPermissionOptions) const appCardClassName = cn( - 'inline-flex h-full w-full touch-manipulation flex-col overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs outline-hidden transition-shadow duration-200 ease-in-out', - isPreviewOnly - ? 'cursor-not-allowed opacity-60 focus-visible:ring-2 focus-visible:ring-state-accent-solid' - : 'cursor-pointer hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid', + 'inline-flex h-full w-full touch-manipulation flex-col rounded-xl outline-hidden', + isPreviewOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer', ) const showPreviewOnlyAccessWarning = useCallback(() => { toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' })) @@ -138,6 +136,7 @@ export const AppCard = memo( icon={app.icon ?? undefined} background={app.icon_background} imageUrl={app.icon_url} + decorative /> -
-
+
{app.author_name && ( <> @@ -187,7 +185,13 @@ export const AppCard = memo( ) return ( -
+
  • a:focus-visible]:after:inset-ring-2 has-[>a:focus-visible]:after:inset-ring-state-accent-solid has-[>button:focus-visible]:after:inset-ring-2 has-[>button:focus-visible]:after:inset-ring-state-accent-solid motion-reduce:transition-none", + !isPreviewOnly && + 'hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5 has-data-popup-open:bg-components-card-bg-alt has-data-popup-open:shadow-md has-data-popup-open:shadow-shadow-shadow-5 [@media(hover:none)]:bg-components-card-bg-alt', + )} + > {isPreviewOnly ? (
  • + ) }, ) diff --git a/web/app/components/apps/app-card/interactions.tsx b/web/app/components/apps/app-card/interactions.tsx new file mode 100644 index 00000000000..0bdb03c2101 --- /dev/null +++ b/web/app/components/apps/app-card/interactions.tsx @@ -0,0 +1,757 @@ +'use client' + +import type { + AppPartial, + EnvironmentVariableItemResponse, +} from '@dify/contracts/api/console/apps/types.gen' +import type { FormEventHandler, MouseEvent, ReactElement } from 'react' +import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' +import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' +import { zIconType } from '@dify/contracts/api/console/apps/zod.gen' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@langgenius/dify-ui/context-menu' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { Field, FieldLabel } from '@langgenius/dify-ui/field' +import { IconButton } from '@langgenius/dify-ui/icon-button' +import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group' +import { toast } from '@langgenius/dify-ui/toast' +import { Toggle } from '@langgenius/dify-ui/toggle' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useMutation, useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' +import { useCallback, useMemo, useState } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { useExportAppDsl, useExportWorkflowAppDsl } from '@/app/components/app/use-export-app-dsl' +import StarIcon from '@/app/components/base/icons/src/vender/Star' +import { buildInstalledAppPath } from '@/app/components/explore/installed-app/routes' +import { + getStepByStepTourDropdownMenuContentProps, + useStepByStepTourControlledDropdown, +} from '@/app/components/step-by-step-tour/dropdown-menu' +import { workspacePermissionKeysAtom } from '@/context/permission-state' +import { useProviderContext } from '@/context/provider-context' +import { userProfileQueryOptions } from '@/features/account-profile/client' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' +import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' +import { AccessMode } from '@/models/access-control' +import dynamic from '@/next/dynamic' +import { useRouter } from '@/next/navigation' +import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control' +import { consoleQuery } from '@/service/client' +import { fetchInstalledAppList } from '@/service/explore' +import { AppModeEnum } from '@/types/app' +import { getRedirection } from '@/utils/app-redirection' +import { getAppACLCapabilities, hasPermission } from '@/utils/permission' +import { basePath } from '@/utils/var' + +const EditAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), { + ssr: false, +}) +const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), { + ssr: false, +}) +const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), { + ssr: false, +}) +const DSLExportConfirmModal = dynamic( + () => import('@/app/components/workflow/dsl-export-confirm-modal'), + { + ssr: false, + }, +) + +const OPERATIONS_MENU_POPUP_CLASS_NAME = 'min-w-[216px]' +const APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE = new Set([ + AppModeEnum.ADVANCED_CHAT, + AppModeEnum.WORKFLOW, +]) + +function requiresPublishedWorkflowInExplore(app: AppPartial) { + return APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE.has(app.mode) +} + +type AppCardOperationsMenuItemsProps = { + app: AppPartial + kind: 'context' | 'dropdown' + shouldShowEditOption: boolean + shouldShowDuplicateOption: boolean + shouldShowExportOption: boolean + shouldShowSwitchOption: boolean + shouldShowAccessConfigOption: boolean + shouldShowDeleteOption: boolean + isExporting: boolean + onEdit: () => void + onDuplicate: () => void + onExport: () => void + onSwitch: () => void + onDelete: () => void + onAccessConfig: () => void +} + +function AppCardOperationsMenuItems({ + app, + kind, + shouldShowEditOption, + shouldShowDuplicateOption, + shouldShowExportOption, + shouldShowSwitchOption, + shouldShowAccessConfigOption, + shouldShowDeleteOption, + isExporting, + onEdit, + onDuplicate, + onExport, + onSwitch, + onDelete, + onAccessConfig, +}: AppCardOperationsMenuItemsProps) { + const { t } = useTranslation() + const openAsyncWindow = useAsyncWindowOpen() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({ + appId: app.id, + enabled: systemFeatures.webapp_auth.enabled, + }) + const needsPublishBeforeExplore = requiresPublishedWorkflowInExplore(app) && !app.workflow?.id + const shouldShowOpenInExploreOption = + !app.has_draft_trigger && + app.access_mode !== AccessMode.EXTERNAL_MEMBERS && + (needsPublishBeforeExplore || + !systemFeatures.webapp_auth.enabled || + (!isGettingUserCanAccessApp && Boolean(userCanAccessApp?.result))) + const hasEditGroup = shouldShowEditOption + const hasCreateExportGroup = shouldShowDuplicateOption || shouldShowExportOption + const hasSwitchOrExploreGroup = shouldShowSwitchOption || shouldShowOpenInExploreOption + const hasAccessDeleteGroup = shouldShowAccessConfigOption || shouldShowDeleteOption + const MenuItem = kind === 'context' ? ContextMenuItem : DropdownMenuItem + const MenuSeparator = kind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator + + function handleMenuAction(event: MouseEvent, action: () => void) { + event.stopPropagation() + event.preventDefault() + action() + } + + async function handleOpenInstalledApp(event: MouseEvent) { + event.stopPropagation() + event.preventDefault() + if (requiresPublishedWorkflowInExplore(app) && !app.workflow?.id) { + toast.error(t(($) => $.notPublishedYet, { ns: 'app' })) + return + } + + try { + await openAsyncWindow( + async () => { + const { installed_apps } = await fetchInstalledAppList(app.id) + if (installed_apps?.length > 0) + return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}` + throw new Error(t(($) => $.notPublishedYet, { ns: 'app' })) + }, + { + onError: (error) => { + toast.error(`${error.message || error}`) + }, + }, + ) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : `${error}` + toast.error(message) + } + } + + return ( + <> + {shouldShowEditOption && ( + handleMenuAction(event, onEdit)}> + + {t(($) => $.editApp, { ns: 'app' })} + + + )} + {hasEditGroup && + (hasCreateExportGroup || hasSwitchOrExploreGroup || hasAccessDeleteGroup) && ( + + )} + {shouldShowDuplicateOption && ( + handleMenuAction(event, onDuplicate)}> + + {t(($) => $.duplicate, { ns: 'app' })} + + + )} + {shouldShowExportOption && ( + handleMenuAction(event, onExport)} + > + + {t(($) => $.export, { ns: 'app' })} + + + )} + {hasCreateExportGroup && (hasSwitchOrExploreGroup || hasAccessDeleteGroup) && ( + + )} + {shouldShowSwitchOption && ( + handleMenuAction(event, onSwitch)}> + {t(($) => $.switch, { ns: 'app' })} + + )} + {shouldShowOpenInExploreOption && ( + + + {t(($) => $.openInExplore, { ns: 'app' })} + + + )} + {hasSwitchOrExploreGroup && hasAccessDeleteGroup && } + {shouldShowAccessConfigOption && ( + handleMenuAction(event, onAccessConfig)} + > + + {t(($) => $['settings.resourceAccess'], { ns: 'common' })} + + + )} + {shouldShowAccessConfigOption && shouldShowDeleteOption && } + {shouldShowDeleteOption && ( + handleMenuAction(event, onDelete)} + > + + {t(($) => $['operation.delete'], { ns: 'common' })} + + + )} + + ) +} + +type AppCardInteractionsProps = { + app: AppPartial + children: ReactElement + stepByStepTourActionMenuOpen?: boolean + stepByStepTourActionMenuHighlightPart?: string +} + +export function AppCardInteractions({ + app, + children, + stepByStepTourActionMenuOpen = false, + stepByStepTourActionMenuHighlightPart, +}: AppCardInteractionsProps) { + const { t } = useTranslation() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const { data: currentUserId } = useSuspenseQuery({ + ...userProfileQueryOptions(), + select: (data) => data.profile.id, + }) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) + const isRbacEnabled = systemFeatures.rbac_enabled + const { onPlanInfoChanged } = useProviderContext() + const { push } = useRouter() + const { mutate: copyApp } = useMutation(consoleQuery.apps.byAppId.copy.post.mutationOptions()) + const { mutateAsync: updateApp } = useMutation(consoleQuery.apps.byAppId.put.mutationOptions()) + const { mutate: deleteApp, isPending: isDeleting } = useMutation( + consoleQuery.apps.byAppId.delete.mutationOptions(), + ) + const { mutate: starApp, isPending: isStarring } = useMutation( + consoleQuery.apps.byAppId.star.post.mutationOptions(), + ) + const { mutate: unstarApp, isPending: isUnstarring } = useMutation( + consoleQuery.apps.byAppId.star.delete.mutationOptions(), + ) + + const [activeDialog, setActiveDialog] = useState< + 'delete' | 'duplicate' | 'edit' | 'switch' | null + >(null) + const [confirmDeleteInput, setConfirmDeleteInput] = useState('') + const operationsMenu = useStepByStepTourControlledDropdown({ + allowTriggerCloseWhileControlled: false, + controlledOpen: stepByStepTourActionMenuOpen, + }) + const isOperationsMenuOpen = operationsMenu.open + const setIsOperationsMenuOpen = operationsMenu.onOpenChange + const [secretEnvList, setSecretEnvList] = useState([]) + const { exportAppDsl, isExporting: isAppDslExporting } = useExportAppDsl() + const { exportWorkflowAppDsl, isExporting: isWorkflowAppDslExporting } = useExportWorkflowAppDsl() + const isExporting = isAppDslExporting || isWorkflowAppDslExporting + const isTogglingStar = isStarring || isUnstarring + const appIconType = zIconType.safeParse(app.icon_type).data ?? null + const resourceMaintainer = app.maintainer ?? undefined + const maintainerPermissionOptions = useMemo( + () => ({ + currentUserId, + resourceMaintainer, + workspacePermissionKeys, + isRbacEnabled, + }), + [currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys], + ) + const appACLCapabilities = useMemo( + () => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions), + [app.permission_keys, maintainerPermissionOptions], + ) + const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') + + const onConfirmDelete = useCallback(() => { + try { + deleteApp( + { params: { app_id: app.id } }, + { + onSuccess: () => { + toast.success(t(($) => $.appDeleted, { ns: 'app' })) + onPlanInfoChanged() + setActiveDialog(null) + setConfirmDeleteInput('') + }, + onError: (error) => { + const message = error instanceof Error ? error.message : '' + toast.error( + `${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`, + ) + }, + }, + ) + } catch (error) { + const message = error instanceof Error ? error.message : '' + toast.error(`${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`) + } + }, [app.id, deleteApp, onPlanInfoChanged, t]) + + const onDeleteDialogOpenChange = useCallback( + (open: boolean) => { + if (isDeleting) return + + setActiveDialog(open ? 'delete' : null) + if (!open) setConfirmDeleteInput('') + }, + [isDeleting], + ) + + const isDeleteConfirmDisabled = isDeleting || confirmDeleteInput !== app.name + + const onDeleteDialogSubmit: FormEventHandler = useCallback( + (e) => { + e.preventDefault() + if (isDeleteConfirmDisabled) return + + void onConfirmDelete() + }, + [isDeleteConfirmDisabled, onConfirmDelete], + ) + + const handleShowEditModal = useCallback(() => { + setIsOperationsMenuOpen(false) + queueMicrotask(() => { + setActiveDialog('edit') + }) + }, [setIsOperationsMenuOpen]) + + const handleShowDuplicateModal = useCallback(() => { + setIsOperationsMenuOpen(false) + queueMicrotask(() => { + setActiveDialog('duplicate') + }) + }, [setIsOperationsMenuOpen]) + + const handleShowSwitchModal = useCallback(() => { + setIsOperationsMenuOpen(false) + queueMicrotask(() => { + setActiveDialog('switch') + }) + }, [setIsOperationsMenuOpen]) + + const handleShowDeleteConfirm = useCallback(() => { + setIsOperationsMenuOpen(false) + queueMicrotask(() => { + setActiveDialog('delete') + }) + }, [setIsOperationsMenuOpen]) + + const handleOpenAccessConfig = useCallback(() => { + setIsOperationsMenuOpen(false) + push(`/app/${app.id}/access-config`) + }, [app.id, push, setIsOperationsMenuOpen]) + + const onEdit: CreateAppModalProps['onConfirm'] = useCallback( + async ({ + name, + icon_type, + icon, + icon_background, + description, + use_icon_as_answer_icon, + max_active_requests, + }) => { + try { + await updateApp({ + params: { app_id: app.id }, + body: { + name, + icon_type, + icon, + icon_background, + description, + use_icon_as_answer_icon, + max_active_requests, + }, + }) + setActiveDialog(null) + toast.success(t(($) => $.editDone, { ns: 'app' })) + } catch (e) { + toast.error(e instanceof Error ? e.message : t(($) => $.editFailed, { ns: 'app' })) + } + }, + [app.id, t, updateApp], + ) + + const onCopy: DuplicateAppModalProps['onConfirm'] = ({ + name, + icon_type, + icon, + icon_background, + }) => { + try { + copyApp( + { + params: { app_id: app.id }, + body: { + name, + icon_type, + icon, + icon_background, + }, + }, + { + onSuccess: (newApp) => { + if (!('mode' in newApp)) { + toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) + return + } + + setActiveDialog(null) + toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' })) + onPlanInfoChanged() + getRedirection(newApp, push, { + currentUserId, + resourceMaintainer: newApp.maintainer ?? undefined, + workspacePermissionKeys, + isRbacEnabled, + }) + }, + onError: () => toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })), + }, + ) + } catch { + toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })) + } + return Promise.resolve() + } + + const onExport = async (include = false) => { + await exportAppDsl({ appId: app.id, appName: app.name, includeSecret: include }) + } + + const exportCheck = async () => { + if (isExporting) return + + setIsOperationsMenuOpen(false) + const isWorkflowApp = + app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT + const result = isWorkflowApp + ? await exportWorkflowAppDsl({ appId: app.id, appName: app.name }) + : await exportAppDsl({ appId: app.id, appName: app.name }) + if (result?.status === 'confirmation-required') setSecretEnvList(result.secretEnvList) + } + + const handleToggleStar = useCallback( + (pressed: boolean) => { + if (isTogglingStar) return + + const mutateStar = pressed ? starApp : unstarApp + try { + mutateStar( + { params: { app_id: app.id } }, + { + onError: (error) => + toast.error( + error instanceof Error + ? error.message + : t(($) => $['studio.starFailed'], { ns: 'app' }), + ), + }, + ) + } catch (error) { + toast.error( + error instanceof Error ? error.message : t(($) => $['studio.starFailed'], { ns: 'app' }), + ) + } + }, + [app.id, isTogglingStar, starApp, t, unstarApp], + ) + + const shouldShowEditOption = appACLCapabilities.canEdit + const shouldShowDuplicateOption = canCreateApp + const shouldShowExportOption = appACLCapabilities.canImportExportDSL + const shouldShowSwitchOption = + appACLCapabilities.canEdit && + (app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) + const shouldShowAccessConfigOption = appACLCapabilities.canAccessConfig + const shouldShowDeleteOption = appACLCapabilities.canDelete + const shouldShowOperationsMenu = + shouldShowEditOption || + shouldShowDuplicateOption || + shouldShowExportOption || + shouldShowSwitchOption || + shouldShowAccessConfigOption || + shouldShowDeleteOption + const starToggleLabel = t(($) => $['studio.starApp'], { ns: 'app' }) + const starToggleAccessibleLabel = `${starToggleLabel}: ${app.name}` + const operationsMenuItemsProps = { + app, + shouldShowEditOption, + shouldShowDuplicateOption, + shouldShowExportOption, + shouldShowSwitchOption, + shouldShowAccessConfigOption, + shouldShowDeleteOption, + isExporting, + onEdit: handleShowEditModal, + onDuplicate: handleShowDuplicateModal, + onExport: exportCheck, + onSwitch: handleShowSwitchModal, + onDelete: handleShowDeleteConfirm, + onAccessConfig: handleOpenAccessConfig, + } + + return ( + <> + {shouldShowOperationsMenu ? ( + + + + + + + ) : ( + children + )} +
    +
    + + + + + } + /> + } + /> + {starToggleLabel} + + {shouldShowOperationsMenu && ( + + $['operation.exporting'], { ns: 'common' }) + : t(($) => $['operation.moreActionsFor'], { + ns: 'common', + name: app.name, + }) + } + disabled={isExporting} + className="data-popup-open:bg-state-base-hover" + > + + + } + /> + + + + + )} +
    +
    + {activeDialog === 'edit' && ( + setActiveDialog(null)} + /> + )} + {activeDialog === 'duplicate' && ( + setActiveDialog(null)} + /> + )} + {activeDialog === 'switch' && ( + setActiveDialog(null)} /> + )} + + +
    +
    + + {t(($) => $.deleteAppConfirmTitle, { ns: 'app' })} + + + {t(($) => $.deleteAppConfirmContent, { ns: 'app' })} + + + + $.deleteAppConfirmInputLabel} + ns="app" + values={{ appName: app.name }} + components={{ + appName: ( + + ), + }} + /> + + + $.deleteAppConfirmInputPlaceholder, { ns: 'app' })} + value={confirmDeleteInput} + onValueChange={setConfirmDeleteInput} + /> + + + + + +
    + + + {t(($) => $['operation.cancel'], { ns: 'common' })} + + + {t(($) => $['operation.confirm'], { ns: 'common' })} + + +
    +
    +
    + {secretEnvList.length > 0 && ( + setSecretEnvList([])} + /> + )} + + ) +} diff --git a/web/app/components/apps/app-card/operations-menu.tsx b/web/app/components/apps/app-card/operations-menu.tsx deleted file mode 100644 index 714d9dce82e..00000000000 --- a/web/app/components/apps/app-card/operations-menu.tsx +++ /dev/null @@ -1,201 +0,0 @@ -'use client' - -import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen' -import type { MouseEvent } from 'react' -import { DropdownMenuItem, DropdownMenuSeparator } from '@langgenius/dify-ui/dropdown-menu' -import { toast } from '@langgenius/dify-ui/toast' -import { useSuspenseQuery } from '@tanstack/react-query' -import { useTranslation } from 'react-i18next' -import { buildInstalledAppPath } from '@/app/components/explore/installed-app/routes' -import { systemFeaturesQueryOptions } from '@/features/system-features/client' -import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' -import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control' -import { fetchInstalledAppList } from '@/service/explore' -import { AppModeEnum } from '@/types/app' -import { basePath } from '@/utils/var' - -const APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE = new Set([ - AppModeEnum.ADVANCED_CHAT, - AppModeEnum.WORKFLOW, -]) - -function requiresPublishedWorkflowInExplore(app: AppPartial) { - return APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE.has(app.mode) -} - -type AppCardOperationsMenuProps = { - app: AppPartial - shouldShowEditOption: boolean - shouldShowDuplicateOption: boolean - shouldShowExportOption: boolean - shouldShowSwitchOption: boolean - shouldShowOpenInExploreOption: boolean - shouldShowAccessConfigOption: boolean - shouldShowDeleteOption: boolean - isExporting: boolean - onEdit: () => void - onDuplicate: () => void - onExport: () => void - onSwitch: () => void - onDelete: () => void - onAccessConfig: () => void -} - -function AppCardOperationsMenu({ - app, - shouldShowEditOption, - shouldShowDuplicateOption, - shouldShowExportOption, - shouldShowSwitchOption, - shouldShowOpenInExploreOption, - shouldShowAccessConfigOption, - shouldShowDeleteOption, - isExporting, - onEdit, - onDuplicate, - onExport, - onSwitch, - onDelete, - onAccessConfig, -}: AppCardOperationsMenuProps) { - const { t } = useTranslation() - const openAsyncWindow = useAsyncWindowOpen() - const hasEditGroup = shouldShowEditOption - const hasCreateExportGroup = shouldShowDuplicateOption || shouldShowExportOption - const hasSwitchOrExploreGroup = shouldShowSwitchOption || shouldShowOpenInExploreOption - const hasAccessDeleteGroup = shouldShowAccessConfigOption || shouldShowDeleteOption - - function handleMenuAction(e: MouseEvent, action: () => void) { - e.stopPropagation() - e.preventDefault() - action() - } - - async function handleOpenInstalledApp(e: MouseEvent) { - e.stopPropagation() - e.preventDefault() - if (requiresPublishedWorkflowInExplore(app) && !app.workflow?.id) { - toast.error(t(($) => $.notPublishedYet, { ns: 'app' })) - return - } - - try { - await openAsyncWindow( - async () => { - const { installed_apps } = await fetchInstalledAppList(app.id) - if (installed_apps?.length > 0) - return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}` - throw new Error(t(($) => $.notPublishedYet, { ns: 'app' })) - }, - { - onError: (err) => { - toast.error(`${err.message || err}`) - }, - }, - ) - } catch (e: unknown) { - const message = e instanceof Error ? e.message : `${e}` - toast.error(message) - } - } - - return ( - <> - {shouldShowEditOption && ( - handleMenuAction(e, onEdit)}> - - {t(($) => $.editApp, { ns: 'app' })} - - - )} - {hasEditGroup && - (hasCreateExportGroup || hasSwitchOrExploreGroup || hasAccessDeleteGroup) && ( - - )} - {shouldShowDuplicateOption && ( - handleMenuAction(e, onDuplicate)}> - - {t(($) => $.duplicate, { ns: 'app' })} - - - )} - {shouldShowExportOption && ( - handleMenuAction(e, onExport)} - > - - {t(($) => $.export, { ns: 'app' })} - - - )} - {hasCreateExportGroup && (hasSwitchOrExploreGroup || hasAccessDeleteGroup) && ( - - )} - {shouldShowSwitchOption && ( - handleMenuAction(e, onSwitch)}> - {t(($) => $.switch, { ns: 'app' })} - - )} - {shouldShowOpenInExploreOption && ( - - - {t(($) => $.openInExplore, { ns: 'app' })} - - - )} - {hasSwitchOrExploreGroup && hasAccessDeleteGroup && } - {shouldShowAccessConfigOption && ( - handleMenuAction(e, onAccessConfig)} - > - - {t(($) => $['settings.resourceAccess'], { ns: 'common' })} - - - )} - {shouldShowAccessConfigOption && shouldShowDeleteOption && } - {shouldShowDeleteOption && ( - handleMenuAction(e, onDelete)} - > - - {t(($) => $['operation.delete'], { ns: 'common' })} - - - )} - - ) -} - -type AppCardOperationsMenuContentProps = Omit< - AppCardOperationsMenuProps, - 'shouldShowOpenInExploreOption' -> - -export function AppCardOperationsMenuContent(props: AppCardOperationsMenuContentProps) { - const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({ - appId: props.app.id, - enabled: systemFeatures.webapp_auth.enabled, - }) - const needsPublishBeforeExplore = - requiresPublishedWorkflowInExplore(props.app) && !props.app.workflow?.id - - const shouldShowOpenInExploreOption = - !props.app.has_draft_trigger && - (needsPublishBeforeExplore || - !systemFeatures.webapp_auth.enabled || - (!isGettingUserCanAccessApp && Boolean(userCanAccessApp?.result))) - - return ( - - ) -} diff --git a/web/app/components/apps/app-list-catalog.tsx b/web/app/components/apps/app-list-catalog.tsx index 97d7fa36fbc..1330fe8d3b1 100644 --- a/web/app/components/apps/app-list-catalog.tsx +++ b/web/app/components/apps/app-list-catalog.tsx @@ -26,7 +26,7 @@ import Empty from './empty' import FirstEmptyState from './first-empty-state' import { useAppListTour } from './hooks/use-app-list-tour' import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users' -import { StarredAppList } from './starred-app-list' +import { ALL_APPS_HEADING_ID, AppListSectionHeading, StarredAppList } from './starred-app-list' const STARRED_APP_LIMIT = 100 const STEP_BY_STEP_TOUR_APP_ROW_CARD_COUNT = 4 @@ -56,6 +56,7 @@ type AppListCatalogContentProps = Omit & appListPages: AppPagination[] hasNextPage: boolean isFetchNextPageError: boolean + isError: boolean isFetching: boolean isFetchingNextPage: boolean isPlaceholderData: boolean @@ -68,12 +69,10 @@ function CatalogSkeleton() { const { t } = useTranslation() return ( -
    $.loading, { ns: 'common' })} - > - +
    $.loading, { ns: 'common' })}> +
      + +
    ) } @@ -85,6 +84,7 @@ function AppListCatalogContent({ hasActiveFilters, hasNextPage, isFetchNextPageError, + isError, isFetching, isFetchingNextPage, isPlaceholderData, @@ -118,6 +118,9 @@ function AppListCatalogContent({ const hasResolvedFirstPage = appListPages.length > 0 const hasAnyApp = (appListPages[0]?.total ?? 0) > 0 + const emptyMessage = t(($) => $['filterEmpty.noApps'], { ns: 'app' }) + const resultStatusMessage = + !isError && !isPlaceholderData && hasResolvedFirstPage && !hasAnyApp ? emptyMessage : '' const showFirstEmptyState = !isPlaceholderData && !hasAnyApp && canCreateApp && hasResolvedFirstPage && !hasActiveFilters const showNoCreateEmptyState = @@ -134,6 +137,9 @@ function AppListCatalogContent({ return ( <> + + {resultStatusMessage} + {showFirstEmptyState ? ( )} + {starredApps.length > 0 && ( + $['studio.allApps'], { ns: 'app' })} + /> + )}
    {hasAnyApp ? ( - apps.map((app, index) => ( - $['studio.allApps'], { ns: 'app' }) + : undefined + } + aria-labelledby={starredApps.length > 0 ? ALL_APPS_HEADING_ID : undefined} + className={APP_LIST_GRID_CLASS_NAME} + > + {apps.map((app, index) => ( + + ))} + {hasNextPage && } + + ) : ( +
    + - )) - ) : ( - +
    )} {hasNextPage && ( <> - {isFetchNextPageError && (
    $['newApp.dropDSLToCreateApp'], { ns: 'app' })} > @@ -318,6 +344,7 @@ export function AppListCatalog(props: AppListCatalogProps) { hasActiveFilters={hasActiveFilters} hasNextPage={appList.hasNextPage} isFetchNextPageError={appList.isFetchNextPageError} + isError={appList.isError} isFetching={appList.isFetching} isFetchingNextPage={appList.isFetchingNextPage} isPlaceholderData={appList.isPlaceholderData} diff --git a/web/app/components/apps/app-sort-filter.tsx b/web/app/components/apps/app-sort-filter.tsx index 8593a3c36c5..fbb6ac1a614 100644 --- a/web/app/components/apps/app-sort-filter.tsx +++ b/web/app/components/apps/app-sort-filter.tsx @@ -49,7 +49,7 @@ export function AppSortFilter({ value, onChange }: AppSortFilterProps) { className="flex h-8 cursor-pointer items-center rounded-lg border-none bg-components-input-bg-normal py-1 pr-2.5 pl-2 text-left whitespace-nowrap outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover" > - {sortByLabel} + {sortByLabel}{' '} {activeOption.text} diff --git a/web/app/components/apps/creators-filter.tsx b/web/app/components/apps/creators-filter.tsx index 63c6be49b6c..b5de0249425 100644 --- a/web/app/components/apps/creators-filter.tsx +++ b/web/app/components/apps/creators-filter.tsx @@ -1,14 +1,22 @@ 'use client' import { Avatar } from '@langgenius/dify-ui/avatar' -import { Checkbox } from '@langgenius/dify-ui/checkbox' import { cn } from '@langgenius/dify-ui/cn' import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from '@langgenius/dify-ui/dropdown-menu' -import { Input } from '@langgenius/dify-ui/input' + Combobox, + ComboboxInput, + ComboboxInputGroup, + ComboboxItem, + ComboboxItemIndicator, + ComboboxItemText, + ComboboxList, + ComboboxPopup, + ComboboxPortal, + ComboboxPositioner, + ComboboxTrigger, + ComboboxValue, +} from '@langgenius/dify-ui/combobox' +import { IconButton } from '@langgenius/dify-ui/icon-button' import { useSuspenseQuery } from '@tanstack/react-query' import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -38,6 +46,7 @@ const CreatorsFilter = ({ value, onChange }: CreatorsFilterProps) => { }) const { data: membersData } = useMembers() const [keywords, setKeywords] = useState('') + const triggerRef = useRef(null) const searchInputRef = useRef(null) const creatorOptions = useMemo(() => { @@ -58,189 +67,187 @@ const CreatorsFilter = ({ value, onChange }: CreatorsFilterProps) => { })) }, [currentUserId, membersData?.accounts]) - const filteredCreators = useMemo(() => { - const normalizedKeywords = keywords.trim().toLowerCase() - if (!normalizedKeywords) return creatorOptions - - return creatorOptions.filter((creator) => { - const keyword = normalizedKeywords - return creator.name.toLowerCase().includes(keyword) - }) - }, [creatorOptions, keywords]) - + const creatorMap = useMemo( + () => new Map(creatorOptions.map((creator) => [creator.id, creator])), + [creatorOptions], + ) + const selectedCreatorValues = useMemo(() => { + return value.map( + (id) => + creatorMap.get(id) ?? { + id, + name: id, + avatarUrl: null, + isYou: false, + }, + ) + }, [creatorMap, value]) const selectedCreators = useMemo(() => { - const creatorMap = new Map(creatorOptions.map((creator) => [creator.id, creator])) return value .map((id) => creatorMap.get(id)) .filter((creator): creator is CreatorOption => Boolean(creator)) - }, [creatorOptions, value]) + }, [creatorMap, value]) - const toggleCreator = useCallback( - (creatorId: string) => { - if (value.includes(creatorId)) { - onChange(value.filter((id) => id !== creatorId)) - return - } - - onChange([...value, creatorId]) - }, - [onChange, value], + const handleValueChange = useCallback( + (creators: CreatorOption[]) => onChange(creators.map((creator) => creator.id)), + [onChange], ) - const resetCreators = useCallback(() => { + const clearCreatorQuery = useCallback(() => { + setKeywords('') + searchInputRef.current?.focus() + }, []) + + const handleSelectionClear = useCallback(() => { onChange([]) setKeywords('') + triggerRef.current?.focus() }, [onChange]) const selectedCount = value.length const selectedAvatarCreators = selectedCreators.slice(0, 3) - const isSelected = selectedCount > 0 + const creatorFilterLabel = t(($) => $['studio.filters.creators'], { ns: 'app' }) + const resetLabel = t(($) => $['studio.filters.reset'], { ns: 'app' }) + const selectedCountLabel = + selectedCount > 0 + ? t(($) => $['dynamicSelect.selected'], { + ns: 'common', + count: selectedCount, + }) + : '' return ( - - - } - > - {!isSelected && ( - <> - - {t(($) => $['studio.filters.creators'], { ns: 'app' })} - - - - )} - {isSelected && ( - <> - - {t(($) => $['studio.filters.creators'], { ns: 'app' })} - - - {selectedAvatarCreators.map((creator, index) => ( - 0 && '-ml-1')} - /> - ))} - - {`+${selectedCount}`} - $['studio.filters.reset'], { ns: 'app' })} - className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center rounded-xs text-text-quaternary outline-hidden hover:text-text-tertiary focus-visible:ring-2 focus-visible:ring-state-accent-solid" - onClick={(event) => { - event.stopPropagation() - resetCreators() - }} - onKeyDown={(event) => { - if (event.key !== 'Enter' && event.key !== ' ') return - - event.preventDefault() - event.stopPropagation() - resetCreators() - }} - > - - - - )} - - -
    -
    - - $['studio.filters.searchCreators'], { ns: 'app' })} - className={cn( - 'pl-6.5 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none', - keywords && 'pr-6.5', - )} - value={keywords} - onChange={(e) => setKeywords(e.target.value)} - placeholder={t(($) => $['studio.filters.searchCreators'], { ns: 'app' })} - /> - {!!keywords && ( - - )} -
    - {isSelected && ( - + + multiple + autoHighlight + items={creatorOptions} + value={selectedCreatorValues} + inputValue={keywords} + isItemEqualToValue={(creator, selectedCreator) => creator.id === selectedCreator.id} + itemToStringLabel={(creator) => creator.name} + itemToStringValue={(creator) => creator.id} + onInputValueChange={setKeywords} + onValueChange={handleValueChange} + > +
    + -
    - {filteredCreators.map((creator) => { - const checked = value.includes(creator.id) - - return ( - - ) - })} -
    - - + > + > + + + {creatorFilterLabel} + + {selectedCount > 0 ? ( + <> + + {selectedAvatarCreators.map((creator, index) => ( + 0 && '-ml-1')} + /> + ))} + + {`+${selectedCount}`} + + ) : ( + + )} + + {selectedCountLabel} + +
    + {selectedCount > 0 && ( + + + + )} +
    + + + $['studio.filters.creators'], { ns: 'app' })} + className="w-[min(280px,var(--available-width))] min-w-[min(var(--anchor-width),var(--available-width))] bg-components-panel-bg-blur text-sm text-text-secondary backdrop-blur-[5px]" + > +
    + + $['studio.filters.searchCreators'], { ns: 'app' })} + className="block h-4.5 grow px-1 py-0 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none" + placeholder={t(($) => $['studio.filters.searchCreators'], { ns: 'app' })} + /> + + {!!keywords && ( + $['operation.clear'], { ns: 'common' })} + className="me-0 shrink-0 text-text-quaternary hover:bg-transparent hover:text-text-tertiary focus-visible:bg-components-input-bg-hover focus-visible:ring-inset" + onClick={clearCreatorQuery} + > + + + )} + +
    + className="max-h-60 px-1 pt-0 pb-1"> + {(creator) => ( + + + + + + + + + + {creator.name} + {creator.isYou && ( + + {t(($) => $['studio.filters.you'], { ns: 'app' })} + + )} + + + + )} + +
    +
    +
    + ) } diff --git a/web/app/components/apps/import-from-marketplace-template-modal.tsx b/web/app/components/apps/import-from-marketplace-template-modal.tsx index 2891d12e620..c28a24b8230 100644 --- a/web/app/components/apps/import-from-marketplace-template-modal.tsx +++ b/web/app/components/apps/import-from-marketplace-template-modal.tsx @@ -1,7 +1,7 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' -import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine } from '@remixicon/react' import { useCallback, useMemo, useRef, useState } from 'react' @@ -73,22 +73,34 @@ const ImportFromMarketplaceTemplateModal = ({ }} > -
    - {t(($) => $['marketplace.template.modalTitle'], { ns: 'app' })} -
    - -
    +
    + + {t(($) => $['marketplace.template.modalTitle'], { ns: 'app' })} + + $['operation.close'], { ns: 'common' })} + className="flex size-8 cursor-pointer items-center border-none bg-transparent p-0 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid" + > + + + } + />
    -
    +
    {isLoading && (
    -
    Loading...
    +
    + {t(($) => $.loading, { ns: 'common' })} +
    )} {isError && ( -
    +
    {t(($) => $['marketplace.template.fetchFailed'], { ns: 'app' })}
    @@ -103,6 +115,7 @@ const ImportFromMarketplaceTemplateModal = ({ iconType={template.icon_file_key ? 'image' : 'emoji'} icon={template.icon || 'page_facing_up'} background={template.icon_file_key ? undefined : template.icon_background} + decorative imageUrl={ template.icon_file_key ? `${MARKETPLACE_API_PREFIX}/templates/${template.id}/icon` diff --git a/web/app/components/apps/starred-app-card.tsx b/web/app/components/apps/starred-app-card.tsx index 5d3e7da6ae6..253ee98af87 100644 --- a/web/app/components/apps/starred-app-card.tsx +++ b/web/app/components/apps/starred-app-card.tsx @@ -17,7 +17,7 @@ import Link from '@/next/link' import { getRedirectionPath } from '@/utils/app-redirection' import { hasOnlyAppPreviewPermission } from '@/utils/permission' import { formatTime } from '@/utils/time' -import { AppCardActionBar } from './app-card/action-bar' +import { AppCardInteractions } from './app-card/interactions' type StarredAppCardProps = { app: AppPartial @@ -55,10 +55,8 @@ export const StarredAppCard = memo( isRbacEnabled, }) const cardClassName = cn( - 'flex h-18 min-w-0 items-center gap-3 overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg px-4 py-3 shadow-xs outline-hidden transition-shadow duration-200', - isPreviewOnly - ? 'cursor-not-allowed opacity-60 focus-visible:ring-2 focus-visible:ring-state-accent-solid' - : 'hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid', + 'flex h-18 min-w-0 items-center gap-3 rounded-xl px-4 py-3 outline-hidden', + isPreviewOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer', ) const showPreviewOnlyAccessWarning = useCallback(() => { toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' })) @@ -73,6 +71,7 @@ export const StarredAppCard = memo( icon={app.icon ?? undefined} background={app.icon_background} imageUrl={app.icon_url} + decorative /> +
  • a:focus-visible]:after:inset-ring-2 has-[>a:focus-visible]:after:inset-ring-state-accent-solid has-[>button:focus-visible]:after:inset-ring-2 has-[>button:focus-visible]:after:inset-ring-state-accent-solid motion-reduce:transition-none", + !isPreviewOnly && + 'hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5 has-data-popup-open:bg-components-card-bg-alt has-data-popup-open:shadow-md has-data-popup-open:shadow-shadow-shadow-5 [@media(hover:none)]:bg-components-card-bg-alt', + )} + > {isPreviewOnly ? (
  • + ) }, ) diff --git a/web/app/components/apps/starred-app-list.tsx b/web/app/components/apps/starred-app-list.tsx index ec3b9f5f74b..9d1a1e41695 100644 --- a/web/app/components/apps/starred-app-list.tsx +++ b/web/app/components/apps/starred-app-list.tsx @@ -12,11 +12,16 @@ type StarredAppListProps = { stepByStepTourHighlightedCardCount?: number } -function SectionDivider({ label }: { label: string }) { +const STARRED_APPS_HEADING_ID = 'starred-apps-heading' +export const ALL_APPS_HEADING_ID = 'all-apps-heading' + +export function AppListSectionHeading({ id, label }: { id: string; label: string }) { return (
    -
    {label}
    +

    + {label} +

    ) @@ -34,8 +39,17 @@ export function StarredAppList({ return ( <> - $['studio.starred'], { ns: 'app' })} /> -
    + $['studio.starred'], { ns: 'app' })} + /> +
      {apps.map((app, index) => ( ))} -
    - $['studio.allApps'], { ns: 'app' })} /> + ) } diff --git a/web/app/components/base/amplitude/__tests__/registration-tracking.spec.ts b/web/app/components/base/amplitude/__tests__/registration-tracking.spec.ts index 30c6707a702..e5979a79623 100644 --- a/web/app/components/base/amplitude/__tests__/registration-tracking.spec.ts +++ b/web/app/components/base/amplitude/__tests__/registration-tracking.spec.ts @@ -1,55 +1,182 @@ import { - flushRegistrationSuccess, + discardRegistrationSessionState, REGISTRATION_SUCCESS_STORAGE_KEY, +} from '../registration-session-state' +import { + coordinateRegistrationConsent, + flushRegistrationSuccess, rememberRegistrationSuccess, + subscribeRegistrationSuccess, } from '../registration-tracking' const mockTrackEvent = vi.hoisted(() => vi.fn()) +const mockAmplitudeInitialized = vi.hoisted(() => ({ value: true })) const mockConsent = vi.hoisted(() => ({ - value: 'granted' as 'unknown' | 'denied' | 'granted', + value: 'granted' as 'unknown' | 'denied' | 'granted' | 'disabled', })) vi.mock('../utils', () => ({ trackEvent: (...args: unknown[]) => mockTrackEvent(...args), })) +vi.mock('../init', () => ({ + getIsAmplitudeInitialized: () => mockAmplitudeInitialized.value, +})) + vi.mock('@/app/components/base/analytics-consent/consent-store', () => ({ getAnalyticsConsent: () => mockConsent.value, })) +const successResult = () => ({ + promise: Promise.resolve({ code: 200 }), +}) + +const getStoredMarker = () => + JSON.parse(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)!) + describe('registration tracking', () => { beforeEach(() => { vi.clearAllMocks() vi.unstubAllGlobals() + vi.useRealTimers() window.sessionStorage.clear() mockConsent.value = 'granted' + mockAmplitudeInitialized.value = true + mockTrackEvent.mockImplementation(successResult) + coordinateRegistrationConsent('denied') + mockConsent.value = 'granted' + vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue( + '11111111-1111-4111-8111-111111111111', + ) }) - // Captures the registration event for a later flush instead of firing it right away. - describe('rememberRegistrationSuccess', () => { - it('should store the base event and not track immediately when there is no utm info', () => { - rememberRegistrationSuccess({ method: 'email' }) + afterEach(() => { + discardRegistrationSessionState() + vi.useRealTimers() + }) - expect(JSON.parse(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)!)).toEqual({ - eventName: 'user_registration_success', - properties: { method: 'email' }, + describe('rememberRegistrationSuccess', () => { + it('stores a versioned marker with stable delivery metadata and allowlisted attribution', () => { + vi.setSystemTime(new Date('2026-08-26T09:00:00.000Z')) + + const persisted = rememberRegistrationSuccess({ + method: 'email', + utmInfo: { + utm_source: 'linkedin', + utm_medium: 'social', + utm_campaign: 'launch', + utm_content: 'hero', + utm_term: 'agents', + slug: 'agent-launch', + unexpected: 'discard-me', + nested: { unsafe: true }, + }, + }) + + const occurredAt = Date.now() + expect(getStoredMarker()).toEqual({ + version: 2, + registrationId: '11111111-1111-4111-8111-111111111111', + occurredAt, + expiresAt: occurredAt + 24 * 60 * 60 * 1000, + eventName: 'user_registration_success_with_utm', + method: 'email', + attribution: { + utm_source: 'linkedin', + utm_medium: 'social', + utm_campaign: 'launch', + utm_content: 'hero', + utm_term: 'agents', + slug: 'agent-launch', + }, }) expect(mockTrackEvent).not.toHaveBeenCalled() + expect(persisted).toBe(true) }) - it('should store the utm event and merge utm info into properties when utm info is present', () => { - rememberRegistrationSuccess({ - method: 'oauth', - utmInfo: { utm_source: 'linkedin', slug: 'agent-launch' }, + it('persists the latest email marker while consent is unknown so a later grant can flush it', async () => { + mockConsent.value = 'unknown' + rememberRegistrationSuccess({ method: 'email', utmInfo: { utm_source: 'first' } }) + rememberRegistrationSuccess({ method: 'email', utmInfo: { utm_source: 'latest' } }) + + expect(getStoredMarker()).toMatchObject({ + version: 2, + method: 'email', + attribution: { utm_source: 'latest' }, }) - expect(JSON.parse(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)!)).toEqual({ - eventName: 'user_registration_success_with_utm', - properties: { method: 'oauth', utm_source: 'linkedin', slug: 'agent-launch' }, - }) + await flushRegistrationSuccess() + expect(mockTrackEvent).not.toHaveBeenCalled() + + mockConsent.value = 'granted' + await flushRegistrationSuccess() + + expect(mockTrackEvent).toHaveBeenCalledTimes(1) + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() }) - it('should swallow errors when writing to sessionStorage fails', () => { + it('discards an unknown-consent marker on denial before a later grant can flush it', async () => { + mockConsent.value = 'unknown' + rememberRegistrationSuccess({ method: 'email' }) + + coordinateRegistrationConsent('denied') + mockConsent.value = 'granted' + await flushRegistrationSuccess() + + expect(mockTrackEvent).not.toHaveBeenCalled() + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + }) + + it('discards a pending marker and GA guard at an account boundary', async () => { + mockConsent.value = 'unknown' + rememberRegistrationSuccess({ method: 'email' }) + window.sessionStorage.setItem('oauth_registration_ga_sent', 'true') + + discardRegistrationSessionState() + mockConsent.value = 'granted' + await flushRegistrationSuccess() + + expect(mockTrackEvent).not.toHaveBeenCalled() + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + expect(window.sessionStorage.getItem('oauth_registration_ga_sent')).toBeNull() + }) + + it.each(['denied', 'disabled'] as const)( + 'discards a stored marker when consent changes to %s', + (consent) => { + rememberRegistrationSuccess({ method: 'email' }) + + coordinateRegistrationConsent(consent) + + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + }, + ) + + it('persists an oauth marker while consent is unknown so a reload can still flush it', async () => { + mockConsent.value = 'unknown' + rememberRegistrationSuccess({ method: 'oauth' }) + + expect(getStoredMarker()).toMatchObject({ method: 'oauth' }) + + mockConsent.value = 'granted' + await flushRegistrationSuccess() + + expect(mockTrackEvent).toHaveBeenCalledTimes(1) + }) + + it('notifies consumers only after a marker is stored', () => { + const listener = vi.fn() + const unsubscribe = subscribeRegistrationSuccess(listener) + + rememberRegistrationSuccess({ method: 'email' }) + + expect(listener).toHaveBeenCalledTimes(1) + unsubscribe() + }) + + it('swallows sessionStorage write errors without notifying consumers', () => { + const listener = vi.fn() + const unsubscribe = subscribeRegistrationSuccess(listener) vi.stubGlobal('window', { sessionStorage: { getItem: vi.fn(() => null), @@ -60,158 +187,365 @@ describe('registration tracking', () => { }, }) - try { - expect(() => rememberRegistrationSuccess({ method: 'email' })).not.toThrow() - } finally { - vi.unstubAllGlobals() - } + expect(rememberRegistrationSuccess({ method: 'email' })).toBe(false) + expect(listener).not.toHaveBeenCalled() + unsubscribe() + }) + }) + + describe('flushRegistrationSuccess', () => { + it('waits for a successful SDK result before acknowledging the marker', async () => { + let resolveTrack!: (result: { code: number }) => void + mockTrackEvent.mockReturnValue({ + promise: new Promise((resolve) => { + resolveTrack = resolve + }), + }) + vi.setSystemTime(new Date('2026-08-26T09:00:00.000Z')) + rememberRegistrationSuccess({ method: 'oauth', utmInfo: { utm_source: 'blog' } }) + + const flushPromise = flushRegistrationSuccess() + + expect(getStoredMarker()).toBeTruthy() + expect(mockTrackEvent).toHaveBeenCalledWith( + 'user_registration_success_with_utm', + { + method: 'oauth', + utm_source: 'blog', + registration_id: '11111111-1111-4111-8111-111111111111', + event_version: 2, + tracking_contract_version: 'consent_wait_v2', + }, + { + insert_id: '11111111-1111-4111-8111-111111111111', + time: Date.now(), + }, + ) + + resolveTrack({ code: 200 }) + await flushPromise + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() }) - it.each(['unknown', 'denied'] as const)( - 'should not cache an event while consent is %s', - (consent) => { - mockConsent.value = consent + it.each([ + ['unknown consent', () => (mockConsent.value = 'unknown')], + ['uninitialized Amplitude', () => (mockAmplitudeInitialized.value = false)], + ])('defers without deleting for %s', async (_label, makeIneligible) => { + rememberRegistrationSuccess({ method: 'email' }) + makeIneligible() + + await flushRegistrationSuccess() + + expect(mockTrackEvent).not.toHaveBeenCalled() + expect(getStoredMarker()).toBeTruthy() + }) + + it('discards a pending marker when consent is denied', async () => { + rememberRegistrationSuccess({ method: 'oauth' }) + mockConsent.value = 'denied' + + await flushRegistrationSuccess() + + expect(mockTrackEvent).not.toHaveBeenCalled() + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + }) + + it('retains the marker on SDK rejection or a non-success result and reuses its id and time', async () => { + vi.setSystemTime(new Date('2026-08-26T09:00:00.000Z')) + rememberRegistrationSuccess({ method: 'email' }) + const marker = getStoredMarker() + mockTrackEvent + .mockReturnValueOnce({ promise: Promise.reject(new Error('network failed')) }) + .mockReturnValueOnce({ promise: Promise.resolve({ code: 500 }) }) + .mockImplementation(successResult) + + await flushRegistrationSuccess() + await flushRegistrationSuccess() + + expect(getStoredMarker()).toEqual(marker) + expect(mockTrackEvent.mock.calls[0]?.[2]).toEqual({ + insert_id: marker.registrationId, + time: marker.occurredAt, + }) + expect(mockTrackEvent.mock.calls[1]?.[2]).toEqual({ + insert_id: marker.registrationId, + time: marker.occurredAt, + }) + }) + + it.each(['rejected acknowledgement', 'non-success acknowledgement'] as const)( + 'continues with a replacement marker after a %s', + async (oldAcknowledgement) => { + const firstRegistrationId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const replacementRegistrationId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + vi.spyOn(globalThis.crypto, 'randomUUID') + .mockReturnValueOnce(firstRegistrationId) + .mockReturnValueOnce(replacementRegistrationId) + let resolveOldAcknowledgement!: (result: { code: number }) => void + let rejectOldAcknowledgement!: (error: Error) => void + const pendingOldAcknowledgement = new Promise<{ code: number }>((resolve, reject) => { + resolveOldAcknowledgement = resolve + rejectOldAcknowledgement = reject + }) + mockTrackEvent + .mockReturnValueOnce({ promise: pendingOldAcknowledgement }) + .mockImplementation(successResult) rememberRegistrationSuccess({ method: 'email' }) + const firstFlush = flushRegistrationSuccess() + rememberRegistrationSuccess({ method: 'email' }) + const replacementFlush = flushRegistrationSuccess() + expect(replacementFlush).toBe(firstFlush) + expect(mockTrackEvent).toHaveBeenCalledTimes(1) + + if (oldAcknowledgement === 'rejected acknowledgement') + rejectOldAcknowledgement(new Error('network failed')) + else resolveOldAcknowledgement({ code: 500 }) + await firstFlush + + expect(mockTrackEvent).toHaveBeenCalledTimes(2) + expect(mockTrackEvent.mock.calls[1]?.[2]).toEqual({ + insert_id: replacementRegistrationId, + time: expect.any(Number), + }) expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() }, ) - }) - // Replays the remembered event exactly once, after the user ID has been attached. - describe('flushRegistrationSuccess', () => { - it('should track the remembered event and clear it from storage', () => { - rememberRegistrationSuccess({ method: 'email', utmInfo: { utm_source: 'blog' } }) - - flushRegistrationSuccess() + it('retries an acknowledgement-failed marker after the backoff delay', async () => { + vi.useFakeTimers() + rememberRegistrationSuccess({ method: 'email' }) + const marker = getStoredMarker() + mockTrackEvent + .mockReturnValueOnce({ promise: Promise.reject(new Error('network failed')) }) + .mockImplementation(successResult) + await flushRegistrationSuccess() + expect(getStoredMarker()).toEqual(marker) expect(mockTrackEvent).toHaveBeenCalledTimes(1) - expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', { - method: 'email', - utm_source: 'blog', + + await vi.advanceTimersByTimeAsync(1000) + + expect(mockTrackEvent).toHaveBeenCalledTimes(2) + expect(mockTrackEvent.mock.calls[1]?.[2]).toEqual({ + insert_id: marker.registrationId, + time: marker.occurredAt, }) expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() }) - it('should do nothing when there is no pending event', () => { - flushRegistrationSuccess() + it('does not retry an acknowledgement-failed marker after an account boundary', async () => { + rememberRegistrationSuccess({ method: 'email' }) + mockTrackEvent.mockReturnValueOnce({ promise: Promise.reject(new Error('network failed')) }) + await flushRegistrationSuccess() + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).not.toBeNull() + + discardRegistrationSessionState() + await flushRegistrationSuccess() + + expect(mockTrackEvent).toHaveBeenCalledTimes(1) + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + }) + + it.each([ + ['session discard', 'resolves'] as const, + ['session discard', 'rejects'] as const, + ['denied consent', 'resolves'] as const, + ['disabled analytics', 'resolves'] as const, + ])( + 'isolates a new registration flush after %s while the old SDK acknowledgement %s', + async (invalidation, oldAcknowledgement) => { + const accountARegistrationId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const accountBRegistrationId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + vi.spyOn(globalThis.crypto, 'randomUUID') + .mockReturnValueOnce(accountARegistrationId) + .mockReturnValueOnce(accountBRegistrationId) + + let resolveAccountA!: (result: { code: number }) => void + let rejectAccountA!: (error: Error) => void + let resolveAccountB!: (result: { code: number }) => void + const accountAAcknowledgement = new Promise<{ code: number }>((resolve, reject) => { + resolveAccountA = resolve + rejectAccountA = reject + }) + const accountBAcknowledgement = new Promise<{ code: number }>((resolve) => { + resolveAccountB = resolve + }) + mockTrackEvent + .mockReturnValueOnce({ promise: accountAAcknowledgement }) + .mockReturnValueOnce({ promise: accountBAcknowledgement }) + + rememberRegistrationSuccess({ method: 'email' }) + const accountAFlush = flushRegistrationSuccess() + + if (invalidation === 'session discard') { + discardRegistrationSessionState() + } else { + const terminalConsent = invalidation === 'denied consent' ? 'denied' : 'disabled' + mockConsent.value = terminalConsent + coordinateRegistrationConsent(terminalConsent) + mockConsent.value = 'granted' + } + + rememberRegistrationSuccess({ method: 'email' }) + const accountBMarker = window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY) + const accountBFlush = flushRegistrationSuccess() + + const settleAccountA = () => { + if (oldAcknowledgement === 'resolves') resolveAccountA({ code: 200 }) + else rejectAccountA(new Error('account A request failed')) + } + + try { + expect(accountBMarker).not.toBeNull() + expect(accountBFlush).not.toBe(accountAFlush) + expect(mockTrackEvent).toHaveBeenCalledTimes(2) + expect(mockTrackEvent.mock.calls.map((call) => call[2])).toEqual([ + { insert_id: accountARegistrationId, time: expect.any(Number) }, + { insert_id: accountBRegistrationId, time: expect.any(Number) }, + ]) + + settleAccountA() + await accountAFlush + + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBe( + accountBMarker, + ) + expect(flushRegistrationSuccess()).toBe(accountBFlush) + expect(mockTrackEvent).toHaveBeenCalledTimes(2) + + resolveAccountB({ code: 200 }) + await accountBFlush + + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + } finally { + settleAccountA() + resolveAccountB({ code: 200 }) + await Promise.allSettled([accountAFlush, accountBFlush]) + } + }, + ) + + it('coalesces concurrent flushes into one SDK send', async () => { + let resolveTrack!: (result: { code: number }) => void + mockTrackEvent.mockReturnValue({ + promise: new Promise((resolve) => { + resolveTrack = resolve + }), + }) + rememberRegistrationSuccess({ method: 'email' }) + + const first = flushRegistrationSuccess() + const second = flushRegistrationSuccess() + + expect(mockTrackEvent).toHaveBeenCalledTimes(1) + resolveTrack({ code: 200 }) + await Promise.all([first, second]) + }) + + it('discards expired and malformed markers without tracking', async () => { + vi.setSystemTime(new Date('2026-08-26T09:00:00.000Z')) + rememberRegistrationSuccess({ method: 'email' }) + vi.setSystemTime(new Date('2026-08-27T09:00:00.000Z')) + + await flushRegistrationSuccess() + expect(mockTrackEvent).not.toHaveBeenCalled() + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + + const malformedMarkers = [ + '{not-json', + JSON.stringify({ version: 1, eventName: 'user_registration_success' }), + JSON.stringify({ + version: 2, + registrationId: 'id', + occurredAt: Date.now(), + expiresAt: Date.now() + 1000, + eventName: 'arbitrary_event', + method: 'email', + attribution: {}, + }), + ] + + for (const raw of malformedMarkers) { + window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, raw) + await flushRegistrationSuccess() + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + } expect(mockTrackEvent).not.toHaveBeenCalled() }) - it('should fire the event at most once across repeated flushes', () => { - rememberRegistrationSuccess({ method: 'oauth' }) + it('accepts a persisted timestamp just inside the five-minute clock-skew allowance', async () => { + vi.setSystemTime(new Date('2026-08-26T09:04:59.999Z')) + rememberRegistrationSuccess({ method: 'email' }) + vi.setSystemTime(new Date('2026-08-26T09:00:00.000Z')) - flushRegistrationSuccess() - flushRegistrationSuccess() + await flushRegistrationSuccess() expect(mockTrackEvent).toHaveBeenCalledTimes(1) }) - it('should discard a pending event when consent was revoked before flush', () => { - rememberRegistrationSuccess({ method: 'oauth' }) - mockConsent.value = 'denied' + it('rejects a persisted timestamp just outside the five-minute clock-skew allowance', async () => { + vi.setSystemTime(new Date('2026-08-26T09:05:00.001Z')) + rememberRegistrationSuccess({ method: 'email' }) + vi.setSystemTime(new Date('2026-08-26T09:00:00.000Z')) - flushRegistrationSuccess() + await flushRegistrationSuccess() expect(mockTrackEvent).not.toHaveBeenCalled() expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() }) - it('should clear malformed pending data without tracking', () => { - window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, '{not-json') - - flushRegistrationSuccess() - - expect(mockTrackEvent).not.toHaveBeenCalled() - expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() - }) - - it('should clear the pending entry without tracking when it has no event name', () => { - window.sessionStorage.setItem( - REGISTRATION_SUCCESS_STORAGE_KEY, - JSON.stringify({ properties: { method: 'email' } }), - ) - - flushRegistrationSuccess() - - expect(mockTrackEvent).not.toHaveBeenCalled() - expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() - }) - - it('should stop without tracking when reading from sessionStorage throws', () => { + it('handles storage read errors without throwing', async () => { vi.stubGlobal('window', { sessionStorage: { getItem: () => { throw new Error('read failed') }, setItem: vi.fn(), - removeItem: vi.fn(), - }, - }) - - try { - expect(() => flushRegistrationSuccess()).not.toThrow() - expect(mockTrackEvent).not.toHaveBeenCalled() - } finally { - vi.unstubAllGlobals() - } - }) - - it('should still track when clearing the pending entry fails', () => { - const pending = { eventName: 'user_registration_success', properties: { method: 'email' } } - vi.stubGlobal('window', { - sessionStorage: { - getItem: () => JSON.stringify(pending), - setItem: vi.fn(), removeItem: () => { throw new Error('remove failed') }, }, }) - try { - flushRegistrationSuccess() - - expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', { - method: 'email', - }) - } finally { - vi.unstubAllGlobals() - } - }) - }) - - // Both producers and the consumer must degrade gracefully when sessionStorage is - // missing (SSR) or blocked (privacy mode / disabled storage). - describe('when sessionStorage is unavailable', () => { - it('should no-op without throwing when window is undefined', () => { - vi.stubGlobal('window', undefined) - - try { - expect(() => rememberRegistrationSuccess({ method: 'email' })).not.toThrow() - expect(() => flushRegistrationSuccess()).not.toThrow() - expect(mockTrackEvent).not.toHaveBeenCalled() - } finally { - vi.unstubAllGlobals() - } + await expect(flushRegistrationSuccess()).resolves.toBeUndefined() + expect(mockTrackEvent).not.toHaveBeenCalled() }) - it('should no-op without throwing when accessing sessionStorage throws', () => { + it('retains the same marker when acknowledgement removal fails', async () => { + rememberRegistrationSuccess({ method: 'email' }) + const raw = window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY) + const removeItem = vi.fn(() => { + throw new Error('remove failed') + }) + vi.stubGlobal('window', { + sessionStorage: { + getItem: () => raw, + setItem: vi.fn(), + removeItem, + }, + }) + + await expect(flushRegistrationSuccess()).resolves.toBeUndefined() + await expect(flushRegistrationSuccess()).resolves.toBeUndefined() + + expect(mockTrackEvent).toHaveBeenCalledTimes(2) + expect(mockTrackEvent.mock.calls[0]?.[2]).toEqual(mockTrackEvent.mock.calls[1]?.[2]) + expect(removeItem).toHaveBeenCalledTimes(2) + }) + + it('no-ops when sessionStorage access is blocked', async () => { vi.stubGlobal('window', { get sessionStorage() { throw new Error('storage disabled') }, }) - try { - expect(() => rememberRegistrationSuccess({ method: 'oauth' })).not.toThrow() - expect(() => flushRegistrationSuccess()).not.toThrow() - expect(mockTrackEvent).not.toHaveBeenCalled() - } finally { - vi.unstubAllGlobals() - } + expect(() => rememberRegistrationSuccess({ method: 'email' })).not.toThrow() + await expect(flushRegistrationSuccess()).resolves.toBeUndefined() + expect(mockTrackEvent).not.toHaveBeenCalled() }) }) }) diff --git a/web/app/components/base/amplitude/registration-consent-coordinator.tsx b/web/app/components/base/amplitude/registration-consent-coordinator.tsx new file mode 100644 index 00000000000..8d19d05cdc2 --- /dev/null +++ b/web/app/components/base/amplitude/registration-consent-coordinator.tsx @@ -0,0 +1,15 @@ +'use client' + +import { useEffect } from 'react' +import { useAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store' +import { coordinateRegistrationConsent } from './registration-tracking' + +export function RegistrationConsentCoordinator() { + const consent = useAnalyticsConsent() + + useEffect(() => { + coordinateRegistrationConsent(consent) + }, [consent]) + + return null +} diff --git a/web/app/components/base/amplitude/registration-session-state.ts b/web/app/components/base/amplitude/registration-session-state.ts new file mode 100644 index 00000000000..5eae9390cb1 --- /dev/null +++ b/web/app/components/base/amplitude/registration-session-state.ts @@ -0,0 +1,99 @@ +export const REGISTRATION_SUCCESS_STORAGE_KEY = 'pending_registration_success_event' +export const OAUTH_REGISTRATION_GA_SENT_KEY = 'oauth_registration_ga_sent' +const FLUSH_RETRY_DELAYS_MS = [1000, 4000, 16000] as const + +export const REGISTRATION_METHODS = ['email', 'oauth'] as const + +export const ATTRIBUTION_KEYS = [ + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_content', + 'utm_term', + 'slug', +] as const + +export type RegistrationMethod = (typeof REGISTRATION_METHODS)[number] +export type RegistrationAttribution = Partial> + +export type RegistrationIntent = { + registrationId: string + occurredAt: number + method: RegistrationMethod + attribution: RegistrationAttribution +} + +let registrationDeliveryGeneration = 0 +let flushRetryTimer: ReturnType | null = null +let flushRetryAttempt = 0 + +export const getRegistrationSessionStorage = (): Storage | null => { + try { + if (typeof window === 'undefined') return null + return window.sessionStorage + } catch { + return null + } +} + +export const removeStoredRegistrationMarker = (storage = getRegistrationSessionStorage()) => { + try { + storage?.removeItem(REGISTRATION_SUCCESS_STORAGE_KEY) + } catch {} +} + +export const hasSentOAuthRegistrationGA = () => { + try { + return getRegistrationSessionStorage()?.getItem(OAUTH_REGISTRATION_GA_SENT_KEY) === 'true' + } catch { + return false + } +} + +export const markOAuthRegistrationGASent = () => { + try { + getRegistrationSessionStorage()?.setItem(OAUTH_REGISTRATION_GA_SENT_KEY, 'true') + } catch {} +} + +export const clearOAuthRegistrationGAGuard = () => { + try { + getRegistrationSessionStorage()?.removeItem(OAUTH_REGISTRATION_GA_SENT_KEY) + } catch {} +} + +export const getRegistrationDeliveryGeneration = () => registrationDeliveryGeneration + +export const clearRegistrationFlushRetry = () => { + if (flushRetryTimer !== null) { + clearTimeout(flushRetryTimer) + flushRetryTimer = null + } + flushRetryAttempt = 0 +} + +export const scheduleRegistrationFlushRetry = (runFlush: () => void) => { + if (flushRetryAttempt >= FLUSH_RETRY_DELAYS_MS.length) return + + const delay = FLUSH_RETRY_DELAYS_MS[flushRetryAttempt] + flushRetryAttempt += 1 + const generation = registrationDeliveryGeneration + if (flushRetryTimer !== null) clearTimeout(flushRetryTimer) + + flushRetryTimer = setTimeout(() => { + flushRetryTimer = null + if (generation !== registrationDeliveryGeneration) return + runFlush() + }, delay) +} + +export const invalidateRegistrationDeliveryState = () => { + registrationDeliveryGeneration += 1 + clearRegistrationFlushRetry() + removeStoredRegistrationMarker() +} + +export const discardRegistrationSessionState = () => { + invalidateRegistrationDeliveryState() + clearOAuthRegistrationGAGuard() +} diff --git a/web/app/components/base/amplitude/registration-tracking.ts b/web/app/components/base/amplitude/registration-tracking.ts index 5562d2173c4..15c11bc6590 100644 --- a/web/app/components/base/amplitude/registration-tracking.ts +++ b/web/app/components/base/amplitude/registration-tracking.ts @@ -1,38 +1,121 @@ +import type { + RegistrationAttribution, + RegistrationIntent, + RegistrationMethod, +} from './registration-session-state' +import type { AnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store' import { getAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store' +import { getIsAmplitudeInitialized } from './init' +import { + ATTRIBUTION_KEYS, + clearRegistrationFlushRetry, + getRegistrationDeliveryGeneration, + getRegistrationSessionStorage, + invalidateRegistrationDeliveryState, + REGISTRATION_METHODS, + REGISTRATION_SUCCESS_STORAGE_KEY, + removeStoredRegistrationMarker, + scheduleRegistrationFlushRetry, +} from './registration-session-state' import { trackEvent } from './utils' -/** - * Storage key for a registration success event that is waiting to be sent to - * Amplitude until a user ID has been attached. - */ -export const REGISTRATION_SUCCESS_STORAGE_KEY = 'pending_registration_success_event' +const REGISTRATION_MARKER_VERSION = 2 +const REGISTRATION_MARKER_TTL_MS = 24 * 60 * 60 * 1000 +// Browser clocks may be corrected between registration and delivery. Permit a small +// correction, but reject timestamps far enough ahead to corrupt Amplitude ordering. +const REGISTRATION_FUTURE_CLOCK_SKEW_ALLOWANCE_MS = 5 * 60 * 1000 +const SUCCESSFUL_TRACK_RESULT_MIN = 200 +const SUCCESSFUL_TRACK_RESULT_MAX = 299 -type RegistrationMethod = 'email' | 'oauth' +const REGISTRATION_EVENT_NAMES = [ + 'user_registration_success', + 'user_registration_success_with_utm', +] as const -type PendingRegistrationSuccessEvent = { - eventName: string - properties: Record +type RegistrationEventName = (typeof REGISTRATION_EVENT_NAMES)[number] + +type PendingRegistrationSuccessEvent = RegistrationIntent & { + version: typeof REGISTRATION_MARKER_VERSION + expiresAt: number + eventName: RegistrationEventName } -const getSessionStorage = (): Storage | null => { +let registrationSnapshot = 0 +let activeFlush: { generation: number; promise: Promise } | null = null +const registrationListeners = new Set<() => void>() + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value) + +const isRegistrationMethod = (value: unknown): value is RegistrationMethod => + typeof value === 'string' && REGISTRATION_METHODS.includes(value as RegistrationMethod) + +const isRegistrationEventName = (value: unknown): value is RegistrationEventName => + typeof value === 'string' && REGISTRATION_EVENT_NAMES.includes(value as RegistrationEventName) + +const notifyRegistrationMarkerStored = () => { + registrationSnapshot += 1 + registrationListeners.forEach((listener) => listener()) +} + +const createRegistrationId = () => { try { - if (typeof window === 'undefined') return null - return window.sessionStorage + return globalThis.crypto.randomUUID() } catch { - return null + return `${Date.now()}-${Math.random().toString(36).slice(2)}` + } +} + +export const normalizeRegistrationAttribution = ( + value?: Record | null, +): RegistrationAttribution | null => { + if (!value) return null + + const attribution: RegistrationAttribution = {} + ATTRIBUTION_KEYS.forEach((key) => { + const item = value[key] + if (typeof item !== 'string') return + + const normalized = item.trim() + if (normalized) attribution[key] = normalized + }) + + return Object.keys(attribution).length ? attribution : null +} + +const createRegistrationIntent = ( + method: RegistrationMethod, + utmInfo?: Record | null, +): RegistrationIntent => ({ + registrationId: createRegistrationId(), + occurredAt: Date.now(), + method, + attribution: normalizeRegistrationAttribution(utmInfo) ?? {}, +}) + +const storeRegistrationIntent = (intent: RegistrationIntent) => { + const storage = getRegistrationSessionStorage() + if (!storage) return false + + const pending: PendingRegistrationSuccessEvent = { + ...intent, + version: REGISTRATION_MARKER_VERSION, + expiresAt: intent.occurredAt + REGISTRATION_MARKER_TTL_MS, + eventName: Object.keys(intent.attribution).length + ? 'user_registration_success_with_utm' + : 'user_registration_success', + } + + try { + storage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, JSON.stringify(pending)) + clearRegistrationFlushRetry() + notifyRegistrationMarkerStored() + return true + } catch { + return false } } -/** - * Remember a registration success event after analytics consent so it can be sent - * to Amplitude *after* the user ID is attached (see `flushRegistrationSuccess`). - * - * Amplitude attributes events to whatever identity is active when `track` runs. At - * registration time the client does not yet know the user ID, so firing the event - * immediately records it under an anonymous profile. We persist the event here and - * replay it once `setUserId` runs in the bootstrap effects after the redirect. An - * event produced before analytics consent is granted is dropped instead of queued. - */ export const rememberRegistrationSuccess = ({ method, utmInfo, @@ -40,49 +123,157 @@ export const rememberRegistrationSuccess = ({ method: RegistrationMethod utmInfo?: Record | null }) => { - if (getAnalyticsConsent() !== 'granted') return - - const storage = getSessionStorage() - if (!storage) return - - const pending: PendingRegistrationSuccessEvent = { - eventName: utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', - properties: { method, ...utmInfo }, + const consent = getAnalyticsConsent() + if (consent === 'denied' || consent === 'disabled') { + invalidateRegistrationDeliveryState() + return false } - try { - storage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, JSON.stringify(pending)) - } catch {} + // Persist even while consent is unknown. Flush waits for grant + Amplitude init + // + user identity, so a later full-page redirect still has the marker. + return storeRegistrationIntent(createRegistrationIntent(method, utmInfo)) } -/** - * Send a previously remembered registration success event to Amplitude. - * - * MUST be called after `setUserId` so the event lands on the identified user profile. - * No-op when nothing is pending. The pending entry is removed before tracking so the - * event fires at most once even if this runs multiple times. - */ -export const flushRegistrationSuccess = () => { - const storage = getSessionStorage() +export const coordinateRegistrationConsent = (consent: AnalyticsConsent) => { + if (consent === 'denied' || consent === 'disabled') invalidateRegistrationDeliveryState() +} + +export const subscribeRegistrationSuccess = (listener: () => void) => { + registrationListeners.add(listener) + return () => registrationListeners.delete(listener) +} + +export const getRegistrationSuccessSnapshot = () => registrationSnapshot + +const isRegistrationAttribution = (value: unknown): value is RegistrationAttribution => { + if (!isRecord(value)) return false + + return Object.entries(value).every( + ([key, item]) => + ATTRIBUTION_KEYS.includes(key as (typeof ATTRIBUTION_KEYS)[number]) && + typeof item === 'string' && + Boolean(item.trim()), + ) +} + +const parsePendingRegistration = (raw: string): PendingRegistrationSuccessEvent | null => { + try { + const value: unknown = JSON.parse(raw) + if (!isRecord(value)) return null + if (value.version !== REGISTRATION_MARKER_VERSION) return null + if (typeof value.registrationId !== 'string' || !value.registrationId) return null + if (typeof value.occurredAt !== 'number' || !Number.isFinite(value.occurredAt)) return null + if (typeof value.expiresAt !== 'number' || !Number.isFinite(value.expiresAt)) return null + if (value.expiresAt !== value.occurredAt + REGISTRATION_MARKER_TTL_MS) return null + if (!isRegistrationEventName(value.eventName)) return null + if (!isRegistrationMethod(value.method)) return null + if (!isRegistrationAttribution(value.attribution)) return null + + const hasAttribution = Object.keys(value.attribution).length > 0 + if (hasAttribution !== (value.eventName === 'user_registration_success_with_utm')) return null + + return value as PendingRegistrationSuccessEvent + } catch { + return null + } +} + +const runRegistrationFlush = async (generation: number) => { + const isStale = () => generation !== getRegistrationDeliveryGeneration() + if (isStale()) return + + const consent = getAnalyticsConsent() + if (consent === 'unknown') return + + const storage = getRegistrationSessionStorage() if (!storage) return - let raw: string | null = null - try { - raw = storage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY) - } catch { + if (consent === 'denied' || consent === 'disabled') { + invalidateRegistrationDeliveryState() return } + if (!getIsAmplitudeInitialized()) return - if (!raw) return + while (true) { + if (isStale()) return - try { - storage.removeItem(REGISTRATION_SUCCESS_STORAGE_KEY) - } catch {} + let raw: string | null + try { + raw = storage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY) + } catch { + return + } + if (!raw) return - if (getAnalyticsConsent() !== 'granted') return + const pending = parsePendingRegistration(raw) + const now = Date.now() + if ( + !pending || + pending.expiresAt <= now || + pending.occurredAt > now + REGISTRATION_FUTURE_CLOCK_SKEW_ALLOWANCE_MS + ) { + removeStoredRegistrationMarker(storage) + return + } - try { - const pending = JSON.parse(raw) as PendingRegistrationSuccessEvent - if (pending?.eventName) trackEvent(pending.eventName, pending.properties) - } catch {} + let trackResult: ReturnType + try { + trackResult = trackEvent( + pending.eventName, + { + method: pending.method, + ...pending.attribution, + registration_id: pending.registrationId, + event_version: REGISTRATION_MARKER_VERSION, + tracking_contract_version: 'consent_wait_v2', + }, + { + insert_id: pending.registrationId, + time: pending.occurredAt, + }, + ) + } catch { + return + } + if (!trackResult) return + + let acknowledged = false + try { + const result: { code?: unknown } = await trackResult.promise + acknowledged = + typeof result.code === 'number' && + result.code >= SUCCESSFUL_TRACK_RESULT_MIN && + result.code <= SUCCESSFUL_TRACK_RESULT_MAX + } catch {} + if (isStale()) return + + let currentRaw: string | null + try { + currentRaw = storage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY) + } catch { + return + } + if (currentRaw !== raw) continue + if (!acknowledged) { + scheduleRegistrationFlushRetry(() => { + void flushRegistrationSuccess() + }) + return + } + + clearRegistrationFlushRetry() + removeStoredRegistrationMarker(storage) + return + } +} + +export function flushRegistrationSuccess() { + const generation = getRegistrationDeliveryGeneration() + if (activeFlush?.generation === generation) return activeFlush.promise + + const promise = runRegistrationFlush(generation).finally(() => { + if (activeFlush?.promise === promise) activeFlush = null + }) + activeFlush = { generation, promise } + return promise } diff --git a/web/app/components/base/amplitude/utils.ts b/web/app/components/base/amplitude/utils.ts index 58354463fc1..bb6021d0ad1 100644 --- a/web/app/components/base/amplitude/utils.ts +++ b/web/app/components/base/amplitude/utils.ts @@ -9,8 +9,13 @@ const canUseAmplitude = () => getAnalyticsConsent() === 'granted' && getIsAmplit * @param eventName Event name * @param eventProperties Event properties (optional) */ -export const trackEvent = (eventName: string, eventProperties?: Record) => { +export const trackEvent = ( + eventName: string, + eventProperties?: Record, + eventOptions?: amplitude.Types.EventOptions, +) => { if (!canUseAmplitude()) return + if (eventOptions) return amplitude.track(eventName, eventProperties, eventOptions) return amplitude.track(eventName, eventProperties) } diff --git a/web/app/components/base/analytics-consent/__tests__/analytics-disabled.spec.tsx b/web/app/components/base/analytics-consent/__tests__/analytics-disabled.spec.tsx new file mode 100644 index 00000000000..65443ba0046 --- /dev/null +++ b/web/app/components/base/analytics-consent/__tests__/analytics-disabled.spec.tsx @@ -0,0 +1,26 @@ +import { render, waitFor } from '@testing-library/react' +import { REGISTRATION_SUCCESS_STORAGE_KEY } from '../../amplitude/registration-session-state' +import { + coordinateRegistrationConsent, + rememberRegistrationSuccess, +} from '../../amplitude/registration-tracking' +import { AnalyticsDisabled } from '../analytics-disabled' +import { getAnalyticsConsent, setAnalyticsConsent } from '../consent-store' + +describe('AnalyticsDisabled', () => { + beforeEach(() => { + window.sessionStorage.clear() + coordinateRegistrationConsent('denied') + setAnalyticsConsent('granted') + }) + + it('terminally discards a pending registration marker', async () => { + rememberRegistrationSuccess({ method: 'email' }) + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).not.toBeNull() + + render() + + await waitFor(() => expect(getAnalyticsConsent()).toBe('disabled')) + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + }) +}) diff --git a/web/app/components/base/analytics-consent/__tests__/analytics-runtimes.spec.tsx b/web/app/components/base/analytics-consent/__tests__/analytics-runtimes.spec.tsx index bfad2dc720a..d7dabf25264 100644 --- a/web/app/components/base/analytics-consent/__tests__/analytics-runtimes.spec.tsx +++ b/web/app/components/base/analytics-consent/__tests__/analytics-runtimes.spec.tsx @@ -14,6 +14,10 @@ vi.mock('@/app/components/base/amplitude/WebAppAmplitudeProvider', () => ({ WebAppAmplitudeProvider: () => , })) +vi.mock('@/app/components/base/amplitude/registration-consent-coordinator', () => ({ + RegistrationConsentCoordinator: () => , +})) + vi.mock('@/app/components/external-attribution-recorder', () => ({ default: () => , })) @@ -24,6 +28,7 @@ describe('analytics runtimes', () => { expect(screen.getByTestId('cookieyes-consent-bridge')).toBeInTheDocument() expect(screen.getByTestId('console-amplitude-provider')).toBeInTheDocument() + expect(screen.getByTestId('registration-consent-coordinator')).toBeInTheDocument() expect(screen.getByTestId('external-attribution-recorder')).toBeInTheDocument() expect(screen.queryByTestId('web-app-amplitude-provider')).toBeNull() }) @@ -34,6 +39,7 @@ describe('analytics runtimes', () => { expect(screen.getByTestId('cookieyes-consent-bridge')).toBeInTheDocument() expect(screen.getByTestId('web-app-amplitude-provider')).toBeInTheDocument() expect(screen.queryByTestId('console-amplitude-provider')).toBeNull() + expect(screen.queryByTestId('registration-consent-coordinator')).toBeNull() expect(screen.queryByTestId('external-attribution-recorder')).toBeNull() }) }) diff --git a/web/app/components/base/analytics-consent/__tests__/cloud-analytics.spec.tsx b/web/app/components/base/analytics-consent/__tests__/cloud-analytics.spec.tsx index 2380b7485c0..a4dc0dc299b 100644 --- a/web/app/components/base/analytics-consent/__tests__/cloud-analytics.spec.tsx +++ b/web/app/components/base/analytics-consent/__tests__/cloud-analytics.spec.tsx @@ -1,5 +1,6 @@ import { QueryClient } from '@tanstack/react-query' import { render } from '@testing-library/react' +import { REGISTRATION_SUCCESS_STORAGE_KEY } from '@/app/components/base/amplitude/registration-session-state' let queryClient: QueryClient @@ -55,9 +56,13 @@ vi.mock('../cloud-analytics-layout-boundary', () => ({ ), })) -async function renderCloudAnalytics() { +async function getCloudAnalyticsResult() { const { CloudAnalytics } = await import('../cloud-analytics') - return render(await CloudAnalytics()) + return CloudAnalytics() +} + +async function renderCloudAnalytics() { + return render(await getCloudAnalyticsResult()) } describe('CloudAnalytics', () => { @@ -68,6 +73,7 @@ describe('CloudAnalytics', () => { configState.isProd = true configState.webPrefix = 'https://cloud.dify.ai' queryClient = new QueryClient() + window.sessionStorage.clear() queryClient.setQueryData(systemFeaturesQueryKey, { deployment_edition: 'CLOUD' }) mockHeadersGet.mockImplementation((name: string) => { const values: Record = { @@ -94,9 +100,13 @@ describe('CloudAnalytics', () => { return values[name] ?? null }) - const { queryByTestId } = await renderCloudAnalytics() + const result = await getCloudAnalyticsResult() + const { queryByTestId } = render(result) expect(queryByTestId('cloud-analytics-layout-boundary')).toBeNull() + expect(result).not.toBeNull() + const { getAnalyticsConsent } = await import('../consent-store') + expect(getAnalyticsConsent()).toBe('disabled') }) it.each(['COMMUNITY', 'ENTERPRISE'] as const)( @@ -109,11 +119,16 @@ describe('CloudAnalytics', () => { }, ) - it('does not render when System Features are unavailable', async () => { + it('suspends analytics without deleting pending registration state when System Features are unavailable', async () => { queryClient.removeQueries({ queryKey: systemFeaturesQueryKey }) + window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'pending-marker') - const { queryByTestId } = await renderCloudAnalytics() + const result = await getCloudAnalyticsResult() + const { queryByTestId } = render(result) expect(queryByTestId('cloud-analytics-layout-boundary')).toBeNull() + const { getAnalyticsConsent } = await import('../consent-store') + expect(getAnalyticsConsent()).toBe('unknown') + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBe('pending-marker') }) }) diff --git a/web/app/components/base/analytics-consent/analytics-disabled.tsx b/web/app/components/base/analytics-consent/analytics-disabled.tsx new file mode 100644 index 00000000000..a562e67c51a --- /dev/null +++ b/web/app/components/base/analytics-consent/analytics-disabled.tsx @@ -0,0 +1,14 @@ +'use client' + +import { useEffect } from 'react' +import { coordinateRegistrationConsent } from '@/app/components/base/amplitude/registration-tracking' +import { setAnalyticsConsent } from './consent-store' + +export function AnalyticsDisabled() { + useEffect(() => { + setAnalyticsConsent('disabled') + coordinateRegistrationConsent('disabled') + }, []) + + return null +} diff --git a/web/app/components/base/analytics-consent/cloud-analytics.tsx b/web/app/components/base/analytics-consent/cloud-analytics.tsx index 9b53224b494..cf4bd0055f7 100644 --- a/web/app/components/base/analytics-consent/cloud-analytics.tsx +++ b/web/app/components/base/analytics-consent/cloud-analytics.tsx @@ -1,6 +1,7 @@ import { COOKIEYES_SITE_KEY, IS_PROD, WEB_PREFIX } from '@/config' import { getCachedSystemFeatures } from '@/features/system-features/server' import { headers } from '@/next/headers' +import { AnalyticsDisabled } from './analytics-disabled' import { CloudAnalyticsLayoutBoundary } from './cloud-analytics-layout-boundary' import { isCloudAnalyticsRequest } from './request-boundary' @@ -19,7 +20,7 @@ export async function CloudAnalytics() { webPrefix: WEB_PREFIX, }) - if (!enabled) return null + if (!enabled) return const nonce = requestHeaders.get('x-nonce') ?? undefined diff --git a/web/app/components/base/analytics-consent/consent-store.ts b/web/app/components/base/analytics-consent/consent-store.ts index 030d3f770b7..b1f77c2eac7 100644 --- a/web/app/components/base/analytics-consent/consent-store.ts +++ b/web/app/components/base/analytics-consent/consent-store.ts @@ -2,7 +2,7 @@ import { useSyncExternalStore } from 'react' -export type AnalyticsConsent = 'unknown' | 'denied' | 'granted' +export type AnalyticsConsent = 'unknown' | 'denied' | 'granted' | 'disabled' type CookieYesConsentUpdateDetail = { accepted: string[] diff --git a/web/app/components/base/analytics-consent/console-analytics-runtime.tsx b/web/app/components/base/analytics-consent/console-analytics-runtime.tsx index b0f97e5cd7e..00814fad51c 100644 --- a/web/app/components/base/analytics-consent/console-analytics-runtime.tsx +++ b/web/app/components/base/analytics-consent/console-analytics-runtime.tsx @@ -1,6 +1,7 @@ 'use client' import AmplitudeProvider from '@/app/components/base/amplitude' +import { RegistrationConsentCoordinator } from '@/app/components/base/amplitude/registration-consent-coordinator' import ExternalAttributionRecorder from '@/app/components/external-attribution-recorder' import { CookieYesConsentBridge } from './cookieyes-consent-bridge' @@ -9,6 +10,7 @@ export function ConsoleAnalyticsRuntime() { <> + ) diff --git a/web/app/components/base/app-icon-picker/index.tsx b/web/app/components/base/app-icon-picker/index.tsx index f8cb056ecda..67ff538474d 100644 --- a/web/app/components/base/app-icon-picker/index.tsx +++ b/web/app/components/base/app-icon-picker/index.tsx @@ -179,6 +179,7 @@ function AppIconPickerContent({ return ( = ({ icon, background, imageUrl, + decorative = false, className, innerIcon, coverElement, @@ -110,6 +112,7 @@ const AppIcon: FC = ({ showEditIcon = false, }) => { const isValidImageIcon = iconType === 'image' && imageUrl + const isDecorative = decorative && !onClick const emojiIcon = icon && icon !== '' ? icon : '🤖' const isHydrated = useIsHydrated() const Icon = isHydrated ? : emojiIcon @@ -133,9 +136,10 @@ const AppIcon: FC = ({ onKeyDown={onClick ? handleKeyDown : undefined} role={onClick ? 'button' : undefined} tabIndex={onClick ? 0 : undefined} + aria-hidden={isDecorative || undefined} > {isValidImageIcon ? ( - app icon + {isDecorative ) : ( innerIcon || Icon )} diff --git a/web/app/components/base/features/new-feature-panel/index.tsx b/web/app/components/base/features/new-feature-panel/index.tsx index 67343c7834c..1f4194d3918 100644 --- a/web/app/components/base/features/new-feature-panel/index.tsx +++ b/web/app/components/base/features/new-feature-panel/index.tsx @@ -17,6 +17,7 @@ import SpeechToText from '@/app/components/base/features/new-feature-panel/speec import TextToSpeech from '@/app/components/base/features/new-feature-panel/text-to-speech' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { useModalContext } from '@/context/modal-context' type Props = Readonly<{ show: boolean @@ -56,10 +57,11 @@ const NewFeaturePanel = ({ const { t } = useTranslation() const { data: speech2textDefaultModel } = useDefaultModel(ModelTypeEnum.speech2text) const { data: text2speechDefaultModel } = useDefaultModel(ModelTypeEnum.tts) + const { hasBlockingModalOpen } = useModalContext() return ( { has_more: false, limit: 10, page: 1, + publication_counts: { drafts: 0, published: 1 }, total: 1, }) diff --git a/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx index 7c4b00da20e..764b51cb52c 100644 --- a/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx @@ -19,6 +19,7 @@ const expectedAppACLPermissionKeys = [ 'app.acl.tracing_config', 'app.acl.log_and_annotation', 'app.acl.access_config', + 'app.acl.access_point_manage', ] const getPermissionKeyMatcher = (permissionKey: string) => diff --git a/web/app/components/integrations/tool-provider-card.tsx b/web/app/components/integrations/tool-provider-card.tsx index f6aca486a20..27dc904c968 100644 --- a/web/app/components/integrations/tool-provider-card.tsx +++ b/web/app/components/integrations/tool-provider-card.tsx @@ -131,13 +131,13 @@ function IntegrationsToolProviderCard({
    {!!org && ( <> -
    +
    {org}
    /
    )} -
    +
    {name}
    diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 390df3b4a28..9408839897d 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -1441,6 +1441,57 @@ describe('MainNav', () => { ) }) + it('announces no installed web app results only after the search settles', async () => { + const user = userEvent.setup() + let resolveSearch: (() => void) | undefined + const searchPending = new Promise((resolve) => { + resolveSearch = resolve + }) + mockInstalledApps = [createInstalledApp()] + mockInstalledAppsRequest.mockImplementation(async ({ query }: { query: { name?: string } }) => { + if (!query.name) { + return { + installed_apps: mockInstalledApps, + has_more: false, + next_cursor: null, + } + } + + await searchPending + return { + installed_apps: [], + has_more: false, + next_cursor: null, + } + }) + + renderMainNav() + + const webAppsRegion = await screen.findByRole('region', { + name: 'explore.sidebar.webApps', + }) + await user.click(screen.getByRole('button', { name: 'common.operation.search' })) + const resultStatus = within(webAppsRegion).getByRole('status') + expect(resultStatus).toBeEmptyDOMElement() + + await user.type(screen.getByPlaceholderText('common.mainNav.webApps.searchPlaceholder'), 'z') + + await waitFor(() => { + expect(webAppsRegion).toHaveAttribute('aria-busy', 'true') + }) + expect(resultStatus).toBeEmptyDOMElement() + + act(() => { + resolveSearch?.() + }) + + await waitFor(() => { + expect(webAppsRegion).toHaveAttribute('aria-busy', 'false') + expect(resultStatus).toHaveTextContent('common.mainNav.webApps.noResults') + }) + expect(within(webAppsRegion).getByRole('status')).toBe(resultStatus) + }) + it('hides the installed web apps section while installed apps are loading', () => { mockInstalledAppsPending = true diff --git a/web/app/components/main-nav/components/web-apps-section.tsx b/web/app/components/main-nav/components/web-apps-section.tsx index e86f63bee3f..e12e9e7cce4 100644 --- a/web/app/components/main-nav/components/web-apps-section.tsx +++ b/web/app/components/main-nav/components/web-apps-section.tsx @@ -128,6 +128,12 @@ const WebAppsSectionContent = () => { }) const canLoadMore = !installedAppsQuery.isFetching && !installedAppsQuery.error + const noResultsMessage = t(($) => $['mainNav.webApps.noResults'], { ns: 'common' }) + const showNoResults = + !installedAppsQuery.isError && + !installedAppsQuery.isFetching && + !installedAppsQuery.isPlaceholderData && + installedApps.length === 0 const handleSearchTextChange = (value: string) => { scrollRef.current?.scrollTo({ top: 0 }) @@ -249,13 +255,16 @@ const WebAppsSectionContent = () => { $['sidebar.webApps'], { ns: 'explore' })} style={{ overflowX: 'hidden' }} className="overscroll-contain" role="region" > +
    + {showNoResults ? noResultsMessage : ''} +
    {installedAppsQuery.isError && !installedAppsQuery.isFetchNextPageError && (
    {
    )} - {!installedAppsQuery.isError && installedApps.length === 0 && ( -
    - {t(($) => $['mainNav.webApps.noResults'], { ns: 'common' })} -
    + {showNoResults && ( +
    {noResultsMessage}
    )} {webAppRows.length > 0 && (
    { } export function OAuthRegistrationAnalytics() { + const analyticsConsent = useAnalyticsConsent() const searchParams = useSearchParams() const oauthNewUserParam = searchParams.get(OAUTH_NEW_USER_PARAM) - const handledParamRef = useRef(null) + const gaHandledRef = useRef(false) + const amplitudeHandledRef = useRef(false) + const cleanedRef = useRef(false) + const utmInfoRef = useRef | undefined>( + undefined, + ) useEffect(() => { - if (oauthNewUserParam === null || handledParamRef.current === oauthNewUserParam) return - - handledParamRef.current = oauthNewUserParam - const oauthNewUser = oauthNewUserParam === 'true' - if (!oauthNewUser) { - removeOAuthNewUserParam() + if (oauthNewUserParam === null) { + clearOAuthRegistrationGAGuard() return } - let utmInfo: Record | null = null - const utmInfoStr = Cookies.get('utm_info') - if (utmInfoStr) { - try { - const parsed: unknown = JSON.parse(utmInfoStr) - if (isRecord(parsed)) utmInfo = parsed - } catch (e) { - console.error('Failed to parse utm_info cookie:', e) + const oauthNewUser = oauthNewUserParam === 'true' + if (!oauthNewUser) { + if (!cleanedRef.current) { + cleanedRef.current = true + clearOAuthRegistrationGAGuard() + removeOAuthNewUserParam() } + return } + if (utmInfoRef.current === undefined) { + let parsedUtmInfo: Record | null = null + const utmInfoStr = Cookies.get('utm_info') + if (utmInfoStr) { + try { + const parsed: unknown = JSON.parse(utmInfoStr) + if (isRecord(parsed)) parsedUtmInfo = parsed + } catch (e) { + console.error('Failed to parse utm_info cookie:', e) + } + } + utmInfoRef.current = normalizeRegistrationAttribution(parsedUtmInfo) + } + const utmInfo = utmInfoRef.current + const eventName = utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success' - // Defer the Amplitude event until the user ID is attached. The app context - // external sync replays it after setUserId runs. Firing it here would record it under an - // anonymous Amplitude profile (no user ID set yet). - rememberRegistrationSuccess({ method: 'oauth', utmInfo }) + if (!gaHandledRef.current) { + gaHandledRef.current = true + if (!hasSentOAuthRegistrationGA()) { + sendGAEvent(eventName, { + method: 'oauth', + ...utmInfo, + }) + markOAuthRegistrationGASent() + } + } - sendGAEvent(eventName, { - method: 'oauth', - ...utmInfo, - }) + if ( + (analyticsConsent === 'unknown' || analyticsConsent === 'granted') && + !amplitudeHandledRef.current + ) { + const persisted = rememberRegistrationSuccess({ method: 'oauth', utmInfo }) + if (!persisted) return + amplitudeHandledRef.current = true + } - Cookies.remove('utm_info') - removeOAuthNewUserParam() - }, [oauthNewUserParam]) + if (!cleanedRef.current) { + cleanedRef.current = true + Cookies.remove('utm_info') + removeOAuthNewUserParam() + } + }, [analyticsConsent, oauthNewUserParam]) return null } diff --git a/web/app/components/plugins/card/base/org-info.tsx b/web/app/components/plugins/card/base/org-info.tsx index 2d9ef6ce036..9989cd454cb 100644 --- a/web/app/components/plugins/card/base/org-info.tsx +++ b/web/app/components/plugins/card/base/org-info.tsx @@ -13,7 +13,7 @@ const OrgInfo = ({ className, orgName, packageName, packageNameClassName }: Prop {orgName && ( <> {orgName} @@ -23,7 +23,7 @@ const OrgInfo = ({ className, orgName, packageName, packageNameClassName }: Prop )} = ({ return ( { if (!open) onHide() }} > - - + diff --git a/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx index 95215e021de..b988542d401 100644 --- a/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx @@ -32,13 +32,13 @@ vi.mock('../../hooks', () => ({ }), })) -const mockCurrentPluginID = vi.fn((): string | undefined => undefined) -const mockSetCurrentPluginID = vi.fn() +const mockSelectedItem = vi.fn((): { type: 'plugin'; id: string } | undefined => undefined) +const mockSetSelectedItem = vi.fn() vi.mock('../../plugin-page/context', () => ({ usePluginPageContext: (selector: (v: Record) => unknown) => { const context = { - currentPluginID: mockCurrentPluginID(), - setCurrentPluginID: mockSetCurrentPluginID, + selectedItem: mockSelectedItem(), + setSelectedItem: mockSetSelectedItem, } return selector(context) }, @@ -174,7 +174,7 @@ describe('PluginItem', () => { beforeEach(() => { vi.clearAllMocks() mockTheme.mockReturnValue('light') - mockCurrentPluginID.mockReturnValue(undefined) + mockSelectedItem.mockReturnValue(undefined) mockEnableMarketplace.mockReturnValue(true) mockLangGeniusVersionInfo.mockReturnValue(createLangGeniusVersionInfo('1.0.0')) mockGetValueFromI18nObject.mockImplementation((obj: Record) => obj?.en_US || '') @@ -588,7 +588,7 @@ describe('PluginItem', () => { // ==================== User Interactions Tests ==================== describe('User Interactions', () => { - it('should call setCurrentPluginID when plugin is clicked', () => { + it('should select the plugin when its card is clicked', () => { // Arrange const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' }) @@ -598,12 +598,15 @@ describe('PluginItem', () => { fireEvent.click(pluginContainer) // Assert - expect(mockSetCurrentPluginID).toHaveBeenCalledWith('test-plugin-id') + expect(mockSetSelectedItem).toHaveBeenCalledWith({ + type: 'plugin', + id: 'test-plugin-id', + }) }) it('should highlight selected plugin', () => { // Arrange - mockCurrentPluginID.mockReturnValue('test-plugin-id') + mockSelectedItem.mockReturnValue({ type: 'plugin', id: 'test-plugin-id' }) const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' }) // Act @@ -611,12 +614,14 @@ describe('PluginItem', () => { // Assert const pluginContainer = container.firstChild as HTMLElement - expect(pluginContainer).toHaveClass('border-components-option-card-option-selected-border') + expect(pluginContainer).toHaveClass( + 'after:inset-ring-components-option-card-option-selected-border', + ) }) it('should not highlight unselected plugin', () => { // Arrange - mockCurrentPluginID.mockReturnValue('other-plugin-id') + mockSelectedItem.mockReturnValue({ type: 'plugin', id: 'other-plugin-id' }) const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' }) // Act @@ -625,7 +630,7 @@ describe('PluginItem', () => { // Assert const pluginContainer = container.firstChild as HTMLElement expect(pluginContainer).not.toHaveClass( - 'border-components-option-card-option-selected-border', + 'after:inset-ring-components-option-card-option-selected-border', ) }) @@ -638,8 +643,8 @@ describe('PluginItem', () => { const actionArea = screen.getByTestId('plugin-action').parentElement fireEvent.click(actionArea!) - // Assert - setCurrentPluginID should not be called - expect(mockSetCurrentPluginID).not.toHaveBeenCalled() + // Assert - selecting the plugin should not be triggered + expect(mockSetSelectedItem).not.toHaveBeenCalled() }) it('should only reveal actions on card hover or focus', () => { @@ -651,9 +656,18 @@ describe('PluginItem', () => { // Assert expect(screen.getByTestId('plugin-action').parentElement).toHaveClass( + 'absolute', + 'top-1/2', + 'right-0', + '-translate-y-1/2', + 'pointer-events-none', 'opacity-0', + 'group-hover/plugin-item:pointer-events-auto', 'group-hover/plugin-item:opacity-100', - 'focus-within:opacity-100', + 'group-focus-within/plugin-item:pointer-events-auto', + 'group-focus-within/plugin-item:opacity-100', + '[@media(hover:none)]:pointer-events-auto', + '[@media(hover:none)]:opacity-100', ) }) }) diff --git a/web/app/components/plugins/plugin-item/index.tsx b/web/app/components/plugins/plugin-item/index.tsx index 05d0ed90da5..db723f6cfed 100644 --- a/web/app/components/plugins/plugin-item/index.tsx +++ b/web/app/components/plugins/plugin-item/index.tsx @@ -47,8 +47,10 @@ const PluginItem: FC = ({ }) => { const { t } = useTranslation() const { theme } = useTheme() - const currentPluginID = usePluginPageContext((v) => v.currentPluginID) - const setCurrentPluginID = usePluginPageContext((v) => v.setCurrentPluginID) + const selectedPluginID = usePluginPageContext((v) => + v.selectedItem?.type === 'plugin' ? v.selectedItem.id : undefined, + ) + const setSelectedItem = usePluginPageContext((v) => v.setSelectedItem) const { refreshPluginList } = useRefreshPluginList() const { @@ -118,19 +120,20 @@ const PluginItem: FC = ({ return (
    { - setCurrentPluginID(plugin.plugin_id) + setSelectedItem({ type: 'plugin', id: plugin.plugin_id }) }} >
    @@ -186,10 +189,14 @@ const PluginItem: FC = ({ } />
    -
    - +
    +
    e.stopPropagation()} > = ({
    -
    +
    {/* Organization & Name */}
    { - const currentPluginID = usePluginPageContext((v) => v.currentPluginID) - const setCurrentPluginID = usePluginPageContext((v) => v.setCurrentPluginID) + const selectedItem = usePluginPageContext((v) => v.selectedItem) + const setSelectedItem = usePluginPageContext((v) => v.setSelectedItem) const options = usePluginPageContext((v) => v.options) return (
    - {currentPluginID ?? 'none'} + + {selectedItem ? `${selectedItem.type}:${selectedItem.id}` : 'none'} + {options.length} - + +
    ) } @@ -62,7 +70,9 @@ describe('PluginPageContextProvider', () => { expect(screen.getByRole('status', { name: 'Available tabs' })).toHaveTextContent('1') }) - it('keeps the query-state tab and updates the current plugin id', () => { + it('keeps the query-state tab and replaces the selected item', async () => { + const user = userEvent.setup() + renderWithProviders( @@ -70,9 +80,17 @@ describe('PluginPageContextProvider', () => { { enableMarketplace: true, searchParams: '?tab=discover' }, ) - fireEvent.click(screen.getByText('select plugin')) + await user.click(screen.getByRole('button', { name: 'select builtin tool' })) - expect(screen.getByRole('status', { name: 'Current plugin' })).toHaveTextContent('plugin-1') + expect(screen.getByRole('status', { name: 'Selected item' })).toHaveTextContent( + 'builtinTool:builtin-1', + ) + + await user.click(screen.getByRole('button', { name: 'select plugin' })) + + expect(screen.getByRole('status', { name: 'Selected item' })).toHaveTextContent( + 'plugin:plugin-1', + ) expect(screen.getByRole('status', { name: 'Available tabs' })).toHaveTextContent('2') }) }) diff --git a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx index 9f8a18453a1..c4b14947809 100644 --- a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx @@ -1,6 +1,8 @@ import type { PluginDetail } from '../../types' +import type { PluginPageSelection } from '../context' import type { Collection } from '@/app/components/tools/types' import { act, fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { getStepByStepTourTargetSelector, @@ -15,14 +17,18 @@ const mockState = vi.hoisted(() => ({ tags: [] as string[], searchQuery: '', }, - currentPluginID: undefined as string | undefined, + selectedItem: undefined as PluginPageSelection | undefined, })) +const mockContextSubscribers = vi.hoisted(() => new Set<() => void>()) const mockSystemFeatures = vi.hoisted(() => ({ enableMarketplace: true, })) const mockSetFilters = vi.fn() -const mockSetCurrentPluginID = vi.fn() +const mockSetSelectedItem = vi.fn((item?: PluginPageSelection) => { + mockState.selectedItem = item + mockContextSubscribers.forEach((subscriber) => subscriber()) +}) const mockLoadNextPage = vi.fn() const mockInvalidateInstalledPluginList = vi.fn() const mockRemoveFilteredInstalledPluginPageOnUnmount = vi.fn() @@ -55,22 +61,40 @@ vi.mock('../../hooks', () => ({ }), })) -vi.mock('../context', () => ({ - usePluginPageContext: ( - selector: (value: { - filters: typeof mockState.filters - setFilters: typeof mockSetFilters - currentPluginID: string | undefined - setCurrentPluginID: typeof mockSetCurrentPluginID - }) => unknown, - ) => - selector({ - filters: mockState.filters, - setFilters: mockSetFilters, - currentPluginID: mockState.currentPluginID, - setCurrentPluginID: mockSetCurrentPluginID, - }), -})) +vi.mock('../context', async () => { + const { useSyncExternalStore } = await import('react') + + return { + usePluginPageContext: ( + selector: (value: { + filters: typeof mockState.filters + setFilters: typeof mockSetFilters + selectedItem: PluginPageSelection | undefined + setSelectedItem: typeof mockSetSelectedItem + }) => unknown, + ) => + useSyncExternalStore( + (subscriber) => { + mockContextSubscribers.add(subscriber) + return () => mockContextSubscribers.delete(subscriber) + }, + () => + selector({ + filters: mockState.filters, + setFilters: mockSetFilters, + selectedItem: mockState.selectedItem, + setSelectedItem: mockSetSelectedItem, + }), + () => + selector({ + filters: mockState.filters, + setFilters: mockSetFilters, + selectedItem: mockState.selectedItem, + setSelectedItem: mockSetSelectedItem, + }), + ), + } +}) vi.mock('../filter-management', () => ({ default: ({ @@ -140,13 +164,19 @@ vi.mock('../list', () => ({ }) => (
    {pluginList.map((plugin, index) => ( -
    mockSetSelectedItem({ type: 'plugin', id: plugin.plugin_id })} > {plugin.plugin_id} -
    + ))} {children}
    @@ -250,13 +280,14 @@ vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({ detail?: PluginDetail onHide: () => void onUpdate: () => void - }) => ( -
    - {detail?.plugin_id ?? 'none'} - - -
    - ), + }) => + detail ? ( +
    + {detail.plugin_id} + + +
    + ) : null, })) const createPlugin = ( @@ -324,7 +355,7 @@ describe('PluginsPanel', () => { }, ) mockState.filters = { categories: [], tags: [], searchQuery: '' } - mockState.currentPluginID = undefined + mockState.selectedItem = undefined mockUseInstalledPluginList.mockReturnValue({ data: { plugins: [] }, isLoading: false, @@ -544,6 +575,43 @@ describe('PluginsPanel', () => { expect(screen.queryByTestId('builtin-tool-detail')).not.toBeInTheDocument() }) + it('replaces the builtin tool detail when an installed plugin is selected', async () => { + vi.useRealTimers() + const user = userEvent.setup() + mockPluginListWithLatestVersion.mockReturnValue([ + createPlugin('tool-plugin', 'Tool Plugin', [], PluginCategoryEnum.tool), + ]) + mockUseInstalledPluginList.mockReturnValue({ + data: { + plugins: [], + builtin_tools: [createBuiltinTool('builtin-tool', 'Builtin Tool')], + }, + isLoading: false, + isFetching: false, + isLastPage: true, + loadNextPage: mockLoadNextPage, + }) + + render() + + const builtinToolCard = screen.getByRole('button', { name: 'builtin-tool' }) + const pluginCard = screen.getByRole('button', { name: 'tool-plugin' }) + + await user.click(builtinToolCard) + + expect(builtinToolCard).toHaveAttribute('aria-pressed', 'true') + expect(pluginCard).toHaveAttribute('aria-pressed', 'false') + expect(screen.getByTestId('builtin-tool-detail')).toHaveTextContent('builtin-tool') + expect(screen.queryByTestId('plugin-detail-panel')).not.toBeInTheDocument() + + await user.click(pluginCard) + + expect(pluginCard).toHaveAttribute('aria-pressed', 'true') + expect(builtinToolCard).toHaveAttribute('aria-pressed', 'false') + expect(screen.getByTestId('plugin-detail-panel')).toHaveTextContent('tool-plugin') + expect(screen.queryByTestId('builtin-tool-detail')).not.toBeInTheDocument() + }) + it('filters builtin tools with the tool integrations search query', () => { mockState.filters.searchQuery = 'alpha' mockUseInstalledPluginList.mockReturnValue({ @@ -898,7 +966,7 @@ describe('PluginsPanel', () => { }) it('renders the empty state and keeps the current plugin detail in sync', () => { - mockState.currentPluginID = 'beta-tool' + mockState.selectedItem = { type: 'plugin', id: 'beta-tool' } mockState.filters.searchQuery = 'missing' mockPluginListWithLatestVersion.mockReturnValue([createPlugin('beta-tool', 'Beta Tool')]) @@ -907,10 +975,10 @@ describe('PluginsPanel', () => { expect(screen.getByTestId('empty-state')).toBeInTheDocument() expect(screen.getByTestId('plugin-detail-panel')).toHaveTextContent('beta-tool') - fireEvent.click(screen.getByText('hide detail')) fireEvent.click(screen.getByText('refresh detail')) + fireEvent.click(screen.getByText('hide detail')) - expect(mockSetCurrentPluginID).toHaveBeenCalledWith(undefined) + expect(mockSetSelectedItem).toHaveBeenCalledWith(undefined) expect(mockInvalidateInstalledPluginList).toHaveBeenCalled() }) }) diff --git a/web/app/components/plugins/plugin-page/context-provider.tsx b/web/app/components/plugins/plugin-page/context-provider.tsx index 457ca4386a8..2985e8c68ea 100644 --- a/web/app/components/plugins/plugin-page/context-provider.tsx +++ b/web/app/components/plugins/plugin-page/context-provider.tsx @@ -1,7 +1,7 @@ 'use client' import type { ReactNode } from 'react' -import type { PluginPageTab } from './context' +import type { PluginPageSelection, PluginPageTab } from './context' import type { FilterState } from './filter-management' import { useSuspenseQuery } from '@tanstack/react-query' import { parseAsStringEnum, useQueryState } from 'nuqs' @@ -38,7 +38,7 @@ export const PluginPageContextProvider = ({ searchQuery: '', }, ) - const [currentPluginID, setCurrentPluginID] = useState() + const [selectedItem, setSelectedItem] = useState() const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), @@ -56,8 +56,8 @@ export const PluginPageContextProvider = ({ - currentPluginID: string | undefined - setCurrentPluginID: (pluginID?: string) => void + selectedItem: PluginPageSelection | undefined + setSelectedItem: (item?: PluginPageSelection) => void filters: FilterState setFilters: (filter: FilterState) => void activeTab: PluginPageTab @@ -26,8 +30,8 @@ const emptyContainerRef: RefObject = { current: null } export const PluginPageContext = createContext({ containerRef: emptyContainerRef, - currentPluginID: undefined, - setCurrentPluginID: noop, + selectedItem: undefined, + setSelectedItem: noop, filters: { categories: [], tags: [], diff --git a/web/app/components/plugins/plugin-page/plugins-panel-results.tsx b/web/app/components/plugins/plugin-page/plugins-panel-results.tsx index 1031fbf45e4..3d0f46513f9 100644 --- a/web/app/components/plugins/plugin-page/plugins-panel-results.tsx +++ b/web/app/components/plugins/plugin-page/plugins-panel-results.tsx @@ -43,8 +43,8 @@ type PluginsPanelResultsProps = { isLastPage: boolean keywords: string loadNextPage: () => void + onSelectBuiltinTool: (id: string) => void scrollAreaLabel?: string - setCurrentBuiltinToolID: (id: string) => void showCategoryEmptyState: boolean tagFilterValue: string[] } @@ -71,8 +71,8 @@ const PluginsPanelResults = ({ isLastPage, keywords, loadNextPage, + onSelectBuiltinTool, scrollAreaLabel, - setCurrentBuiltinToolID, showCategoryEmptyState, tagFilterValue, }: PluginsPanelResultsProps) => { @@ -152,7 +152,7 @@ const PluginsPanelResults = ({ data-step-by-step-tour-target={ filteredList.length === 0 && index === 0 ? firstBuiltinToolTarget : undefined } - onClick={() => setCurrentBuiltinToolID(collection.id)} + onClick={() => onSelectBuiltinTool(collection.id)} > v.currentPluginID) - const setCurrentPluginID = usePluginPageContext((v) => v.setCurrentPluginID) - const [currentBuiltinToolID, setCurrentBuiltinToolID] = useState() + const selectedItem = usePluginPageContext((v) => v.selectedItem) + const setSelectedItem = usePluginPageContext((v) => v.setSelectedItem) const containerRef = useRef(null) const { run: handleFilterChange } = useDebounceFn( @@ -186,18 +185,17 @@ const PluginsPanel = ({ sourceCount: categoryList.length + builtinTools.length, }) - const currentPluginDetail = useMemo(() => { - const detail = pluginListWithLatestVersion.find( - (plugin) => plugin.plugin_id === currentPluginID, - ) - return detail - }, [currentPluginID, pluginListWithLatestVersion]) + const currentPluginID = selectedItem?.type === 'plugin' ? selectedItem.id : undefined + const currentBuiltinToolID = selectedItem?.type === 'builtinTool' ? selectedItem.id : undefined + const currentPluginDetail = useMemo( + () => pluginListWithLatestVersion.find((plugin) => plugin.plugin_id === currentPluginID), + [currentPluginID, pluginListWithLatestVersion], + ) const currentBuiltinTool = useMemo(() => { return filteredBuiltinTools.find((collection) => collection.id === currentBuiltinToolID) }, [currentBuiltinToolID, filteredBuiltinTools]) - const handleHide = () => setCurrentPluginID(undefined) - const handleBuiltinToolHide = () => setCurrentBuiltinToolID(undefined) + const handleDetailHide = () => setSelectedItem(undefined) const hasToolMarketplacePanel = enableMarketplace && isToolIntegrationPage const categoryMarketplace = enableMarketplace && hasEmbeddedMarketplace ? fixedCategory : undefined @@ -284,7 +282,7 @@ const PluginsPanel = ({ keywords={filters.searchQuery} loadNextPage={loadNextPage} scrollAreaLabel={scrollAreaLabel} - setCurrentBuiltinToolID={setCurrentBuiltinToolID} + onSelectBuiltinTool={(id) => setSelectedItem({ type: 'builtinTool', id })} tagFilterValue={filters.tags} canDeletePlugin={canDeletePlugin} canUpdatePlugin={canUpdatePlugin} @@ -327,14 +325,14 @@ const PluginsPanel = ({ onUpdate={() => { invalidateInstalledPluginList(fixedCategory) }} - onHide={handleHide} + onHide={handleDetailHide} canDeletePlugin={canDeletePlugin} canUpdatePlugin={canUpdatePlugin} /> {currentBuiltinTool && !currentBuiltinTool.plugin_id && ( )} diff --git a/web/app/components/snippet-list/__tests__/index.spec.tsx b/web/app/components/snippet-list/__tests__/index.spec.tsx index 2715097d514..e63bb7cb42b 100644 --- a/web/app/components/snippet-list/__tests__/index.spec.tsx +++ b/web/app/components/snippet-list/__tests__/index.spec.tsx @@ -388,11 +388,12 @@ describe('SnippetList', () => { expect(searchInput).toHaveFocus() }) - it('updates the creator query state as a multi creator filter', () => { + it('updates the creator query state as a multi creator filter', async () => { + const user = userEvent.setup() renderList() - fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.creators' })) - fireEvent.click(screen.getByRole('button', { name: /Bob/ })) + await user.click(screen.getByRole('combobox', { name: 'app.studio.filters.creators' })) + await user.click(screen.getByRole('option', { name: /Bob/ })) expect(mockSetCreatorIDs).toHaveBeenCalledWith(['creator-2']) }) diff --git a/web/app/components/workflow/__tests__/custom-edge.spec.tsx b/web/app/components/workflow/__tests__/custom-edge.spec.tsx index d7a61199c20..77bd5373d67 100644 --- a/web/app/components/workflow/__tests__/custom-edge.spec.tsx +++ b/web/app/components/workflow/__tests__/custom-edge.spec.tsx @@ -1,9 +1,11 @@ import type { ReactNode } from 'react' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { Position } from 'reactflow' import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' import CustomEdge from '../custom-edge' import { BlockEnum, NodeRunningStatus } from '../types' +import { renderWorkflowComponent } from './workflow-test-env' const mockUseAvailableBlocks = vi.hoisted(() => vi.fn()) const mockUseNodesInteractions = vi.hoisted(() => vi.fn()) @@ -38,6 +40,9 @@ vi.mock('reactflow', () => ({ Right: 'right', Left: 'left', }, + useStoreApi: () => ({ + getState: () => ({ getNodes: () => [] }), + }), })) vi.mock('../hooks/use-available-blocks', async (importOriginal) => { @@ -81,8 +86,10 @@ describe('CustomEdge', () => { }) }) - it('should render a gradient edge and its real insert-node trigger', () => { - render( + it('should render a gradient edge and hide the start tab from its insert-node selector', async () => { + const user = userEvent.setup() + + renderWorkflowComponent( { opacity: '0.7', zIndex: '1001', }) + + await user.click(addBlockTrigger) + + expect(screen.queryByRole('tab', { name: 'workflow.tabs.start' })).not.toBeInTheDocument() }) it('should prefer the running stroke color when the edge is selected', () => { - render( + renderWorkflowComponent( { }) it('should use the fail-branch running color while the connected node is hovering', () => { - render( + renderWorkflowComponent( { }) it('should fall back to the default edge color when no highlight state is active', () => { - render( + renderWorkflowComponent( { }) describe('inContainer filtering', () => { - it('should exclude Iteration, Loop, End, DataSource, KnowledgeBase, HumanInput when inContainer=true', () => { + it('should allow HumanInput while excluding unsupported blocks when inContainer=true', () => { const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM, true), { hooksStoreProps, }) @@ -155,7 +155,7 @@ describe('useAvailableBlocks', () => { expect(result.current.availableNextBlocks).not.toContain(BlockEnum.End) expect(result.current.availableNextBlocks).not.toContain(BlockEnum.DataSource) expect(result.current.availableNextBlocks).not.toContain(BlockEnum.KnowledgeBase) - expect(result.current.availableNextBlocks).not.toContain(BlockEnum.HumanInput) + expect(result.current.availableNextBlocks).toContain(BlockEnum.HumanInput) }) it('should exclude LoopEnd when not in container', () => { diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts index 0eab5ad8af2..2d2353b6971 100644 --- a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts @@ -1183,15 +1183,14 @@ describe('useNodesInteractions', () => { ) }) - // Nested container paste restrictions should stay aligned with available block filtering. - describe('nested container paste restrictions', () => { + // Nested container paste behavior should stay aligned with available block filtering. + describe('nested container paste behavior', () => { const disallowedNestedPasteNodeTypes = [ BlockEnum.End, BlockEnum.Iteration, BlockEnum.Loop, BlockEnum.DataSource, BlockEnum.KnowledgeBase, - BlockEnum.HumanInput, ] const createNodeMeta = (type: BlockEnum) => ({ @@ -1205,7 +1204,7 @@ describe('useNodesInteractions', () => { }, }) - const runDisallowedPasteScenario = async ( + const pasteNodeIntoContainer = async ( containerType: BlockEnum.Iteration | BlockEnum.Loop, nodeType: BlockEnum, ) => { @@ -1263,23 +1262,48 @@ describe('useNodesInteractions', () => { const pastedNodes = rfState.setNodes.mock.calls.at(-1)?.[0] as Node[] - expect(pastedNodes).toHaveLength(1) - expect(pastedNodes[0]?.id).toBe(containerId) - expect(pastedNodes[0]?.data._children).toEqual([]) - expect( - pastedNodes.some((node) => node.data.type === nodeType && node.parentId === containerId), - ).toBe(false) + return { containerId, pastedNodes } } it.each(disallowedNestedPasteNodeTypes)( 'should not paste %s into an iteration container', async (nodeType) => { - await runDisallowedPasteScenario(BlockEnum.Iteration, nodeType) + const { containerId, pastedNodes } = await pasteNodeIntoContainer( + BlockEnum.Iteration, + nodeType, + ) + + expect(pastedNodes).toHaveLength(1) + expect(pastedNodes[0]?.id).toBe(containerId) + expect(pastedNodes[0]?.data._children).toEqual([]) }, ) - it('should not paste human-input into a loop container', async () => { - await runDisallowedPasteScenario(BlockEnum.Loop, BlockEnum.HumanInput) - }) + it.each([BlockEnum.Iteration, BlockEnum.Loop] as const)( + 'should paste human-input into a %s container', + async (containerType) => { + const { containerId, pastedNodes } = await pasteNodeIntoContainer( + containerType, + BlockEnum.HumanInput, + ) + const container = pastedNodes.find((node) => node.id === containerId) + const pastedHumanInput = pastedNodes.find( + (node) => node.data.type === BlockEnum.HumanInput && node.parentId === containerId, + ) + const isIteration = containerType === BlockEnum.Iteration + + expect(pastedHumanInput).toBeDefined() + expect(pastedHumanInput?.data).toMatchObject({ + isInIteration: isIteration, + iteration_id: isIteration ? containerId : undefined, + isInLoop: !isIteration, + loop_id: isIteration ? undefined : containerId, + }) + expect(container?.data._children).toContainEqual({ + nodeId: pastedHumanInput?.id, + nodeType: BlockEnum.HumanInput, + }) + }, + ) }) }) diff --git a/web/app/components/workflow/hooks/use-available-blocks.ts b/web/app/components/workflow/hooks/use-available-blocks.ts index 675a36be49a..6ebb1933f28 100644 --- a/web/app/components/workflow/hooks/use-available-blocks.ts +++ b/web/app/components/workflow/hooks/use-available-blocks.ts @@ -11,8 +11,7 @@ const availableBlocksFilter = (nodeType: BlockEnum, inContainer?: boolean) => { nodeType === BlockEnum.Loop || nodeType === BlockEnum.End || nodeType === BlockEnum.DataSource || - nodeType === BlockEnum.KnowledgeBase || - nodeType === BlockEnum.HumanInput) + nodeType === BlockEnum.KnowledgeBase) ) return false diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 4b2ea7bf6a8..6d38ddf853f 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -1786,7 +1786,6 @@ export const useNodesInteractions = () => { BlockEnum.Loop, BlockEnum.DataSource, BlockEnum.KnowledgeBase, - BlockEnum.HumanInput, ] // Same-canvas copy keeps the source container selected, so only treat a // selected container as the paste target when it is not part of the clipboard. diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx index 490f656f284..b659664ddfb 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react' import type { CommonNodeType } from '@/app/components/workflow/types' import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { BlockEnum } from '@/app/components/workflow/types' import { NodeSourceHandle, NodeTargetHandle } from '../node-handle' @@ -210,6 +211,16 @@ describe('node-handle', () => { // Target-side tests cover selector visibility, connection locking, and status rendering. describe('NodeTargetHandle', () => { + it('should show the start tab when adding a node before the target node', async () => { + const user = userEvent.setup() + + renderTargetHandle() + + await user.click(screen.getByTestId('handle-target-handle')) + + expect(screen.getByRole('tab', { name: 'workflow.tabs.start' })).toBeInTheDocument() + }) + it('should toggle the target add trigger', () => { renderTargetHandle() @@ -260,6 +271,16 @@ describe('node-handle', () => { // Source-side tests cover selector opening paths, previous-node selection, and status styling. describe('NodeSourceHandle', () => { + it('should show the start tab when adding a node after the source node', async () => { + const user = userEvent.setup() + + renderSourceHandle() + + await user.click(screen.getByTestId('handle-source-handle')) + + expect(screen.getByRole('tab', { name: 'workflow.tabs.start' })).toBeInTheDocument() + }) + it('should toggle the source add trigger', () => { renderSourceHandle() diff --git a/web/app/components/workflow/nodes/_base/components/node-handle.tsx b/web/app/components/workflow/nodes/_base/components/node-handle.tsx index 955e5eb2f87..30a22ab512c 100644 --- a/web/app/components/workflow/nodes/_base/components/node-handle.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-handle.tsx @@ -79,6 +79,7 @@ export const NodeTargetHandle = memo( 'z-1 size-4! rounded-none! border-none! bg-transparent! outline-hidden!', 'after:absolute after:top-1 after:left-1.5 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle', 'transition-all hover:scale-125', + open && 'scale-125', data._runningStatus === NodeRunningStatus.Succeeded && 'after:bg-workflow-link-line-success-handle', data._runningStatus === NodeRunningStatus.Failed && @@ -106,6 +107,7 @@ export const NodeTargetHandle = memo( nextNodeTargetHandle: handleId, }} placement="left" + showStartTab triggerClassName={` absolute left-0 top-0 opacity-0 pointer-events-none transition-opacity duration-150 ${nodeSelectorClassName} @@ -206,6 +208,7 @@ export const NodeSourceHandle = memo( 'group/handle z-1 size-4! rounded-none! border-none! bg-transparent! outline-hidden!', 'after:absolute after:top-1 after:right-1.5 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle', 'transition-all hover:scale-125', + open && 'scale-125', data._runningStatus === NodeRunningStatus.Succeeded && 'after:bg-workflow-link-line-success-handle', data._runningStatus === NodeRunningStatus.Failed && @@ -252,6 +255,7 @@ export const NodeSourceHandle = memo( data-popup-open:opacity-100 `} availableBlocksTypes={availableNextBlocks} + showStartTab /> )} diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx index 4cface45094..d53e1150749 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx @@ -263,30 +263,11 @@ vi.mock('../components/save-inline-agent-to-roster-dialog', () => ({ onSaved, }: { open: boolean - onSaved: (binding: { - agent_id?: string | null - binding_type: 'inline_agent' | 'roster_agent' - current_snapshot_id?: string | null - id: string - node_id: string - workflow_id: string - }) => void + onSaved: (agentId: string) => void }) => open ? (
    -
    diff --git a/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx index 97a0ff5323c..2e6a40f8560 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx @@ -1,5 +1,5 @@ import type { AgentComposerAgentResponse } from '@dify/contracts/api/console/apps/types.gen' -import { render, screen, within } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { FlowType } from '@/types/common' import { SaveInlineAgentToRosterDialog } from '../save-inline-agent-to-roster-dialog' @@ -112,7 +112,6 @@ const renderDialog = (agent: AgentComposerAgentResponse = inlineAgent) => { { { }), ) }) + + it('keeps one source snapshot while open and uses the latest agent after reopening', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const onSaved = vi.fn() + const updatedInlineAgent = { + ...inlineAgent, + description: 'Updated source description.', + icon: '🦊', + icon_background: '#FFEDD5', + role: 'Updated source role', + } + const { rerender } = render( + , + ) + + rerender( + , + ) + + let dialog = screen.getByRole('dialog', { + name: 'agentV2.roster.saveToRosterDialog.title', + }) + expect( + within(dialog).getByRole('textbox', { + name: 'agentV2.roster.createForm.roleLabel common.label.optional', + }), + ).toHaveValue('Tender Analyst') + await user.click( + within(dialog).getByRole('button', { + name: 'agentV2.roster.saveToRosterForm.changeIcon', + }), + ) + expect(screen.getByText('🤖:#F5F3FF')).toBeInTheDocument() + + rerender( + , + ) + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + rerender( + , + ) + dialog = screen.getByRole('dialog', { + name: 'agentV2.roster.saveToRosterDialog.title', + }) + expect( + within(dialog).getByRole('textbox', { + name: 'agentV2.roster.createForm.roleLabel common.label.optional', + }), + ).toHaveValue('Updated source role') + await user.click( + within(dialog).getByRole('button', { + name: 'agentV2.roster.saveToRosterForm.changeIcon', + }), + ) + expect(screen.getByText('🦊:#FFEDD5')).toBeInTheDocument() + }) + + it('returns only the saved roster agent id after a successful save', async () => { + const user = userEvent.setup() + const { onOpenChange, onSaved } = renderDialog() + + const dialog = screen.getByRole('dialog', { + name: 'agentV2.roster.saveToRosterDialog.title', + }) + await user.type( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + 'Roster Tender Agent', + ) + await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) + + const mutationOptions = mutationMock.mutate.mock.calls[0]?.[1] + mutationOptions.onSuccess({ + binding: { + agent_id: 'roster-agent-1', + binding_type: 'roster_agent', + }, + }) + + expect(onSaved).toHaveBeenCalledWith('roster-agent-1') + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(toastMock.success).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx b/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx index 318d9be15cb..69f4f72b89d 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx @@ -1,9 +1,9 @@ 'use client' import type { AgentComposerAgentResponse, - AgentComposerBindingResponse, WorkflowAgentComposerResponse, } from '@dify/contracts/api/console/apps/types.gen' +import type { Ref } from 'react' import type { AgentFormValues, AgentIconSelection, @@ -18,34 +18,100 @@ import { } from '@langgenius/dify-ui/dialog' import { Form } from '@langgenius/dify-ui/form' import { IconButton } from '@langgenius/dify-ui/icon-button' -import { toast } from '@langgenius/dify-ui/toast' import { useMutation } from '@tanstack/react-query' -import { useState } from 'react' +import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIconPicker from '@/app/components/base/app-icon-picker' -import { - createAgentIconSelection, - defaultAgentIcon, -} from '@/features/agent-v2/roster/components/agent-form' +import { createAgentIconSelection } from '@/features/agent-v2/roster/components/agent-form' import { AgentFormFields } from '@/features/agent-v2/roster/components/agent-form-fields' import { consoleQuery } from '@/service/client' import { FlowType } from '@/types/common' type SaveInlineAgentToRosterDialogProps = { - flowId?: string - flowType?: FlowType - formKey: number - initialAgent?: AgentComposerAgentResponse | null + flowId: string + flowType: FlowType.appFlow | FlowType.snippet + initialAgent: AgentComposerAgentResponse nodeId: string open: boolean onOpenChange: (open: boolean) => void - onSaved: (binding: AgentComposerBindingResponse) => void + onSaved: (agentId: string) => void +} + +type SaveInlineAgentToRosterFormSessionProps = { + initialAgent: AgentComposerAgentResponse + nameInputRef: Ref + pending: boolean + onCancel: () => void + onSubmit: (formValues: AgentFormValues, agentIcon: AgentIconSelection) => void +} + +function SaveInlineAgentToRosterFormSession({ + initialAgent, + nameInputRef, + pending, + onCancel, + onSubmit, +}: SaveInlineAgentToRosterFormSessionProps) { + const { t } = useTranslation('agentV2') + const { t: tCommon } = useTranslation('common') + const [initialValues] = useState(() => ({ + fields: { + description: initialAgent.description ?? '', + name: '', + role: initialAgent.role ?? '', + } satisfies AgentFormValues, + icon: createAgentIconSelection(initialAgent), + })) + const [agentIcon, setAgentIcon] = useState(initialValues.icon) + const [iconPickerOpen, setIconPickerOpen] = useState(false) + + return ( + <> +
    + + {t(($) => $['roster.saveToRosterDialog.title'])} + + + {t(($) => $['roster.saveToRosterDialog.description'])} + +
    + + className="flex min-h-0 flex-1 flex-col" + onFormSubmit={(formValues) => onSubmit(formValues, agentIcon)} + > + $['roster.saveToRosterForm.changeIcon'])} + onIconClick={() => setIconPickerOpen(true)} + /> +
    + + +
    + + + + ) } export function SaveInlineAgentToRosterDialog({ flowId, flowType, - formKey, initialAgent, nodeId, open, @@ -53,14 +119,7 @@ export function SaveInlineAgentToRosterDialog({ onSaved, }: SaveInlineAgentToRosterDialogProps) { const { t } = useTranslation('agentV2') - const { t: tCommon } = useTranslation('common') - const [name, setName] = useState('') - const [description, setDescription] = useState(initialAgent?.description ?? '') - const [role, setRole] = useState(initialAgent?.role ?? '') - const [iconPickerOpen, setIconPickerOpen] = useState(false) - const [agentIcon, setAgentIcon] = useState(() => - initialAgent ? createAgentIconSelection(initialAgent) : defaultAgentIcon, - ) + const nameInputRef = useRef(null) const appSaveToRosterMutation = useMutation( consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions(), ) @@ -69,33 +128,20 @@ export function SaveInlineAgentToRosterDialog({ ) const isSavingToRoster = appSaveToRosterMutation.isPending || snippetSaveToRosterMutation.isPending - const handleOpenChange = (nextOpen: boolean) => { - if (nextOpen) { - setName('') - setDescription(initialAgent?.description ?? '') - setRole(initialAgent?.role ?? '') - setAgentIcon(initialAgent ? createAgentIconSelection(initialAgent) : defaultAgentIcon) - } else { - setIconPickerOpen(false) - } + if (!nextOpen && isSavingToRoster) return onOpenChange(nextOpen) } - const handleSubmit = (formValues: AgentFormValues) => { + const handleSubmit = (formValues: AgentFormValues, agentIcon: AgentIconSelection) => { if (isSavingToRoster) return - if (!flowId) return - - const trimmedName = formValues.name?.trim() ?? '' - const trimmedRole = formValues.role?.trim() ?? '' - const body = { variant: 'workflow' as const, save_strategy: 'save_to_roster' as const, - new_agent_name: trimmedName, - description: formValues.description?.trim() ?? '', - role: trimmedRole, + new_agent_name: formValues.name.trim(), + description: formValues.description.trim(), + role: formValues.role.trim(), icon_type: agentIcon.type, icon: agentIcon.type === 'image' ? agentIcon.fileId : agentIcon.icon, icon_background: agentIcon.type === 'emoji' ? agentIcon.background : undefined, @@ -105,9 +151,8 @@ export function SaveInlineAgentToRosterDialog({ const binding = composerState.binding if (binding?.binding_type !== 'roster_agent' || !binding.agent_id) return - toast.success(t(($) => $['roster.saveToRosterSuccess'])) - onSaved(binding) - handleOpenChange(false) + onSaved(binding.agent_id) + onOpenChange(false) }, } @@ -142,75 +187,31 @@ export function SaveInlineAgentToRosterDialog({ return ( <> - + $['operation.close'], { ns: 'common' })} size="lg" - className="absolute inset-e-6 top-6" + className="absolute inset-e-5 top-5" > } /> -
    - - {t(($) => $['roster.saveToRosterDialog.title'])} - - - {t(($) => $['roster.saveToRosterDialog.description'])} - -
    - - key={formKey} - className="min-h-0 flex-1" - onFormSubmit={handleSubmit} - > - $['roster.saveToRosterForm.changeIcon'])} - name={name} - role={role} - onDescriptionChange={setDescription} - onIconClick={() => setIconPickerOpen(true)} - onNameChange={setName} - onRoleChange={setRole} - /> -
    - - -
    - + onOpenChange(false)} + onSubmit={handleSubmit} + />
    - { - setAgentIcon(icon) - }} - /> ) } diff --git a/web/app/components/workflow/nodes/agent-v2/panel.tsx b/web/app/components/workflow/nodes/agent-v2/panel.tsx index ee5c6b9e6b5..1c12d645377 100644 --- a/web/app/components/workflow/nodes/agent-v2/panel.tsx +++ b/web/app/components/workflow/nodes/agent-v2/panel.tsx @@ -131,7 +131,6 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { requestKey: number } | null>(null) const [isOutputVariablesCollapsed, setIsOutputVariablesCollapsed] = useState(true) - const [saveToRosterSessionKey, setSaveToRosterSessionKey] = useState(0) const { handleNodeDataUpdate, handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate() const openInlineAgentPanelNodeId = useStore((state) => state.openInlineAgentPanelNodeId) const setOpenInlineAgentPanelNodeId = useStore((state) => state.setOpenInlineAgentPanelNodeId) @@ -186,7 +185,12 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { const isAgentBindingPending = isInlineAgentPending || isInlineAgentWaitingForCreation || isCreatingInlineAgent const canStartFromScratch = inputs.agent_binding?.binding_type !== 'inline_agent' - const canSaveInlineToRoster = isInlineAgentReady && !!inlineAgent + const saveToRosterTarget = + configsMap?.flowId && + (configsMap.flowType === FlowType.appFlow || configsMap.flowType === FlowType.snippet) + ? { flowId: configsMap.flowId, flowType: configsMap.flowType } + : null + const canSaveInlineToRoster = isInlineAgentReady && !!inlineAgent && !!saveToRosterTarget const inlineComposerStateForPanel = inlineAgentQuery.data const displayedAgent = rosterAgentQuery.data ?? @@ -378,14 +382,11 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { ]) const handleSaveInlineToRosterOpen = useCallback(() => { - setSaveToRosterSessionKey((key) => key + 1) setIsSaveToRosterDialogOpen(true) }, []) const handleInlineSavedToRoster = useCallback( - (binding: AgentComposerBindingResponse) => { - if (binding.binding_type !== 'roster_agent' || !binding.agent_id) return - + (agentId: string) => { setOpenInlineAgentPanelNodeId(undefined) setIsInlineAgentPanelOpenedFromTrigger(false) setIsRosterAgentPanelOpen(true) @@ -395,7 +396,7 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { delete draft._openInlineAgentPanel draft.agent_binding = { binding_type: 'roster_agent', - agent_id: binding.agent_id!, + agent_id: agentId, } }) inputsRef.current = newInputs @@ -698,17 +699,17 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { onSaveInlineToRoster={canSaveInlineToRoster ? handleSaveInlineToRosterOpen : undefined} onStartFromScratch={canStartFromScratch ? handleStartFromScratch : undefined} /> - + {saveToRosterTarget && inlineAgent && ( + + )}
    = {}): EmailConfig => ({ ...overrides, }) +const TestEmailSenderHarness = () => { + const [open, setOpen] = useState(true) + + return ( + <> + + + + ) +} + const createFormInput = (overrides: Partial = {}): FormInputItem => ({ type: InputVarType.paragraph, output_variable_name: 'user_name', @@ -251,6 +272,70 @@ describe('human-input/delivery-method/test-email-sender', () => { expect(handleOpenChange).toHaveBeenCalledWith(false) }) + it('should start a fresh session after closing and reopening', async () => { + const user = userEvent.setup() + const { requests } = setupFetch() + renderWithProviders() + + await user.click( + screen.getByRole('button', { + name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send', + }), + ) + expect( + await screen.findByText('workflow.nodes.humanInput.deliveryMethod.emailSender.done'), + ).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'common.operation.ok' })) + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + await user.click(screen.getByRole('button', { name: 'Open test email sender' })) + + expect( + screen.getByRole('button', { + name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send', + }), + ).toBeInTheDocument() + expect( + screen.queryByText('workflow.nodes.humanInput.deliveryMethod.emailSender.done'), + ).not.toBeInTheDocument() + + await user.click( + screen.getByRole('button', { + name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send', + }), + ) + await waitFor(() => { + expect( + requests.filter( + (request) => request.method === 'POST' && request.url.endsWith('/delivery-test'), + ), + ).toHaveLength(2) + }) + }) + + it('should stay open when clicking outside the dialog', async () => { + const user = userEvent.setup() + const handleOpenChange = vi.fn() + + renderWithProviders( + , + ) + + await user.click(document.body) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(handleOpenChange).not.toHaveBeenCalled() + }) + it('should submit variables referenced by dynamic select option sources', async () => { const user = userEvent.setup() const { requests } = setupFetch() diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx index 86f772b15e7..5d730e134cc 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx @@ -45,6 +45,8 @@ type EmailSenderModalProps = { availableNodes?: Node[] } +type EmailSenderContentProps = Omit + const getOriginVar = (valueSelector: string[], list: NodeOutPutVar[]) => { const targetVar = list.find((item) => item.nodeId === valueSelector[0]) if (!targetVar) return undefined @@ -117,10 +119,9 @@ const formatEmailSenderInputs = ( } } -const EmailSenderModal = ({ +const EmailSenderContent = ({ nodeId, deliveryId, - open, onOpenChange, jumpToEmailConfigModal, config, @@ -128,7 +129,7 @@ const EmailSenderModal = ({ formInputs, nodesOutputVars = [], availableNodes = [], -}: EmailSenderModalProps) => { +}: EmailSenderContentProps) => { const { t } = useTranslation() const { data: userProfileEmail } = useSuspenseQuery({ ...userProfileQueryOptions(), @@ -258,142 +259,49 @@ const EmailSenderModal = ({ if (done) { return ( - - -
    - - {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.done`], { ns: 'workflow' })} - - {debugEnabled && ( -
    - $[`${i18nPrefix}.deliveryMethod.emailSender.debugDone`]} - ns="workflow" - components={{ - email: , - }} - values={{ email: userProfileEmail }} - /> -
    - )} - {!debugEnabled && onlyWholeTeam && ( -
    - $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone2`]} - ns="workflow" - components={{ - team: , - }} - values={{ team: currentWorkspace.name.replace(/'/g, '’') }} - /> -
    - )} - {!debugEnabled && onlySpecificUsers && ( -
    - {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone3`], { - ns: 'workflow', - })} -
    - )} - {!debugEnabled && combinedRecipients && ( -
    - $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone1`]} - ns="workflow" - components={{ - team: , - }} - values={{ team: currentWorkspace.name.replace(/'/g, '’') }} - /> -
    - )} -
    - {(onlySpecificUsers || combinedRecipients) && !debugEnabled && ( -
    - +
    + + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.done`], { ns: 'workflow' })} + + {debugEnabled && ( +
    + $[`${i18nPrefix}.deliveryMethod.emailSender.debugDone`]} + ns="workflow" + components={{ + email: , + }} + values={{ email: userProfileEmail }} />
    )} -
    - -
    - -
    - ) - } - - return ( - - - $['operation.close'], { ns: 'common' })} - size="lg" - className="absolute inset-e-6 top-6" - > - - - } - /> -
    - - {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.title`], { ns: 'workflow' })} - - {debugEnabled && ( - <> -
    - {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip`], { - ns: 'workflow', - })} -
    -
    - $[`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip2`]} - ns="workflow" - components={{ - email: , - }} - values={{ email: userProfileEmail }} - /> -
    - - )} {!debugEnabled && onlyWholeTeam && ( -
    +
    $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip2`]} + i18nKey={($) => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone2`]} ns="workflow" components={{ - team: , + team: , }} values={{ team: currentWorkspace.name.replace(/'/g, '’') }} />
    )} {!debugEnabled && onlySpecificUsers && ( -
    - {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip3`], { +
    + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone3`], { ns: 'workflow', })}
    )} {!debugEnabled && combinedRecipients && ( -
    +
    $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip1`]} + i18nKey={($) => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone1`]} ns="workflow" components={{ - team: , + team: , }} values={{ team: currentWorkspace.name.replace(/'/g, '’') }} /> @@ -401,94 +309,193 @@ const EmailSenderModal = ({ )}
    {(onlySpecificUsers || combinedRecipients) && !debugEnabled && ( - <> -
    - -
    -
    - $[`${i18nPrefix}.deliveryMethod.emailSender.tip`]} - ns="workflow" - components={{ - strong: ( -
    - - )} - {/* vars */} - {generatedInputs.length > 0 && ( - <> -
    - -
    -
    - -
    - {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.varsTip`], { - ns: 'workflow', - })} -
    - {!collapsed && ( -
    - {generatedInputs.map((variable, index) => ( -
    - handleValueChange(variable.variable, v)} - /> -
    - ))} -
    - )} -
    - +
    + +
    )}
    - -
    + + ) + } + + return ( + <> + $['operation.close'], { ns: 'common' })} + size="lg" + className="absolute inset-e-6 top-6" + > + + + } + /> +
    + + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.title`], { ns: 'workflow' })} + + {debugEnabled && ( + <> +
    + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip`], { + ns: 'workflow', + })} +
    +
    + $[`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip2`]} + ns="workflow" + components={{ + email: , + }} + values={{ email: userProfileEmail }} + /> +
    + + )} + {!debugEnabled && onlyWholeTeam && ( +
    + $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip2`]} + ns="workflow" + components={{ + team: , + }} + values={{ team: currentWorkspace.name.replace(/'/g, '’') }} + /> +
    + )} + {!debugEnabled && onlySpecificUsers && ( +
    + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip3`], { + ns: 'workflow', + })} +
    + )} + {!debugEnabled && combinedRecipients && ( +
    + $[`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip1`]} + ns="workflow" + components={{ + team: , + }} + values={{ team: currentWorkspace.name.replace(/'/g, '’') }} + /> +
    + )} +
    + {(onlySpecificUsers || combinedRecipients) && !debugEnabled && ( + <> +
    + +
    +
    + $[`${i18nPrefix}.deliveryMethod.emailSender.tip`]} + ns="workflow" + components={{ + strong: ( +
    + + )} + {/* vars */} + {generatedInputs.length > 0 && ( + <> +
    + +
    +
    + +
    + {t(($) => $[`${i18nPrefix}.deliveryMethod.emailSender.varsTip`], { + ns: 'workflow', + })} +
    + {!collapsed && ( +
    + {generatedInputs.map((variable, index) => ( +
    + handleValueChange(variable.variable, v)} + /> +
    + ))} +
    + )} +
    + + )} +
    + + +
    + + ) +} + +const EmailSenderModal = ({ open, onOpenChange, ...props }: EmailSenderModalProps) => { + return ( + + + ) diff --git a/web/app/layout.tsx b/web/app/layout.tsx index f9452cc2777..4e50f33409e 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -16,6 +16,7 @@ import { import { getLocaleOnServer } from '@/i18n-config/server' import { headers } from '@/next/headers' import { getApplicationTitle } from '@/utils/document-title' +import { basePath } from '@/utils/var' import { CloudAnalytics } from './components/base/analytics-consent/cloud-analytics' import { PartnerStackCookieRecorder } from './components/billing/partner-stack/cookie-recorder' import { AgentationLoader } from './components/devtools/agentation-loader' @@ -33,13 +34,18 @@ export const viewport: Viewport = { export async function generateMetadata(): Promise { const systemFeatures = await prefetchSystemFeatures() - const applicationTitle = getApplicationTitle(systemFeatures?.branding) + const branding = systemFeatures?.branding + const applicationTitle = getApplicationTitle(branding) + const brandedFavicon = branding?.enabled ? branding.favicon : undefined return { title: { default: applicationTitle, template: `%s - ${applicationTitle}`, }, + icons: brandedFavicon + ? { icon: brandedFavicon, apple: brandedFavicon } + : { icon: `${basePath}/favicon.ico` }, } } diff --git a/web/context/__tests__/console-bootstrap.spec.tsx b/web/context/__tests__/console-bootstrap.spec.tsx index 9e0e66bbbb1..3287a77cd5a 100644 --- a/web/context/__tests__/console-bootstrap.spec.tsx +++ b/web/context/__tests__/console-bootstrap.spec.tsx @@ -186,6 +186,8 @@ vi.mock('@/app/components/base/amplitude/use-amplitude-initialized', () => ({ vi.mock('@/app/components/base/amplitude/registration-tracking', () => ({ flushRegistrationSuccess: vi.fn(), + subscribeRegistrationSuccess: () => () => {}, + getRegistrationSuccessSnapshot: () => 0, })) vi.mock('@/app/components/base/zendesk/utils', () => ({ diff --git a/web/docs/test.md b/web/docs/test.md index df686e8e37b..7de60d55f15 100644 --- a/web/docs/test.md +++ b/web/docs/test.md @@ -59,6 +59,7 @@ Browser Mode remains a focused component or feature test and currently proves Ch - Drive state transitions through props, user interaction, URL changes, or public APIs. - Assert rendered UI, ARIA state, navigation, persistence, network-boundary calls, or another observable result. +- For a reset-or-persistence regression in a hidden surface, exercise the public transition: open, modify, close and wait for the surface to disappear, then reopen. Assert the intended behavior without coupling the test to hook placement, component names, keys, or private mount structure. - Do not inspect React state, refs, hook call order, effect dependencies, or private DOM structure. - Test referential identity only when identity is itself a documented public contract. - One test should describe one behavior. It may contain multiple assertions when they jointly prove that behavior. diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx index 7c3a3a452e5..795ba76588d 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx @@ -2965,6 +2965,61 @@ describe('AgentConfigurePage', () => { }) }) + it('should stop applying after finalize fails without adding a generic error toast', async () => { + const user = userEvent.setup() + mocks.finalizeBuildChat.mockRejectedValueOnce(new Error('finalize failed')) + mocks.queryState.composer = { + data: { + agent_soul: { + prompt: { + system_prompt: 'old draft prompt', + }, + }, + }, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + mocks.queryState.buildDraft = { + data: { + agent_soul: { + prompt: { + system_prompt: 'build prompt', + }, + }, + draft: {}, + variant: 'agent_app', + }, + dataUpdatedAt: 1, + error: null, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'apply build draft' })) + + await waitFor(() => expect(mocks.finalizeBuildChat).toHaveBeenCalledTimes(1)) + await waitFor(() => { + expect(screen.getByRole('region', { name: 'build-draft-bar' })).toHaveTextContent( + 'applying:no', + ) + }) + expect(mocks.applyBuildDraft).not.toHaveBeenCalled() + expect(toastMock.error).not.toHaveBeenCalledWith('common.api.actionFailed') + expect(toastMock.success).not.toHaveBeenCalled() + }) + it('should keep the build draft UI while the applied normal draft is still refreshing', async () => { const user = userEvent.setup() const queryClient = new QueryClient() diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx index d1fcfb76586..a04a731b2e8 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx @@ -471,7 +471,7 @@ function AgentVersionRestoreBar({ +
    + {`${initialEmoji?.icon}:${initialEmoji?.background}`} + +
    ) : null, })) @@ -71,9 +76,9 @@ const createAgent = (overrides: Partial = {}): AgentAppPartial const renderDialog = (agent = createAgent()) => { const onOpenChange = vi.fn() - render() + const renderResult = render() - return { onOpenChange } + return { ...renderResult, onOpenChange } } describe('EditAgentDialog', () => { @@ -154,12 +159,35 @@ describe('EditAgentDialog', () => { expect(mutationOptions).not.toHaveProperty('onError') }) + it('closes without a redundant success toast after updating', async () => { + const user = userEvent.setup() + const { onOpenChange } = renderDialog() + + const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) + const roleInput = within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.roleLabel/, + }) + await user.clear(roleInput) + await user.type(roleInput, 'Market Analyst') + await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) + + const mutationOptions = mutationMock.mutate.mock.calls[0]?.[1] + mutationOptions.onSuccess() + + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(toastMock.success).not.toHaveBeenCalled() + }) + it('submits selected icon fields when the roster icon changes', async () => { const user = userEvent.setup() renderDialog() const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) - await user.click(within(dialog).getByRole('button', { name: /agentV2\.roster\.editAgent/ })) + await user.click( + within(dialog).getByRole('button', { + name: 'agentV2.roster.createForm.changeIcon', + }), + ) await user.click(screen.getByRole('button', { hidden: true, name: 'Select brain icon' })) await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) @@ -185,6 +213,159 @@ describe('EditAgentDialog', () => { expect(mutationOptions).not.toHaveProperty('onError') }) + it('keeps the original form snapshot when the agent source changes while open', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const agent = createAgent() + const { rerender } = render() + + let dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) + expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toBeDisabled() + + rerender( + , + ) + + dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) + expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toBeDisabled() + expect( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + ).toHaveValue('Research Agent') + await user.click( + within(dialog).getByRole('button', { + name: 'agentV2.roster.createForm.changeIcon', + }), + ) + expect(screen.getByText('🧸:#F5F3FF')).toBeInTheDocument() + }) + + it('keeps a user-selected icon when the agent source changes during the session', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const { rerender } = render( + , + ) + + const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) + await user.click( + within(dialog).getByRole('button', { + name: 'agentV2.roster.createForm.changeIcon', + }), + ) + await user.click(screen.getByRole('button', { hidden: true, name: 'Select brain icon' })) + + rerender( + , + ) + + expect(screen.getByText('🧠:#E0F2FE')).toBeInTheDocument() + expect( + within(screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' })).getByRole( + 'button', + { name: 'common.operation.save' }, + ), + ).not.toBeDisabled() + }) + + it('creates a fresh form session from the latest agent after closing', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const agent = createAgent() + const { rerender } = render() + + const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) + await user.click( + within(dialog).getByRole('button', { + name: 'agentV2.roster.createForm.changeIcon', + }), + ) + await user.click(screen.getByRole('button', { hidden: true, name: 'Select brain icon' })) + + rerender() + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + rerender( + , + ) + const reopenedDialog = screen.getByRole('dialog', { + name: 'agentV2.roster.editDialog.title', + }) + await user.click( + within(reopenedDialog).getByRole('button', { + name: 'agentV2.roster.createForm.changeIcon', + }), + ) + + expect(screen.getByText('🦊:#FFEDD5')).toBeInTheDocument() + }) + + it('starts a new form session when the agent identity changes', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const { rerender } = render( + , + ) + + rerender( + , + ) + + const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) + const nameInput = within(dialog).getByRole('textbox', { + name: 'agentV2.roster.createForm.nameLabel', + }) + expect(nameInput).toHaveValue('Second Agent') + expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toBeDisabled() + + await user.clear(nameInput) + await user.type(nameInput, 'Renamed Second Agent') + await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) + + expect(mutationMock.mutate).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-2', + }, + body: { + name: 'Renamed Second Agent', + description: 'Second description', + role: 'Second Role', + icon_type: 'emoji', + icon: '🦊', + icon_background: '#FFEDD5', + }, + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) + }) + it('shows a field error when saving with an empty name', async () => { const user = userEvent.setup() renderDialog() diff --git a/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx b/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx index 44b27ecb9c9..ee8258b08dd 100644 --- a/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx @@ -20,15 +20,17 @@ vi.mock('@/next/navigation', () => ({ })) const renderToolbar = ({ + publicationCounts = { drafts: 2, published: 1 }, searchParams = '', }: { + publicationCounts?: { drafts: number; published: number } searchParams?: string } = {}) => { const queryClient = new QueryClient() const result = renderWithNuqs( - + , { searchParams }, ) @@ -93,6 +95,24 @@ describe('RosterToolbar', () => { expect(within(draftsFilter).getByText('2')).toBeInTheDocument() }) + it('renders zero counts before server data is available', () => { + renderToolbar({ publicationCounts: { drafts: 0, published: 0 } }) + + expect( + screen.getByRole('radio', { name: /agentV2\.roster\.filters\.published/ }), + ).toBeInTheDocument() + expect( + within(screen.getByRole('radio', { name: /agentV2\.roster\.filters\.published/ })).getByText( + '0', + ), + ).toBeInTheDocument() + expect( + within(screen.getByRole('radio', { name: /agentV2\.roster\.filters\.drafts/ })).getByText( + '0', + ), + ).toBeInTheDocument() + }) + it('renders created-by-me filtering and emits checked state', async () => { const user = userEvent.setup() const { onUrlUpdate } = renderToolbar() diff --git a/web/features/agent-v2/roster/components/agent-form-fields.tsx b/web/features/agent-v2/roster/components/agent-form-fields.tsx index ff1b5caaf03..7203f173256 100644 --- a/web/features/agent-v2/roster/components/agent-form-fields.tsx +++ b/web/features/agent-v2/roster/components/agent-form-fields.tsx @@ -1,4 +1,5 @@ -import type { AgentIconSelection } from './agent-form' +import type { Ref } from 'react' +import type { AgentFormValues, AgentIconSelection } from './agent-form' import { Field, FieldError, FieldLabel } from '@langgenius/dify-ui/field' import { Input } from '@langgenius/dify-ui/input' import { Textarea } from '@langgenius/dify-ui/textarea' @@ -6,34 +7,26 @@ import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' type AgentFormFieldsProps = { - description: string + defaultValues: AgentFormValues icon: AgentIconSelection iconAriaLabel: string - name: string - onDescriptionChange: (description: string) => void onIconClick: () => void - onNameChange: (name: string) => void - onRoleChange: (role: string) => void - role: string + ref: Ref } export function AgentFormFields({ - description, + defaultValues, icon, iconAriaLabel, - name, - onDescriptionChange, onIconClick, - onNameChange, - onRoleChange, - role, + ref, }: AgentFormFieldsProps) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') return ( -
    -
    +
    +
    -
    +
    { if (typeof value === 'string' && value.length > 0 && !value.trim()) return t(($) => $['roster.createForm.nameRequired']) @@ -63,23 +56,19 @@ export function AgentFormFields({ > {t(($) => $['roster.createForm.nameLabel'])} $['roster.createForm.namePlaceholder'])} required - value={name} /> -
    - - {t(($) => $['roster.createForm.nameRequired'])} - - -
    + + {t(($) => $['roster.createForm.nameRequired'])} + +
    - + {t(($) => $['roster.createForm.roleLabel'])} @@ -88,10 +77,9 @@ export function AgentFormFields({ $['roster.createForm.rolePlaceholder'])} - value={role} />
    @@ -106,9 +94,9 @@ export function AgentFormFields({