diff --git a/.github/scripts/generate-i18n-changes.mjs b/.github/scripts/generate-i18n-changes.mjs index 9fb3f979db9..5bd8eb30c26 100644 --- a/.github/scripts/generate-i18n-changes.mjs +++ b/.github/scripts/generate-i18n-changes.mjs @@ -8,7 +8,8 @@ const headSha = process.env.HEAD_SHA || '' const files = (process.env.CHANGED_FILES || '').split(/\s+/).filter(Boolean) const outputPath = process.env.I18N_CHANGES_OUTPUT_PATH || '/tmp/i18n-changes.json' -const englishPath = (fileStem) => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`) +const englishPath = (fileStem) => + path.join(repoRoot, 'web', 'i18n', 'locales', 'en-US', `${fileStem}.json`) const readCurrentJson = (fileStem) => { const filePath = englishPath(fileStem) @@ -21,7 +22,7 @@ const readBaseJson = (fileStem) => { if (!baseSha) return null try { - const relativePath = `web/i18n/en-US/${fileStem}.json` + const relativePath = `web/i18n/locales/en-US/${fileStem}.json` const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], { encoding: 'utf8', }) diff --git a/.github/workflows/post-merge.yml b/.github/workflows/post-merge.yml index 89c06f6e5ed..b84461ce3b4 100644 --- a/.github/workflows/post-merge.yml +++ b/.github/workflows/post-merge.yml @@ -100,8 +100,8 @@ jobs: - 'web/app/components/workflow/hooks/use-nodes-sync-draft.ts' - 'web/app/components/workflow/nodes/_base/**' - 'web/app/components/workflow/nodes/agent-v2/**' - - 'web/i18n/en-US/agent-v-2.json' - - 'web/i18n/en-US/common.json' + - 'web/i18n/locales/en-US/agent-v-2.json' + - 'web/i18n/locales/en-US/common.json' - 'web/service/base.ts' - 'web/service/console/**' - 'web/service/share.ts' diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 087963d0b0b..d68fae1b98f 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -143,6 +143,11 @@ jobs: working-directory: . run: vp run knip + - name: Web build and unused translations check + env: + NODE_OPTIONS: --max-old-space-size=8192 + run: vp run build:vinext + - name: Web dead code check production working-directory: . run: vp run knip:production diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index f2d1114ae17..f9aef3c605a 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -69,7 +69,7 @@ jobs: /supported: true/ && value != \"en-US\" { printf \"%s \", value } - " web/i18n-config/languages.ts | sed 's/[[:space:]]*$//') + " web/i18n/languages.ts | sed 's/[[:space:]]*$//') generate_changes_json() { node .github/scripts/generate-i18n-changes.mjs @@ -110,9 +110,9 @@ jobs: elif [ "$SYNC_MODE" = "incremental" ]; then BASE_SHA=$(git rev-parse HEAD~1 2>/dev/null || true) if [ -n "$BASE_SHA" ]; then - CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/en-US/*.json' 2>/dev/null | sed -n 's@^.*/@@p' | sed 's/\.json$//' | tr '\n' ' ' | sed 's/[[:space:]]*$//') + CHANGED_FILES=$(git diff --name-only --find-renames=100% --diff-filter=AMDT "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/' 2>/dev/null | sed -n '\@^web/i18n/locales/en-US/[^/]*\.json$@p' | sed -n 's@^.*/@@p' | sed 's/\.json$//' | tr '\n' ' ' | sed 's/[[:space:]]*$//') else - CHANGED_FILES=$(find web/i18n/en-US -maxdepth 1 -type f -name '*.json' -print | sed -n 's@^.*/@@p' | sed 's/\.json$//' | sort | tr '\n' ' ' | sed 's/[[:space:]]*$//') + CHANGED_FILES=$(find web/i18n/locales/en-US -maxdepth 1 -type f -name '*.json' -print | sed -n 's@^.*/@@p' | sed 's/\.json$//' | sort | tr '\n' ' ' | sed 's/[[:space:]]*$//') fi elif [ "$SYNC_MODE" = "full" ]; then echo "workflow_dispatch full mode requires the files input to stay within CI limits." >&2 @@ -170,12 +170,12 @@ jobs: show_full_output: ${{ github.event_name == 'workflow_dispatch' }} prompt: | You are the i18n sync agent for the Dify repository. - Your job is to keep translations synchronized with the English source files under `${{ github.workspace }}/web/i18n/en-US/`. + Your job is to keep translations synchronized with the English source files under `${{ github.workspace }}/web/i18n/locales/en-US/`. Use absolute paths at all times: - Repo root: `${{ github.workspace }}` - Web directory: `${{ github.workspace }}/web` - - Language config: `${{ github.workspace }}/web/i18n-config/languages.ts` + - Language config: `${{ github.workspace }}/web/i18n/languages.ts` Inputs: - Files in scope: `${{ steps.context.outputs.CHANGED_FILES }}` @@ -198,12 +198,12 @@ jobs: Required execution plan: 1. Resolve target languages. - Use the provided `Target languages` value as the source of truth. - - If it is unexpectedly empty, read `${{ github.workspace }}/web/i18n-config/languages.ts` and use every language with `supported: true` except `en-US`. + - If it is unexpectedly empty, read `${{ github.workspace }}/web/i18n/languages.ts` and use every language with `supported: true` except `en-US`. 2. Stay strictly in scope. - Only process the files listed in `Files in scope`. - Only process the resolved target languages, never `en-US`. - Do not touch unrelated i18n files. - - Do not modify `${{ github.workspace }}/web/i18n/en-US/`. + - Do not modify `${{ github.workspace }}/web/i18n/locales/en-US/`. 3. Resolve source changes. - If `Structured change set available` is `true`, read `/tmp/i18n-changes.json` and use it as the source of truth for file-level and key-level changes. - For each file entry: @@ -227,7 +227,7 @@ jobs: - Match the existing terminology and register used by each locale. - Prefer one Edit per file when stable, but prioritize correctness over batching. 6. Verify only the edited files. - - Run `vp fmt ` + - Run `vp fmt ` - Run `vp run dify-web#i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}` - If verification fails, fix the remaining problems before continuing. 7. Stop after the scoped locale files are updated and verification passes. @@ -241,7 +241,7 @@ jobs: if: steps.context.outputs.CHANGED_FILES != '' shell: bash run: | - if [ -z "$(git -C "${{ github.workspace }}" status --porcelain -- web/i18n/)" ]; then + if [ -z "$(git -C "${{ github.workspace }}" status --porcelain -- web/i18n/locales/)" ]; then echo "has_changes=false" >> "$GITHUB_OUTPUT" exit 0 fi @@ -260,7 +260,7 @@ jobs: shell: bash run: | git -C "${{ github.workspace }}" checkout -B "${{ steps.pr_meta.outputs.branch_name }}" - git -C "${{ github.workspace }}" add web/i18n/ + git -C "${{ github.workspace }}" add web/i18n/locales/ git -C "${{ github.workspace }}" commit -m "chore(i18n): sync translations with en-US" - name: Push translation branch @@ -326,7 +326,7 @@ jobs: '## Verification', '', `- \`vp run dify-web#i18n:check --file ${process.env.FILES_IN_SCOPE} --lang ${process.env.TARGET_LANGS}\``, - `- \`vp fmt \``, + `- \`vp fmt \``, '', '## Notes', '', diff --git a/.github/workflows/trigger-i18n-sync.yml b/.github/workflows/trigger-i18n-sync.yml index 6cb096562ff..c576094d3b8 100644 --- a/.github/workflows/trigger-i18n-sync.yml +++ b/.github/workflows/trigger-i18n-sync.yml @@ -4,7 +4,7 @@ on: push: branches: [main] paths: - - 'web/i18n/en-US/*.json' + - 'web/i18n/locales/en-US/*.json' permissions: contents: write @@ -36,9 +36,9 @@ jobs: HEAD_SHA="${{ github.sha }}" if [ -n "$BASE_SHA" ]; then - CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/en-US/*.json' 2>/dev/null | sed -n 's@^.*/@@p' | sed 's/\.json$//' | tr '\n' ' ' | sed 's/[[:space:]]*$//') + CHANGED_FILES=$(git diff --name-only --find-renames=100% --diff-filter=AMDT "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/' 2>/dev/null | sed -n '\@^web/i18n/locales/en-US/[^/]*\.json$@p' | sed -n 's@^.*/@@p' | sed 's/\.json$//' | tr '\n' ' ' | sed 's/[[:space:]]*$//') else - CHANGED_FILES=$(find web/i18n/en-US -maxdepth 1 -type f -name '*.json' -print | sed -n 's@^.*/@@p' | sed 's/\.json$//' | sort | tr '\n' ' ' | sed 's/[[:space:]]*$//') + CHANGED_FILES=$(find web/i18n/locales/en-US -maxdepth 1 -type f -name '*.json' -print | sed -n 's@^.*/@@p' | sed 's/\.json$//' | sort | tr '\n' ' ' | sed 's/[[:space:]]*$//') fi export BASE_SHA HEAD_SHA CHANGED_FILES diff --git a/README.md b/README.md index a0f1dcb5654..0b4df324ba5 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ All of Dify's offerings come with corresponding APIs, so you could effortlessly Use our [documentation](https://docs.dify.ai) for further references and more in-depth instructions. - **[Dify Enterprise](https://dify.ai/dify-enterprise)
** - We provide additional enterprise-centric features. Fill out our [Enterprise form](https://share-na2.hsforms.com/14-09ff5HS92Sh4m3f4yrcw40s9fk) to discuss your enterprise needs.
+ For organizations requiring self-hosting, SSO, RBAC Security and Enterprise Support SLAs, please fill out [this form](https://share-na2.hsforms.com/14-09ff5HS92Sh4m3f4yrcw40s9fk) to speak to a solution representative.
## Staying ahead @@ -140,7 +140,7 @@ Dify welcomes contributions of all kinds: - **Code**: Read the [Contribution Guide](CONTRIBUTING.md), then browse [good first issues](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22). - **Ideas and feedback**: Start or join a [GitHub Discussion](https://github.com/langgenius/dify/discussions). -- **Translations**: Follow the [internationalization guide](web/i18n-config/README.md) to add or update a locale. +- **Translations**: Follow the [internationalization guide](web/i18n/README.md) to add or update a locale. - **Community**: Share the apps you build, help other users, and spread the word about Dify. ### Contributors diff --git a/api/commands/storage.py b/api/commands/storage.py index f23b17680a0..a21b66aa4e8 100644 --- a/api/commands/storage.py +++ b/api/commands/storage.py @@ -751,7 +751,7 @@ def migrate_oss( UploadFile.storage_type == source_storage_type, UploadFile.key.in_(copied_upload_file_keys), ) - .values(storage_type=dify_config.STORAGE_TYPE) + .values(storage_type=StorageType(dify_config.STORAGE_TYPE)) ), ).rowcount db.session.commit() diff --git a/api/constants/languages.py b/api/constants/languages.py index 3ae6773a149..7a485a64b46 100644 --- a/api/constants/languages.py +++ b/api/constants/languages.py @@ -23,6 +23,7 @@ language_timezone_mapping = { "id-ID": "Asia/Jakarta", "ar-TN": "Africa/Tunis", "nl-NL": "Europe/Amsterdam", + "az-AZ": "Asia/Baku", } languages = list(language_timezone_mapping.keys()) diff --git a/api/controllers/console/app/conversation.py b/api/controllers/console/app/conversation.py index 2f0977b39f8..6b136bacb2d 100644 --- a/api/controllers/console/app/conversation.py +++ b/api/controllers/console/app/conversation.py @@ -406,9 +406,11 @@ def _get_conversation(session: Session, current_user: Account, app_model, conver .where(Conversation.id == conversation_id, Conversation.read_at.is_(None)) # Keep updated_at unchanged when only marking a conversation as read. .values( - read_at=naive_utc_now(), - read_account_id=current_user.id, - updated_at=Conversation.updated_at, + { + Conversation.read_at: naive_utc_now(), + Conversation.read_account_id: current_user.id, + Conversation.updated_at: Conversation.updated_at, + } ) ) session.flush() diff --git a/api/controllers/console/app/workflow_run.py b/api/controllers/console/app/workflow_run.py index 66873a9b523..08097d6d43e 100644 --- a/api/controllers/console/app/workflow_run.py +++ b/api/controllers/console/app/workflow_run.py @@ -3,11 +3,13 @@ from uuid import UUID from flask_restx import Resource from pydantic import BaseModel, Field, field_validator +from sqlalchemy.orm import Session from configs import dify_config from controllers.common.errors import NotFoundError from controllers.common.rbac import PlainApp, RBACCheck 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 import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.flask_admission import console_account_admission @@ -24,6 +26,8 @@ from fields.workflow_run_fields import ( WorkflowRunNodeExecutionListResponse, WorkflowRunNodeExecutionResponse, WorkflowRunPaginationResponse, + workflow_run_pagination_response_source, + workflow_run_response_source, ) from libs.custom_inputs import time_duration from libs.helper import dump_response, uuid_value @@ -147,7 +151,8 @@ class AdvancedChatAppWorkflowRunListApi(Resource): ) @get_app_model(mode=[AppMode.ADVANCED_CHAT]) @model_validate(WorkflowRunListQuery) - def get(self, req_data: WorkflowRunListQuery, request_context: RequestContext, app_model: App): + @with_session(write=False) + def get(self, session: Session, req_data: WorkflowRunListQuery, request_context: RequestContext, app_model: App): """ Get advanced chat app workflow run list """ @@ -158,7 +163,10 @@ class AdvancedChatAppWorkflowRunListApi(Resource): triggered_from=_triggered_from(req_data.triggered_from), ) - return dump_response(AdvancedChatWorkflowRunPaginationResponse, result) + return dump_response( + AdvancedChatWorkflowRunPaginationResponse, + workflow_run_pagination_response_source(result, session=session), + ) @console_ns.route("/apps//advanced-chat/workflow-runs/count") @@ -208,7 +216,8 @@ class WorkflowRunListApi(Resource): ) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @model_validate(WorkflowRunListQuery) - def get(self, req_data: WorkflowRunListQuery, request_context: RequestContext, app_model: App): + @with_session(write=False) + def get(self, session: Session, req_data: WorkflowRunListQuery, request_context: RequestContext, app_model: App): """ Get workflow run list """ @@ -219,7 +228,9 @@ class WorkflowRunListApi(Resource): triggered_from=_triggered_from(req_data.triggered_from), ) - return dump_response(WorkflowRunPaginationResponse, result) + return dump_response( + WorkflowRunPaginationResponse, workflow_run_pagination_response_source(result, session=session) + ) @console_ns.route("/apps//workflow-runs/count") @@ -267,8 +278,9 @@ class WorkflowRunDetailApi(Resource): @console_account_admission( rbac_checks=[RBACCheck(RBACPermission.APP_CREATE_AND_MANAGEMENT, PlainApp())], ) + @with_session(write=False) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - def get(self, request_context: RequestContext, app_model: App, run_id: UUID): + def get(self, session: Session, request_context: RequestContext, app_model: App, run_id: UUID): """ Get workflow run detail """ @@ -280,7 +292,7 @@ class WorkflowRunDetailApi(Resource): if workflow_run is None: raise NotFoundError("Workflow run not found") - return dump_response(WorkflowRunDetailResponse, workflow_run) + return dump_response(WorkflowRunDetailResponse, workflow_run_response_source(workflow_run, session=session)) @console_ns.route("/apps//workflow-runs//node-executions") diff --git a/api/controllers/console/datasets/data_source.py b/api/controllers/console/datasets/data_source.py index f5506e36850..39b1d454f13 100644 --- a/api/controllers/console/datasets/data_source.py +++ b/api/controllers/console/datasets/data_source.py @@ -157,7 +157,7 @@ class DataSourceApi(Resource): data_source_oauth_base_path = "/console/api/oauth/data-source" providers = ["notion"] - integrate_data = [] + integrate_data: list[dict[str, bool | datetime | dict[str, Any] | str | None]] = [] for provider in providers: # existing_integrate = next((ai for ai in data_source_integrates if ai.provider == provider), None) existing_integrates = filter(lambda item: item.provider == provider, data_source_integrates) 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 230cc5a3cd5..4df2e49b5ec 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py @@ -54,6 +54,8 @@ from fields.workflow_run_fields import ( WorkflowRunNodeExecutionResponse, WorkflowRunPaginationResponse, node_execution_response_source, + workflow_run_pagination_response_source, + workflow_run_response_source, ) from graphon.model_runtime.utils.encoders import jsonable_encoder from libs import helper @@ -199,12 +201,12 @@ class DraftRagPipelineApi(Resource): @get_rag_pipeline @edit_permission_required @rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetByPipeline())) - def get(self, pipeline: Pipeline): + @with_session(write=False) + def get(self, session: Session, pipeline: Pipeline): """ Get draft rag pipeline's workflow """ # fetch draft workflow by app_model - session = db.session() rag_pipeline_service = RagPipelineService(session) workflow = rag_pipeline_service.get_draft_workflow(pipeline=pipeline) @@ -913,7 +915,8 @@ class RagPipelineWorkflowRunListApi(Resource): @login_required @account_initialization_required @get_rag_pipeline - def get(self, pipeline: Pipeline): + @with_session(write=False) + def get(self, session: Session, pipeline: Pipeline): """ Get workflow run list """ @@ -928,10 +931,12 @@ class RagPipelineWorkflowRunListApi(Resource): "limit": query.limit, } - rag_pipeline_service = RagPipelineService(db.session()) + rag_pipeline_service = RagPipelineService(session) result = rag_pipeline_service.get_rag_pipeline_paginate_workflow_runs(pipeline=pipeline, args=args) - return WorkflowRunPaginationResponse.model_validate(result, from_attributes=True).model_dump(mode="json") + return WorkflowRunPaginationResponse.model_validate( + workflow_run_pagination_response_source(result, session=session), from_attributes=True + ).model_dump(mode="json") @console_ns.route("/rag/pipelines//workflow-runs/") @@ -945,18 +950,21 @@ class RagPipelineWorkflowRunDetailApi(Resource): @login_required @account_initialization_required @get_rag_pipeline - def get(self, pipeline: Pipeline, run_id: UUID): + @with_session(write=False) + def get(self, session: Session, pipeline: Pipeline, run_id: UUID): """ Get workflow run detail """ run_id_str = str(run_id) - rag_pipeline_service = RagPipelineService(db.session()) + rag_pipeline_service = RagPipelineService(session) workflow_run = rag_pipeline_service.get_rag_pipeline_workflow_run(pipeline=pipeline, run_id=run_id_str) if workflow_run is None: raise NotFound("Workflow run not found") - return WorkflowRunDetailResponse.model_validate(workflow_run, from_attributes=True).model_dump(mode="json") + return WorkflowRunDetailResponse.model_validate( + workflow_run_response_source(workflow_run, session=session), from_attributes=True + ).model_dump(mode="json") @console_ns.route("/rag/pipelines//workflow-runs//node-executions") diff --git a/api/controllers/console/snippets/snippet_workflow.py b/api/controllers/console/snippets/snippet_workflow.py index 5cc8d42d5a6..d4a6497ad7a 100644 --- a/api/controllers/console/snippets/snippet_workflow.py +++ b/api/controllers/console/snippets/snippet_workflow.py @@ -12,6 +12,7 @@ from controllers.common.controller_schemas import WorkflowUpdatePayload from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse from controllers.common.rbac import RBACCheck, Workspace 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 import console_ns from controllers.console.app.error import DraftWorkflowNotExist, DraftWorkflowNotSync from controllers.console.app.workflow import ( @@ -52,6 +53,8 @@ from fields.workflow_run_fields import ( WorkflowRunNodeExecutionResponse, WorkflowRunPaginationResponse, node_execution_response_source, + workflow_run_pagination_response_source, + workflow_run_response_source, ) from graphon.graph_engine.manager import GraphEngineManager from libs import helper @@ -512,7 +515,8 @@ class SnippetWorkflowRunsApi(Resource): @login_required @account_initialization_required @get_snippet - def get(self, snippet: CustomizedSnippet): + @with_session(write=False) + def get(self, session: Session, snippet: CustomizedSnippet): """List workflow runs for snippet.""" query = WorkflowRunQuery.model_validate( { @@ -528,7 +532,9 @@ class SnippetWorkflowRunsApi(Resource): snippet_service = _snippet_service() result = snippet_service.get_snippet_workflow_runs(snippet=snippet, args=args) - return WorkflowRunPaginationResponse.model_validate(result, from_attributes=True).model_dump(mode="json") + return WorkflowRunPaginationResponse.model_validate( + workflow_run_pagination_response_source(result, session=session), from_attributes=True + ).model_dump(mode="json") @console_ns.route("/snippets//workflow-runs/") @@ -544,7 +550,8 @@ class SnippetWorkflowRunDetailApi(Resource): @login_required @account_initialization_required @get_snippet - def get(self, snippet: CustomizedSnippet, run_id): + @with_session(write=False) + def get(self, session: Session, snippet: CustomizedSnippet, run_id): """Get workflow run detail for snippet.""" run_id = str(run_id) @@ -554,7 +561,9 @@ class SnippetWorkflowRunDetailApi(Resource): if not workflow_run: raise NotFound("Workflow run not found") - return WorkflowRunDetailResponse.model_validate(workflow_run, from_attributes=True).model_dump(mode="json") + return WorkflowRunDetailResponse.model_validate( + workflow_run_response_source(workflow_run, session=session), from_attributes=True + ).model_dump(mode="json") @console_ns.route("/snippets//workflow-runs//node-executions") diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py index c5e8a22d466..530f2afeb0d 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import StrEnum -from typing import Any, Final, Literal +from typing import Any, Final, Literal, Self from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -51,7 +51,7 @@ class MessageMetadata(BaseModel): class PaginationEnvelope[T](BaseModel): - """Canonical pagination envelope for `/openapi/v1/*` list endpoints.""" + """The one shape every paginated list on this surface answers with.""" page: int limit: int @@ -60,7 +60,7 @@ class PaginationEnvelope[T](BaseModel): data: list[T] @classmethod - def build(cls, *, page: int, limit: int, total: int, items: list[T]) -> PaginationEnvelope[T]: + def build(cls, *, page: int, limit: int, total: int, items: list[T]) -> Self: return cls(page=page, limit=limit, total=total, has_more=page * limit < total, data=items) @@ -74,20 +74,12 @@ class AppListRow(BaseModel): workspace_name: str | None = None -class AppListResponse(BaseModel): - page: int - limit: int - total: int - has_more: bool - data: list[AppListRow] +class AppListResponse(PaginationEnvelope[AppListRow]): + pass -class PermittedExternalAppsListResponse(BaseModel): - page: int - limit: int - total: int - has_more: bool - data: list[AppListRow] +class PermittedExternalAppsListResponse(PaginationEnvelope[AppListRow]): + pass class AppInfo(BaseModel): @@ -109,29 +101,6 @@ class AppDescribeResponse(BaseModel): input_schema: dict[str, Any] | None = Field(default=None) -class ChatMessageResponse(BaseModel): - event: str - task_id: str - id: str - message_id: str - conversation_id: str - mode: str - answer: str - metadata: MessageMetadata = Field(default_factory=MessageMetadata) - created_at: int - - -class CompletionMessageResponse(BaseModel): - event: str - task_id: str - id: str - message_id: str - mode: str - answer: str - metadata: MessageMetadata = Field(default_factory=MessageMetadata) - created_at: int - - class WorkflowRunData(BaseModel): id: str workflow_id: str @@ -145,13 +114,6 @@ class WorkflowRunData(BaseModel): finished_at: int | None = None -class WorkflowRunResponse(BaseModel): - workflow_run_id: str - task_id: str - mode: Literal["workflow"] = "workflow" - data: WorkflowRunData - - class AccountPayload(BaseModel): id: str email: str @@ -195,12 +157,8 @@ class SessionRow(BaseModel): expires_at: str | None = None -class SessionListResponse(BaseModel): - page: int - limit: int - total: int - has_more: bool - data: list[SessionRow] +class SessionListResponse(PaginationEnvelope[SessionRow]): + pass class SessionListQuery(BaseModel): @@ -398,12 +356,8 @@ class MemberResponse(BaseModel): avatar: str | None = None -class MemberListResponse(BaseModel): - page: int - limit: int - total: int - has_more: bool - data: list[MemberResponse] +class MemberListResponse(PaginationEnvelope[MemberResponse]): + pass class MemberListQuery(BaseModel): diff --git a/api/controllers/openapi/apps.py b/api/controllers/openapi/apps.py index 5e1abe27b98..2675cac0400 100644 --- a/api/controllers/openapi/apps.py +++ b/api/controllers/openapi/apps.py @@ -37,8 +37,6 @@ from models.model import AppMode from services.account_service import TenantService from services.app_service import AppListParams, AppService -_ALLOWED_DESCRIBE_FIELDS: frozenset[str] = frozenset({"info", "parameters", "input_schema"}) - def _is_listable(app: App) -> bool: """Whether the openapi app face exposes this app (curated, listable types only).""" @@ -155,7 +153,7 @@ class AppListApi(Resource): def get(self, session: Session, *, auth_data: AuthData, query: AppListQuery): workspace_id = query.workspace_id - empty = AppListResponse(page=query.page, limit=query.limit, total=0, has_more=False, data=[]) + empty = AppListResponse.build(page=query.page, limit=query.limit, total=0, items=[]) if query.name: try: @@ -205,7 +203,7 @@ class AppListApi(Resource): workspace_id=str(workspace_id), workspace_name=tenant_name, ) - env = AppListResponse(page=1, limit=1, total=1, has_more=False, data=[item]) + env = AppListResponse.build(page=1, limit=1, total=1, items=[item]) return env params = AppListParams( @@ -244,11 +242,5 @@ class AppListApi(Resource): if _is_listable(r) ] - env = AppListResponse( - page=query.page, - limit=query.limit, - total=pagination.total, - has_more=query.page * query.limit < pagination.total, - data=items, - ) + env = AppListResponse.build(page=query.page, limit=query.limit, total=pagination.total, items=items) return env diff --git a/api/controllers/openapi/apps_permitted_external.py b/api/controllers/openapi/apps_permitted_external.py index a56ab4ae621..284ec0713d9 100644 --- a/api/controllers/openapi/apps_permitted_external.py +++ b/api/controllers/openapi/apps_permitted_external.py @@ -52,8 +52,8 @@ class PermittedExternalAppsListApi(Resource): ) if not page_result.app_ids: - env = PermittedExternalAppsListResponse( - page=query.page, limit=query.limit, total=page_result.total, has_more=False, data=[] + env = PermittedExternalAppsListResponse.build( + page=query.page, limit=query.limit, total=page_result.total, items=[] ) return env @@ -80,12 +80,8 @@ class PermittedExternalAppsListApi(Resource): workspace_name=tenant.name if tenant else None, ) ) - env = PermittedExternalAppsListResponse( - page=query.page, - limit=query.limit, - total=page_result.total, - has_more=query.page * query.limit < page_result.total, - data=items, + env = PermittedExternalAppsListResponse.build( + page=query.page, limit=query.limit, total=page_result.total, items=items ) return env diff --git a/api/controllers/openapi/auth/data.py b/api/controllers/openapi/auth/data.py index ab1d311fb06..de6f6eaf0fa 100644 --- a/api/controllers/openapi/auth/data.py +++ b/api/controllers/openapi/auth/data.py @@ -42,10 +42,8 @@ class AuthData(BaseModel): required_scope: Scope | None = None token_type: TokenType account_id: uuid.UUID | None = None - token_hash: str token_id: uuid.UUID | None = None scopes: frozenset[Scope] - tenants: dict[str, bool] = Field(default_factory=dict) external_identity: ExternalIdentity | None = None path_params: dict[str, str] = Field(default_factory=dict) diff --git a/api/controllers/openapi/auth/pipeline.py b/api/controllers/openapi/auth/pipeline.py index 12c3c9dce6c..bb32927725d 100644 --- a/api/controllers/openapi/auth/pipeline.py +++ b/api/controllers/openapi/auth/pipeline.py @@ -75,10 +75,8 @@ class AuthPipeline: data = AuthData( token_type=identity.token_type, account_id=identity.account_id, - token_hash=identity.token_hash, token_id=identity.token_id, scopes=frozenset(identity.scopes), - tenants=dict(identity.verified_tenants), required_scope=scope, allowed_roles=allowed_roles, rbac=rbac, diff --git a/api/controllers/openapi/auth/surface_gate.py b/api/controllers/openapi/auth/surface_gate.py deleted file mode 100644 index f3d2d0735bb..00000000000 --- a/api/controllers/openapi/auth/surface_gate.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Surface gate. - -`@accept_subjects(...)` is the route-level form. `SurfaceCheck` (pipeline -step) is the pipeline-level form. Both delegate to `check_surface` so the -audit emit + canonical-path message are single-sourced. - -Subjects come from `libs.oauth_bearer.SubjectType` directly — no parallel -vocabulary. Caller hits the wrong surface → 403 ``wrong_surface`` + audit -``openapi.wrong_surface_denied``. -""" - -from __future__ import annotations - -from collections.abc import Callable -from functools import wraps -from typing import TypeVar - -from flask import request -from werkzeug.exceptions import Forbidden - -from controllers.openapi._audit import emit_wrong_surface -from libs.oauth_bearer import SubjectType, try_get_auth_ctx - -_CANONICAL_PATH: dict[SubjectType, str] = { - SubjectType.ACCOUNT: "/openapi/v1/apps", - SubjectType.EXTERNAL_SSO: "/openapi/v1/permitted-external-apps", -} - -F = TypeVar("F", bound=Callable[..., object]) - - -def check_surface(accepted: frozenset[SubjectType]) -> None: - """Enforce that the resolved subject is in ``accepted``. - - Reads the openapi auth ContextVar via :func:`try_get_auth_ctx`. Raises - ``Forbidden`` with ``wrong_surface`` + canonical-path hint on miss; - emits ``openapi.wrong_surface_denied`` audit. If no auth context is - set the bearer layer didn't run — that's a wiring bug, not a - user-driven failure, so surface it as a ``RuntimeError`` instead of - a silent 403. - """ - ctx = try_get_auth_ctx() - if ctx is None: - raise RuntimeError( - "check_surface called without an auth context; stack validate_bearer or BearerCheck above the surface gate" - ) - - subject = _coerce_subject_type(getattr(ctx, "subject_type", None)) - if subject in accepted: - return - - canonical = _CANONICAL_PATH.get(subject, "/openapi/v1/") if subject else "/openapi/v1/" - emit_wrong_surface( - subject_type=subject.value if subject else None, - attempted_path=request.path, - client_id=getattr(ctx, "client_id", None), - token_id=_stringify(getattr(ctx, "token_id", None)), - ) - raise Forbidden(description=f"wrong_surface (canonical: {canonical})") - - -def accept_subjects(*accepted: SubjectType) -> Callable[[F], F]: - accepted_set: frozenset[SubjectType] = frozenset(accepted) - - def deco(fn: F) -> F: - @wraps(fn) - def wrapper(*args: object, **kwargs: object) -> object: - check_surface(accepted_set) - return fn(*args, **kwargs) - - return wrapper # type: ignore[return-value] - - return deco - - -def _coerce_subject_type(raw: object) -> SubjectType | None: - match raw: - case None: - return None - case SubjectType(): - return raw - case str(): - return SubjectType(raw) - return None - - -def _stringify(value: object) -> str | None: - if value is None: - return None - return str(value) diff --git a/api/controllers/openapi/auth/verify.py b/api/controllers/openapi/auth/verify.py index 7420841a8f3..c669bfaecd9 100644 --- a/api/controllers/openapi/auth/verify.py +++ b/api/controllers/openapi/auth/verify.py @@ -8,7 +8,7 @@ from controllers.common.rbac import enforce_rbac_checks from controllers.openapi.auth.data import AuthData, CallerKind from extensions.ext_database import db from libs.oauth_bearer import Scope, TokenType -from services.account_service import AccountService, TenantService +from services.account_service import AccountService from services.enterprise.enterprise_service import EnterpriseService, WebAppAccessMode @@ -77,13 +77,6 @@ def check_app_api_enabled(data: AuthData) -> None: raise Forbidden("service_api_disabled") -def check_app_access(data: AuthData) -> None: - if data.tenant is None: - return - if not TenantService.account_belongs_to_tenant(data.account_id, data.tenant.id, session=db.session()): - raise Forbidden("subject_no_app_access") - - _ALLOWED_MODES_BY_TOKEN_TYPE: dict[TokenType, frozenset[WebAppAccessMode]] = { TokenType.OAUTH_ACCOUNT: frozenset( { diff --git a/api/controllers/openapi/workspaces.py b/api/controllers/openapi/workspaces.py index 6bff0349f78..6053e09bf28 100644 --- a/api/controllers/openapi/workspaces.py +++ b/api/controllers/openapi/workspaces.py @@ -163,12 +163,11 @@ class WorkspaceMembersApi(Resource): total = len(members) start = (query.page - 1) * query.limit page_items = members[start : start + query.limit] - return MemberListResponse( + return MemberListResponse.build( page=query.page, limit=query.limit, total=total, - has_more=query.page * query.limit < total, - data=[_member_response(m) for m in page_items], + items=[_member_response(m) for m in page_items], ) @auth_router.guard_workspace( diff --git a/api/core/rag/extractor/notion_extractor.py b/api/core/rag/extractor/notion_extractor.py index f0c30b36d7e..f8b849255c1 100644 --- a/api/core/rag/extractor/notion_extractor.py +++ b/api/core/rag/extractor/notion_extractor.py @@ -358,7 +358,7 @@ class NotionExtractor(BaseExtractor): db.session.execute( update(DocumentModel) .where(DocumentModel.id == document_model.id) - .values(data_source_info=json.dumps(data_source_info)) + .values({DocumentModel.data_source_info: json.dumps(data_source_info)}) ) db.session.commit() diff --git a/api/core/rag/index_processor/index_processor.py b/api/core/rag/index_processor/index_processor.py index dc951c554fe..2ab23ae3a82 100644 --- a/api/core/rag/index_processor/index_processor.py +++ b/api/core/rag/index_processor/index_processor.py @@ -15,6 +15,7 @@ from core.rag.index_processor.index_processor_base import SummaryIndexSettingDic from core.workflow.nodes.knowledge_index.exc import KnowledgeIndexNodeError from core.workflow.nodes.knowledge_index.protocols import IndexingResultDict, Preview, PreviewItem, QaPreview from models.dataset import Dataset, Document, DocumentSegment +from models.enums import SegmentStatus from services.vector_space_admission_service import VectorSpaceAdmissionService from .index_processor_factory import IndexProcessorFactory @@ -157,7 +158,7 @@ class IndexProcessor: DocumentSegment.tenant_id == dataset.tenant_id, ) .values( - status="completed", + status=SegmentStatus.COMPLETED, enabled=True, completed_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None), ) diff --git a/api/core/tools/tool_engine.py b/api/core/tools/tool_engine.py index d9761eb4dbd..9c747acc2f8 100644 --- a/api/core/tools/tool_engine.py +++ b/api/core/tools/tool_engine.py @@ -273,8 +273,15 @@ class ToolEngine: # Add JSON parts, avoiding duplicates from text parts. if json_parts: - existing_parts = set(parts) - parts.extend(p for p in json_parts if p not in existing_parts) + + def normalize_json(text: str) -> str: + try: + return json.dumps(json.loads(text), ensure_ascii=False, sort_keys=True, separators=(",", ":")) + except (ValueError, RecursionError): + return text + + existing_parts = {normalize_json(p) for p in parts} + parts.extend(p for p in json_parts if normalize_json(p) not in existing_parts) return "".join(parts) diff --git a/api/fields/base.py b/api/fields/base.py index 826a07d00ea..c8c1b8c8d56 100644 --- a/api/fields/base.py +++ b/api/fields/base.py @@ -1,7 +1,27 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from pydantic import BaseModel, ConfigDict +if TYPE_CHECKING: + from sqlalchemy.orm import Session + + +class SessionResponseSource[SourceT]: + """Wrap a model so session-backed accessors resolve during response validation. + + Subclasses override the accessors that need the session as properties; + every other attribute is delegated to the wrapped model unchanged. + """ + + def __init__(self, source: SourceT, *, session: Session) -> None: + self._source = source + self._session = session + + def __getattr__(self, name: str) -> object: + return getattr(self._source, name) # guard-ignore: no-new-getattr -- delegates model fields + class ResponseModel(BaseModel): model_config = ConfigDict( diff --git a/api/fields/conversation_fields.py b/api/fields/conversation_fields.py index 754c2f85b4d..7e3a0d1a4f0 100644 --- a/api/fields/conversation_fields.py +++ b/api/fields/conversation_fields.py @@ -5,10 +5,9 @@ from datetime import datetime from typing import Annotated, Any from pydantic import Field, WithJsonSchema, field_validator, model_validator -from sqlalchemy.orm import Session from core.rag.entities.citation_metadata import RetrievalSourceMetadata -from fields.base import ResponseModel +from fields.base import ResponseModel, SessionResponseSource from graphon.file import File from libs.helper import to_timestamp from models.account import Account @@ -32,22 +31,13 @@ Int64 = Annotated[int, WithJsonSchema({"format": "int64", "type": "integer"})] URLString = Annotated[str, WithJsonSchema({"format": "url", "type": "string"})] -class _SessionResponseSource[SourceT]: - def __init__(self, source: SourceT, *, session: Session) -> None: - self._source = source - self._session = session - - def __getattr__(self, name: str) -> object: - return getattr(self._source, name) # guard-ignore: no-new-getattr -- delegates model fields - - -class _FeedbackResponseSource(_SessionResponseSource[MessageFeedback]): +class _FeedbackResponseSource(SessionResponseSource[MessageFeedback]): @property def from_account(self) -> Account | None: return self._source.from_account_with_session(session=self._session) -class _AnnotationResponseSource(_SessionResponseSource[MessageAnnotation]): +class _AnnotationResponseSource(SessionResponseSource[MessageAnnotation]): @property def account(self) -> Account | None: return self._source.account_with_session(session=self._session) @@ -57,7 +47,7 @@ class _AnnotationResponseSource(_SessionResponseSource[MessageAnnotation]): return self._source.annotation_create_account_with_session(session=self._session) -class MessageResponseSource(_SessionResponseSource[MessageModel]): +class MessageResponseSource(SessionResponseSource[MessageModel]): @property def inputs(self) -> dict[str, Any]: return self._source.inputs_with_session(session=self._session) @@ -92,7 +82,7 @@ class MessageResponseSource(_SessionResponseSource[MessageModel]): return self._source.message_files_with_session(session=self._session) -class ConversationResponseSource(_SessionResponseSource[ConversationModel]): +class ConversationResponseSource(SessionResponseSource[ConversationModel]): @property def inputs(self) -> dict[str, Any]: return self._source.inputs_with_session(session=self._session) diff --git a/api/fields/workflow_run_fields.py b/api/fields/workflow_run_fields.py index 55caa4893f9..4b69c1a107a 100644 --- a/api/fields/workflow_run_fields.py +++ b/api/fields/workflow_run_fields.py @@ -6,7 +6,6 @@ Pydantic models in this module. from __future__ import annotations -from dataclasses import dataclass from datetime import datetime from enum import Enum from typing import Any @@ -14,7 +13,7 @@ from typing import Any from pydantic import AliasChoices, Field, field_validator from sqlalchemy.orm import Session -from fields.base import ResponseModel +from fields.base import ResponseModel, SessionResponseSource from fields.end_user_fields import SimpleEndUser from fields.member_fields import SimpleAccount from libs.helper import to_timestamp @@ -185,27 +184,45 @@ class WorkflowRunNodeExecutionResponse(ResponseModel): return to_timestamp(value) -@dataclass(frozen=True) -class WorkflowNodeExecutionResponseSource: - """Expose session-backed node-execution accessors during response validation.""" - - node_execution: Any - session: Session +class WorkflowRunResponseSource(SessionResponseSource[Any]): + """Expose session-backed workflow-run accessors during response validation.""" @property def created_by_account(self) -> Any: - return self.node_execution.created_by_account(self.session) + return self._source.created_by_account(self._session) @property def created_by_end_user(self) -> Any: - return self.node_execution.created_by_end_user(self.session) + return self._source.created_by_end_user(self._session) - def __getattr__(self, name: str) -> Any: - return getattr(self.node_execution, name) # guard-ignore: no-new-getattr -- delegates model fields + +def workflow_run_response_source(workflow_run: Any, *, session: Session) -> WorkflowRunResponseSource: + return WorkflowRunResponseSource(workflow_run, session=session) + + +def workflow_run_pagination_response_source(pagination: Any, *, session: Session) -> dict[str, Any]: + """Wrap each run in a pagination payload so list responses resolve accessors via the session.""" + return { + "limit": pagination.limit, + "has_more": pagination.has_more, + "data": [workflow_run_response_source(run, session=session) for run in pagination.data], + } + + +class WorkflowNodeExecutionResponseSource(SessionResponseSource[Any]): + """Expose session-backed node-execution accessors during response validation.""" + + @property + def created_by_account(self) -> Any: + return self._source.created_by_account(self._session) + + @property + def created_by_end_user(self) -> Any: + return self._source.created_by_end_user(self._session) def node_execution_response_source(node_execution: Any, *, session: Session) -> WorkflowNodeExecutionResponseSource: - return WorkflowNodeExecutionResponseSource(node_execution=node_execution, session=session) + return WorkflowNodeExecutionResponseSource(node_execution, session=session) class WorkflowRunNodeExecutionListResponse(ResponseModel): diff --git a/api/libs/oauth_bearer.py b/api/libs/oauth_bearer.py index 89ede4b8a12..bfd96650b8d 100644 --- a/api/libs/oauth_bearer.py +++ b/api/libs/oauth_bearer.py @@ -1,9 +1,4 @@ -"""OAuth bearer primitives. - -To add a token kind: write a Resolver, add a SubjectType + Accepts member, -append a TokenKind to build_registry, and update _SUBJECT_TO_ACCEPT. -Authenticator + validate_bearer stay untouched. -""" +"""OAuth bearer primitives.""" from __future__ import annotations @@ -13,23 +8,19 @@ import logging import uuid from collections.abc import Callable, Iterable from contextvars import ContextVar, Token -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import UTC, datetime from enum import StrEnum from functools import wraps -from typing import Literal, ParamSpec, Protocol, TypeVar +from typing import Literal, Protocol -from flask import request -from sqlalchemy import select, update +from sqlalchemy import update from sqlalchemy.orm import Session -from werkzeug.exceptions import Forbidden, ServiceUnavailable, Unauthorized +from werkzeug.exceptions import ServiceUnavailable from configs import dify_config -from enums import DeploymentEdition -from extensions.ext_database import db -from extensions.ext_redis import redis_client from libs.rate_limit import enforce_bearer_rate_limit -from models import Account, OAuthAccessToken, TenantAccountJoin +from models import OAuthAccessToken logger = logging.getLogger(__name__) @@ -53,8 +44,8 @@ class Scope(StrEnum): """Catalog of bearer scopes recognised by the openapi surface. `FULL` is the catch-all carried by `dfoa_` account tokens — it satisfies - any per-route `require_scope`. `dfoe_` tokens carry the per-feature scopes - (`APPS_RUN`, `APPS_READ_PERMITTED_EXTERNAL`). + any per-route scope requirement. `dfoe_` tokens carry the per-feature + scopes (`APPS_RUN`, `APPS_READ_PERMITTED_EXTERNAL`). """ FULL = "full" @@ -65,32 +56,12 @@ class Scope(StrEnum): WORKSPACE_WRITE = "workspace:write" -class Accepts(StrEnum): - """Subject types a route is willing to accept as caller.""" - - USER_ACCOUNT = "user_account" - USER_EXT_SSO = "user_ext_sso" - - -ACCEPT_USER_ANY: frozenset[Accepts] = frozenset({Accepts.USER_ACCOUNT, Accepts.USER_EXT_SSO}) -ACCEPT_USER_EXT_SSO: frozenset[Accepts] = frozenset({Accepts.USER_EXT_SSO}) - -_SUBJECT_TO_ACCEPT: dict[SubjectType, Accepts] = { - SubjectType.ACCOUNT: Accepts.USER_ACCOUNT, - SubjectType.EXTERNAL_SSO: Accepts.USER_EXT_SSO, -} - - @dataclass(frozen=True, slots=True) class AuthContext: """Per-request identity published via :data:`_auth_ctx_var` (see :func:`set_auth_ctx` / :func:`get_auth_ctx`). ``scopes`` / ``subject_type`` / ``token_type`` come from the TokenKind, not the DB — corrupt rows can't elevate scope. - - `verified_tenants` is a snapshot of the Layer-0 verdict cache at - authenticate time. Per-request mutations write through to Redis via - `record_layer0_verdict`; this snapshot is not updated in place (frozen). """ subject_type: SubjectType @@ -102,8 +73,6 @@ class AuthContext: token_id: uuid.UUID token_type: TokenType expires_at: datetime | None - token_hash: str - verified_tenants: dict[str, bool] = field(default_factory=dict) _auth_ctx_var: ContextVar[AuthContext] = ContextVar("openapi_auth_ctx") @@ -133,7 +102,6 @@ class ResolvedRow: client_id: str | None token_id: uuid.UUID expires_at: datetime | None - verified_tenants: dict[str, bool] = field(default_factory=dict) def to_cache(self) -> dict: return { @@ -143,7 +111,6 @@ class ResolvedRow: "client_id": self.client_id, "token_id": str(self.token_id), "expires_at": self.expires_at.isoformat() if self.expires_at else None, - "verified_tenants": dict(self.verified_tenants), } @classmethod @@ -155,29 +122,9 @@ class ResolvedRow: client_id=data.get("client_id"), token_id=uuid.UUID(data["token_id"]), expires_at=datetime.fromisoformat(data["expires_at"]) if data["expires_at"] else None, - verified_tenants=_coerce_verified_tenants(data.get("verified_tenants")), ) -def _coerce_verified_tenants(raw: object) -> dict[str, bool]: - """Tolerate legacy entries that stored 'ok'/'denied' string verdicts. - - TODO(post-v1.0): remove once the AuthContext cache TTL has fully cycled - on all live deployments (60s TTL → safe to drop one release after rollout). - """ - if not isinstance(raw, dict): - return {} - out: dict[str, bool] = {} - for k, v in raw.items(): - if isinstance(v, bool): - out[k] = v - elif v == "ok": - out[k] = True - elif v == "denied": - out[k] = False - return out - - class Resolver(Protocol): def resolve(self, token_hash: str) -> ResolvedRow | None: # pragma: no cover - contract ... @@ -279,9 +226,8 @@ class BearerAuthenticator: def authenticate(self, token: str) -> AuthContext: """Identity + per-token rate limit (single source). - Both the openapi pipeline (`BearerCheck`) and the decorator - (`validate_bearer`) call this — rate-limit fires exactly once per - request regardless of which path hosts the route. + The openapi auth pipeline is the only caller, so the rate limit fires + exactly once per request. """ kind = self._registry.find(token) if kind is None: @@ -301,8 +247,6 @@ class BearerAuthenticator: token_id=row.token_id, token_type=kind.token_type, expires_at=row.expires_at, - token_hash=token_hash, - verified_tenants=dict(row.verified_tenants), ) @@ -466,92 +410,6 @@ class _VariantResolver: ) -# ============================================================================ -# Layer 0 — workspace membership cache + helper -# ============================================================================ - - -def record_layer0_verdict(token_hash: str, tenant_id: str, verdict: bool) -> None: - """Merge a Layer-0 membership verdict into the AuthContext cache entry at - `auth:token:{hash}`. No-op if entry missing/expired/invalid — next request - rebuilds via authenticate() and re-runs Layer 0. - """ - cache_key = TOKEN_CACHE_KEY_FMT.format(hash=token_hash) - raw = redis_client.get(cache_key) - if raw is None: - return - text = raw.decode() if isinstance(raw, (bytes, bytearray)) else raw - if text == "invalid": - return - try: - data = json.loads(text) - except (ValueError, KeyError): - return - ttl = redis_client.ttl(cache_key) - if ttl <= 0: - return - data.setdefault("verified_tenants", {})[tenant_id] = verdict - redis_client.setex(cache_key, ttl, json.dumps(data)) - - -def check_workspace_membership( - *, - account_id: uuid.UUID | str, - tenant_id: str, - token_hash: str, - membership_cache: dict[str, bool] | None = None, - cached_verdicts: dict[str, bool] | None = None, -) -> None: - """Layer-0 enforcement core. Raises `Forbidden` on deny, returns on allow. - - Shared by the pipeline step (`WorkspaceMembershipCheck`) and the - inline helper (`require_workspace_member`). Caller is responsible for - short-circuiting on EE / SSO subjects before invoking — this function - runs the membership + active-status checks unconditionally. - """ - cache = membership_cache if membership_cache is not None else cached_verdicts or {} - cached = cache.get(tenant_id) - if cached is True: - return - if cached is False: - raise Forbidden("workspace_membership_revoked") - - join = db.session.execute( - select(TenantAccountJoin.id).where( - TenantAccountJoin.account_id == account_id, - TenantAccountJoin.tenant_id == tenant_id, - ) - ).scalar_one_or_none() - if join is None: - record_layer0_verdict(token_hash, tenant_id, False) - raise Forbidden("workspace_membership_revoked") - - status = db.session.execute(select(Account.status).where(Account.id == account_id)).scalar_one_or_none() - if status != "active": - record_layer0_verdict(token_hash, tenant_id, False) - raise Forbidden("workspace_membership_revoked") - - record_layer0_verdict(token_hash, tenant_id, True) - - -def require_workspace_member(ctx: AuthContext, tenant_id: str) -> None: - """AuthContext-flavoured wrapper around `check_workspace_membership`. - - No-op on EE (gateway RBAC owns tenant isolation) and for SSO subjects - (no `tenant_account_joins` row by definition). - """ - if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE: - return - if ctx.subject_type != SubjectType.ACCOUNT or ctx.account_id is None: - return - check_workspace_membership( - account_id=ctx.account_id, - tenant_id=tenant_id, - token_hash=ctx.token_hash, - membership_cache=ctx.verified_tenants, - ) - - # ============================================================================ # Decorator — route-level bearer gate # ============================================================================ @@ -574,10 +432,9 @@ def get_authenticator() -> BearerAuthenticator: def extract_bearer(req) -> str | None: """Pull the bearer token out of an HTTP request's Authorization header. - Used by both attachment paths (the ``validate_bearer`` decorator and the - openapi ``Pipeline.guard``) so the parsing rule lives in one place. Pipeline - callers extract once at the boundary and pass the token through ``Context`` - so steps stay independent of the request object. + Used by the openapi auth pipeline, which extracts once at the request + boundary so the parsing rule lives in one place and later steps stay + independent of the request object. """ header = req.headers.get("Authorization", "") scheme, _, value = header.partition(" ") @@ -586,50 +443,6 @@ def extract_bearer(req) -> str | None: return value.strip() -_DP = ParamSpec("_DP") -_DR = TypeVar("_DR") - - -def validate_bearer(*, accept: frozenset[Accepts]) -> Callable[[Callable[_DP, _DR]], Callable[_DP, _DR]]: - """Opt-in: omitting it leaves the route unauthenticated. - - Resolves user-level OAuth bearers (``dfoa_`` / ``dfoe_``). Legacy - ``app-`` keys belong to ``service_api/wraps.py:validate_app_token`` - and are rejected here as the wrong auth scheme for this surface. - """ - - def wrap(fn: Callable[_DP, _DR]) -> Callable[_DP, _DR]: - @wraps(fn) - def inner(*args: _DP.args, **kwargs: _DP.kwargs) -> _DR: - token = extract_bearer(request) - if token is None: - raise Unauthorized("missing bearer token") - - if _authenticator is None: - raise ServiceUnavailable("bearer_auth_disabled: set ENABLE_OAUTH_BEARER=true to enable") - - try: - ctx = get_authenticator().authenticate(token) - except InvalidBearerError as e: - raise Unauthorized(str(e)) - - if _SUBJECT_TO_ACCEPT[ctx.subject_type] not in accept: - raise Forbidden("token subject type not accepted here") - - # Try/finally pairing — the WSGI worker thread is reused - # across requests, so a leaked ContextVar would publish the - # previous caller's identity to the next request. - reset_token = set_auth_ctx(ctx) - try: - return fn(*args, **kwargs) - finally: - reset_auth_ctx(reset_token) - - return inner - - return wrap - - def bearer_feature_required[**P, R](fn: Callable[P, R]) -> Callable[P, R]: """503 if ENABLE_OAUTH_BEARER is off — minted tokens would be unusable without the authenticator, so fail fast instead of approving silently. @@ -644,29 +457,6 @@ def bearer_feature_required[**P, R](fn: Callable[P, R]) -> Callable[P, R]: return inner -def require_scope(scope: Scope) -> Callable: - """Route-level scope gate — must run AFTER validate_bearer so that - the auth ContextVar is set. Raises ``Forbidden('insufficient_scope: ')`` - when the bearer lacks both the requested scope and ``Scope.FULL``. - """ - - def wrap(fn: Callable) -> Callable: - @wraps(fn) - def inner(*args, **kwargs): - ctx = try_get_auth_ctx() - if ctx is None: - raise RuntimeError( - "require_scope used without validate_bearer; stack @validate_bearer above @require_scope" - ) - if Scope.FULL not in ctx.scopes and scope not in ctx.scopes: - raise Forbidden(f"insufficient_scope: {scope}") - return fn(*args, **kwargs) - - return inner - - return wrap - - # ============================================================================ # Wiring — called once from the app factory # ============================================================================ diff --git a/api/models/workflow.py b/api/models/workflow.py index dbc248e5a11..a53cd4248c8 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -866,19 +866,15 @@ class WorkflowRun(Base): back_populates="workflow_run", ) - @property - @deprecated("This method is retained for historical reasons; avoid using it if possible.") - 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 - @deprecated("This method is retained for historical reasons; avoid using it if possible.") - 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 @property def graph_dict(self) -> Mapping[str, Any]: @@ -892,20 +888,6 @@ class WorkflowRun(Base): def outputs_dict(self) -> Mapping[str, Any]: return json.loads(self.outputs) if self.outputs else {} - @property - @deprecated("This method is retained for historical reasons; avoid using it if possible.") - def message(self): - from .model import Message - - return db.session.scalar( - select(Message).where(Message.app_id == self.app_id, Message.workflow_run_id == self.id) - ) - - @property - @deprecated("This method is retained for historical reasons; avoid using it if possible.") - def workflow(self): - return db.session.scalar(select(Workflow).where(Workflow.id == self.workflow_id)) - def to_dict(self) -> WorkflowRunDict: return WorkflowRunDict( id=self.id, diff --git a/api/pyproject.toml b/api/pyproject.toml index 28ccf3c3d8a..cfbf28c95b3 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -186,7 +186,7 @@ dev = [ # "locust>=2.40.4", # Temporarily removed due to compatibility issues. Uncomment when resolved. "pytest-timeout>=2.4.0", "pytest-xdist>=3.8.0", - "pyrefly>=1.2.0", + "pyrefly>=1.3.1", "xinference-client>=2.7.0", "types-pytz>=2026.3.1.20260727", "types-bleach>=6.4.0.20260728", diff --git a/api/services/file_service.py b/api/services/file_service.py index f27f4224b27..47c1ecb5ada 100644 --- a/api/services/file_service.py +++ b/api/services/file_service.py @@ -1,6 +1,7 @@ import base64 import hashlib import os +import posixpath import uuid from collections.abc import Generator, Sequence # Changed Iterator to Generator from contextlib import contextmanager, suppress @@ -315,8 +316,9 @@ class FileService: We keep this conservative: the upload flow already rejects `/` and `\\`, but older rows (or imported data) could still contain unsafe names. """ - # Drop any directory components and prevent empty names. - base = os.path.basename(name).strip() or "file" + # Drop any directory components and prevent empty names. ZIP names are always POSIX-style, so split on + # `/` explicitly rather than through `os.path`, whose separators differ on Windows hosts. + base = posixpath.basename(name).strip() or "file" # ZIP uses forward slashes as separators; remove any residual separator characters. return base.replace("/", "_").replace("\\", "_") diff --git a/api/services/legacy_model_type_migration.py b/api/services/legacy_model_type_migration.py index 2034246991f..7fcbb95c421 100644 --- a/api/services/legacy_model_type_migration.py +++ b/api/services/legacy_model_type_migration.py @@ -876,7 +876,7 @@ class Migration: session.execute( sa.update(ProviderModel) .where(ProviderModel.id == str(plan.winner.row.id)) - .values(model_type=plan.winner.canonical_model_type.value) + .values(model_type=plan.winner.canonical_model_type) ) self._log_row_updated( ProviderModel.__tablename__, @@ -1099,7 +1099,7 @@ class Migration: session.execute( sa.update(TenantDefaultModel) .where(TenantDefaultModel.id == str(plan.winner.row.id)) - .values(model_type=plan.winner.canonical_model_type.value) + .values(model_type=plan.winner.canonical_model_type) ) self._log_row_updated( TenantDefaultModel.__tablename__, @@ -1314,7 +1314,7 @@ class Migration: session.execute( sa.update(ProviderModelSetting) .where(ProviderModelSetting.id == str(plan.winner.row.id)) - .values(model_type=plan.winner.canonical_model_type.value) + .values(model_type=plan.winner.canonical_model_type) ) self._log_row_updated( ProviderModelSetting.__tablename__, @@ -1751,7 +1751,7 @@ class Migration: session.execute( sa.update(LoadBalancingModelConfig) .where(LoadBalancingModelConfig.id == processed_row_id) - .values(model_type=current_row.canonical_model_type.value) + .values(model_type=current_row.canonical_model_type) ) self._log_row_updated( LoadBalancingModelConfig.__tablename__, @@ -2096,7 +2096,7 @@ class Migration: session.execute( sa.update(ProviderModelCredential) .where(ProviderModelCredential.id == winner_credential_id) - .values(model_type=plan.winner.canonical_model_type.value) + .values(model_type=plan.winner.canonical_model_type) ) self._log_row_updated( ProviderModelCredential.__tablename__, diff --git a/api/services/openapi/license_gate.py b/api/services/openapi/license_gate.py deleted file mode 100644 index 8df92eb344b..00000000000 --- a/api/services/openapi/license_gate.py +++ /dev/null @@ -1,53 +0,0 @@ -"""License gate for the /openapi/v1/permitted-external-apps* surface. - -Enterprise-edition only. Community and Cloud deployments skip the gate entirely — -the EE blueprint chain is what gives CE deploys no callers on this surface -in practice, but the explicit short-circuit avoids any test/fixture that -flips the surface on without flipping the license. - -Uses the narrow system license policy shared with Console admission. - -Companion to ``controllers.console.wraps.enterprise_license_required`` — -that one is for console (cookie-authed, force-logout 401). This one is -for bearer surface (token-authed, 403 ``license_required``). -""" - -from __future__ import annotations - -import logging -from collections.abc import Callable -from functools import wraps - -from werkzeug.exceptions import Forbidden - -from configs import dify_config -from enums import DeploymentEdition -from services.entities.feature_entities import LicenseStatus -from services.system_feature_service import SystemFeatureService - -logger = logging.getLogger(__name__) - -_VALID_LICENSE_STATUSES: frozenset[LicenseStatus] = frozenset({LicenseStatus.ACTIVE, LicenseStatus.EXPIRING}) - - -def license_required[**P, R](view: Callable[P, R]) -> Callable[P, R]: - """Decorator form. Raises ``Forbidden('license_required')`` when the EE - deployment has no valid license. No-op outside the Enterprise edition. - """ - - @wraps(view) - def decorated(*args: P.args, **kwargs: P.kwargs) -> R: - if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE and not _is_license_valid(): - raise Forbidden(description="license_required") - return view(*args, **kwargs) - - return decorated - - -def _is_license_valid() -> bool: - try: - license_status = SystemFeatureService.get_license_status() - except Exception: - logger.exception("license_gate: SystemFeatureService.get_license_status failed") - return False - return license_status in _VALID_LICENSE_STATUSES diff --git a/api/services/rag_pipeline/rag_pipeline_transform_service.py b/api/services/rag_pipeline/rag_pipeline_transform_service.py index 90dd6e081bd..9304257de9a 100644 --- a/api/services/rag_pipeline/rag_pipeline_transform_service.py +++ b/api/services/rag_pipeline/rag_pipeline_transform_service.py @@ -123,29 +123,40 @@ class RagPipelineTransformService: case DataSourceType.UPLOAD_FILE: if indexing_technique == IndexTechniqueType.HIGH_QUALITY: # get graph from transform.file-general-high-quality.yml - with open(f"{Path(__file__).parent}/transform/file-general-high-quality.yml") as f: + with open( + f"{Path(__file__).parent}/transform/file-general-high-quality.yml", encoding="utf-8" + ) as f: pipeline_yaml = yaml.safe_load(f) if indexing_technique == IndexTechniqueType.ECONOMY: # get graph from transform.file-general-economy.yml - with open(f"{Path(__file__).parent}/transform/file-general-economy.yml") as f: + with open(f"{Path(__file__).parent}/transform/file-general-economy.yml", encoding="utf-8") as f: pipeline_yaml = yaml.safe_load(f) case DataSourceType.NOTION_IMPORT: if indexing_technique == IndexTechniqueType.HIGH_QUALITY: # get graph from transform.notion-general-high-quality.yml - with open(f"{Path(__file__).parent}/transform/notion-general-high-quality.yml") as f: + with open( + f"{Path(__file__).parent}/transform/notion-general-high-quality.yml", encoding="utf-8" + ) as f: pipeline_yaml = yaml.safe_load(f) if indexing_technique == IndexTechniqueType.ECONOMY: # get graph from transform.notion-general-economy.yml - with open(f"{Path(__file__).parent}/transform/notion-general-economy.yml") as f: + with open( + f"{Path(__file__).parent}/transform/notion-general-economy.yml", encoding="utf-8" + ) as f: pipeline_yaml = yaml.safe_load(f) case DataSourceType.WEBSITE_CRAWL: if indexing_technique == IndexTechniqueType.HIGH_QUALITY: # get graph from transform.website-crawl-general-high-quality.yml - with open(f"{Path(__file__).parent}/transform/website-crawl-general-high-quality.yml") as f: + with open( + f"{Path(__file__).parent}/transform/website-crawl-general-high-quality.yml", + encoding="utf-8", + ) as f: pipeline_yaml = yaml.safe_load(f) if indexing_technique == IndexTechniqueType.ECONOMY: # get graph from transform.website-crawl-general-economy.yml - with open(f"{Path(__file__).parent}/transform/website-crawl-general-economy.yml") as f: + with open( + f"{Path(__file__).parent}/transform/website-crawl-general-economy.yml", encoding="utf-8" + ) as f: pipeline_yaml = yaml.safe_load(f) case _: raise ValueError("Unsupported datasource type") @@ -153,15 +164,17 @@ class RagPipelineTransformService: match datasource_type: case DataSourceType.UPLOAD_FILE: # get graph from transform.file-parentchild.yml - with open(f"{Path(__file__).parent}/transform/file-parentchild.yml") as f: + with open(f"{Path(__file__).parent}/transform/file-parentchild.yml", encoding="utf-8") as f: pipeline_yaml = yaml.safe_load(f) case DataSourceType.NOTION_IMPORT: # get graph from transform.notion-parentchild.yml - with open(f"{Path(__file__).parent}/transform/notion-parentchild.yml") as f: + with open(f"{Path(__file__).parent}/transform/notion-parentchild.yml", encoding="utf-8") as f: pipeline_yaml = yaml.safe_load(f) case DataSourceType.WEBSITE_CRAWL: # get graph from transform.website-crawl-parentchild.yml - with open(f"{Path(__file__).parent}/transform/website-crawl-parentchild.yml") as f: + with open( + f"{Path(__file__).parent}/transform/website-crawl-parentchild.yml", encoding="utf-8" + ) as f: pipeline_yaml = yaml.safe_load(f) case _: raise ValueError("Unsupported datasource type") diff --git a/api/services/tag_service.py b/api/services/tag_service.py index 990e3d7c984..139c76add3c 100644 --- a/api/services/tag_service.py +++ b/api/services/tag_service.py @@ -5,7 +5,7 @@ import sqlalchemy as sa from flask_login import current_user from flask_sqlalchemy.session import Session as FlaskSession from pydantic import BaseModel, Field -from sqlalchemy import delete, func, select +from sqlalchemy import delete, func, select, update from sqlalchemy.engine import CursorResult from sqlalchemy.orm import Session, scoped_session from werkzeug.exceptions import NotFound @@ -192,7 +192,12 @@ class TagService: ) if existing: raise ValueError("Tag name already exists") - tag.name = payload.name + session.execute( + update(Tag) + .where(Tag.id == tag_id, Tag.tenant_id == current_tenant_id) + .values(name=payload.name) + .execution_options(synchronize_session="fetch") + ) session.commit() return tag diff --git a/api/tasks/deal_dataset_index_update_task.py b/api/tasks/deal_dataset_index_update_task.py index 63279348455..dfbbe3de065 100644 --- a/api/tasks/deal_dataset_index_update_task.py +++ b/api/tasks/deal_dataset_index_update_task.py @@ -48,7 +48,7 @@ def deal_dataset_index_update_task(dataset_id: str, action: str): session.execute( update(DatasetDocument) .where(DatasetDocument.id.in_(dataset_documents_ids)) - .values(indexing_status="indexing") + .values({DatasetDocument.indexing_status: "indexing"}) ) session.commit() @@ -90,14 +90,14 @@ def deal_dataset_index_update_task(dataset_id: str, action: str): session.execute( update(DatasetDocument) .where(DatasetDocument.id == dataset_document.id) - .values(indexing_status="completed") + .values({DatasetDocument.indexing_status: "completed"}) ) session.commit() except Exception as e: session.execute( update(DatasetDocument) .where(DatasetDocument.id == dataset_document.id) - .values(indexing_status="error", error=str(e)) + .values({DatasetDocument.indexing_status: "error", DatasetDocument.error: str(e)}) ) session.commit() elif action == "update": @@ -116,7 +116,7 @@ def deal_dataset_index_update_task(dataset_id: str, action: str): session.execute( update(DatasetDocument) .where(DatasetDocument.id.in_(dataset_documents_ids)) - .values(indexing_status="indexing") + .values({DatasetDocument.indexing_status: "indexing"}) ) session.commit() @@ -191,14 +191,14 @@ def deal_dataset_index_update_task(dataset_id: str, action: str): session.execute( update(DatasetDocument) .where(DatasetDocument.id == dataset_document.id) - .values(indexing_status="completed") + .values({DatasetDocument.indexing_status: "completed"}) ) session.commit() except Exception as e: session.execute( update(DatasetDocument) .where(DatasetDocument.id == dataset_document.id) - .values(indexing_status="error", error=str(e)) + .values({DatasetDocument.indexing_status: "error", DatasetDocument.error: str(e)}) ) session.commit() else: diff --git a/api/tasks/deal_dataset_vector_index_task.py b/api/tasks/deal_dataset_vector_index_task.py index 001436a9af6..928134fb7b7 100644 --- a/api/tasks/deal_dataset_vector_index_task.py +++ b/api/tasks/deal_dataset_vector_index_task.py @@ -52,7 +52,7 @@ def deal_dataset_vector_index_task(dataset_id: str, action: str): session.execute( update(DatasetDocument) .where(DatasetDocument.id.in_(dataset_documents_ids)) - .values(indexing_status="indexing") + .values({DatasetDocument.indexing_status: "indexing"}) ) session.commit() @@ -86,14 +86,14 @@ def deal_dataset_vector_index_task(dataset_id: str, action: str): session.execute( update(DatasetDocument) .where(DatasetDocument.id == dataset_document.id) - .values(indexing_status="completed") + .values({DatasetDocument.indexing_status: "completed"}) ) session.commit() except Exception as e: session.execute( update(DatasetDocument) .where(DatasetDocument.id == dataset_document.id) - .values(indexing_status="error", error=str(e)) + .values({DatasetDocument.indexing_status: "error", DatasetDocument.error: str(e)}) ) session.commit() elif action == "update": @@ -112,7 +112,7 @@ def deal_dataset_vector_index_task(dataset_id: str, action: str): session.execute( update(DatasetDocument) .where(DatasetDocument.id.in_(dataset_documents_ids)) - .values(indexing_status="indexing") + .values({DatasetDocument.indexing_status: "indexing"}) ) session.commit() @@ -187,14 +187,14 @@ def deal_dataset_vector_index_task(dataset_id: str, action: str): session.execute( update(DatasetDocument) .where(DatasetDocument.id == dataset_document.id) - .values(indexing_status="completed") + .values({DatasetDocument.indexing_status: "completed"}) ) session.commit() except Exception as e: session.execute( update(DatasetDocument) .where(DatasetDocument.id == dataset_document.id) - .values(indexing_status="error", error=str(e)) + .values({DatasetDocument.indexing_status: "error", DatasetDocument.error: str(e)}) ) session.commit() else: diff --git a/api/tasks/enable_segments_to_index_task.py b/api/tasks/enable_segments_to_index_task.py index d4ab9a333df..0de5effcd47 100644 --- a/api/tasks/enable_segments_to_index_task.py +++ b/api/tasks/enable_segments_to_index_task.py @@ -14,6 +14,7 @@ from extensions.ext_redis import redis_client from libs.datetime_utils import naive_utc_now from models.dataset import Dataset, DocumentSegment from models.dataset import Document as DatasetDocument +from models.enums import SegmentStatus logger = logging.getLogger(__name__) @@ -133,7 +134,7 @@ def enable_segments_to_index_task(segment_ids: list, dataset_id: str, document_i DocumentSegment.dataset_id == dataset_id, DocumentSegment.document_id == document_id, ) - .values(error=str(e), status="error", disabled_at=naive_utc_now(), enabled=False) + .values(error=str(e), status=SegmentStatus.ERROR, disabled_at=naive_utc_now(), enabled=False) ) session.commit() finally: diff --git a/api/tests/integration_tests/controllers/openapi/test_auth.py b/api/tests/integration_tests/controllers/openapi/test_auth.py deleted file mode 100644 index 956a0904dab..00000000000 --- a/api/tests/integration_tests/controllers/openapi/test_auth.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Integration tests for the /openapi/v1 bearer auth surface. - -Layer 0 (workspace membership), per-token rate limit, and read-scope (`apps:read`) -acceptance/rejection on app-scoped routes. -""" - -from __future__ import annotations - -from collections.abc import Generator - -import pytest -from flask import Flask -from flask.testing import FlaskClient - -from enums import DeploymentEdition -from extensions.ext_database import db -from models import App, Tenant - - -def test_info_accepts_account_bearer_with_apps_read_scope( - test_client: FlaskClient, - app_in_workspace: App, - account_token: str, -) -> None: - res = test_client.get( - f"/openapi/v1/apps/{app_in_workspace.id}/info", - headers={"Authorization": f"Bearer {account_token}"}, - ) - assert res.status_code == 200 - assert res.json["id"] == app_in_workspace.id - - -@pytest.fixture -def other_workspace_app(flask_app: Flask) -> Generator[App, None, None]: - """A fresh app under a *different* tenant — caller has no membership row.""" - with flask_app.app_context(): - other_tenant = Tenant(name="other", status="normal") - db.session.add(other_tenant) - db.session.commit() - app = App( - tenant_id=other_tenant.id, - name="b", - mode="chat", - status="normal", - enable_site=True, - enable_api=True, - ) - db.session.add(app) - db.session.commit() - yield app - db.session.delete(app) - db.session.delete(other_tenant) - db.session.commit() - - -def test_layer0_denies_account_bearer_without_membership( - test_client: FlaskClient, - account_token: str, - other_workspace_app: App, -) -> None: - """Account A bearer hitting an app under tenant B — Layer 0 denies on CE.""" - res = test_client.get( - f"/openapi/v1/apps/{other_workspace_app.id}/info", - headers={"Authorization": f"Bearer {account_token}"}, - ) - assert res.status_code == 403 - assert res.json.get("message") == "workspace_membership_revoked" - - -def test_layer0_skipped_for_enterprise_edition( - test_client: FlaskClient, - account_token: str, - other_workspace_app: App, - monkeypatch, -) -> None: - """On EE, Layer 0 short-circuits — gateway RBAC owns tenant isolation. - - /info uses validate_bearer + require_workspace_member inline (no - AppAuthzCheck), so a cross-tenant bearer reaches the app lookup and - gets 200 — gateway is expected to enforce isolation upstream. - """ - from configs import dify_config - - # Override the conftest autouse default for this test only. - monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) - - res = test_client.get( - f"/openapi/v1/apps/{other_workspace_app.id}/info", - headers={"Authorization": f"Bearer {account_token}"}, - ) - assert res.status_code == 200 - assert res.json.get("message") != "workspace_membership_revoked" - - -def test_rate_limit_returns_429_after_60_requests( - test_client: FlaskClient, - account_token: str, -) -> None: - """61st sequential GET to /account on the same bearer → 429 with Retry-After.""" - headers = {"Authorization": f"Bearer {account_token}"} - for i in range(60): - r = test_client.get("/openapi/v1/account", headers=headers) - assert r.status_code == 200, f"unexpected fail at i={i}" - - r = test_client.get("/openapi/v1/account", headers=headers) - assert r.status_code == 429 - assert r.headers.get("Retry-After"), "Retry-After header missing" - assert int(r.headers["Retry-After"]) >= 1 - body = r.json or {} - assert body.get("error") == "rate_limited" - assert isinstance(body.get("retry_after_ms"), int) - assert body["retry_after_ms"] >= 1000 - - -def test_rate_limit_bucket_shared_across_surfaces( - test_client: FlaskClient, - app_in_workspace: App, - account_token: str, -) -> None: - """30 calls to /account + 30 calls to /apps//info on same token → 61st 429s.""" - headers = {"Authorization": f"Bearer {account_token}"} - for _ in range(30): - assert test_client.get("/openapi/v1/account", headers=headers).status_code == 200 - for _ in range(30): - assert test_client.get(f"/openapi/v1/apps/{app_in_workspace.id}/info", headers=headers).status_code == 200 - - r = test_client.get("/openapi/v1/account", headers=headers) - assert r.status_code == 429 diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py b/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py index 2047ee765d1..1b7894bcb8a 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py @@ -82,7 +82,6 @@ def auth_for( return AuthData( token_type=TokenType.OAUTH_ACCOUNT, account_id=uuid.UUID(str(account.id)), - token_hash="integration-test", token_id=token_id, scopes=frozenset({Scope.FULL}), caller=account, 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 d8c47e2747e..bb3201bbdaa 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 @@ -22,6 +22,7 @@ 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 +from tests.unit_tests.model_factories import make_tenant def _invoke_reset() -> int: @@ -38,9 +39,7 @@ USER_ID = "22222222-2222-2222-2222-222222222222" def _tenant(tenant_id: str, *, name: str = "Test tenant") -> Tenant: - tenant = Tenant(name=name, encrypt_public_key="old-key") - tenant.id = tenant_id - return tenant + return make_tenant(tenant_id=tenant_id, name=name, encrypt_public_key="old-key") def _encrypted_rows(tenant_id: str, *, suffix: str = "1") -> tuple[object, ...]: 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 ee10c5dc19f..d2af3b5fddc 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 @@ -75,6 +75,7 @@ from services.entities.agent_entities import ( WorkflowComposerCopyFromRosterPayload, ) from tests.unit_tests.config_override import apply_config_overrides +from tests.unit_tests.model_factories import make_account def _persist_conversation_message( @@ -228,12 +229,13 @@ def _app_detail_obj(**overrides) -> App: def _account(*, account_id: str = "account-1", privileged: bool = False, timezone: str | None = None) -> Account: - account = Account(name="Agent Controller Tester", email=f"{account_id}@example.com") - account.id = account_id - account.timezone = timezone - if privileged: - account.role = TenantAccountRole.OWNER - return account + return make_account( + account_id=account_id, + name="Agent Controller Tester", + email=f"{account_id}@example.com", + timezone=timezone, + role=TenantAccountRole.OWNER if privileged else None, + ) def _candidates_response(variant: str) -> dict: diff --git a/api/tests/unit_tests/controllers/console/agent/test_app_helpers.py b/api/tests/unit_tests/controllers/console/agent/test_app_helpers.py index b797a2956ad..8ad20b6b84c 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_app_helpers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_app_helpers.py @@ -6,10 +6,11 @@ from sqlalchemy.orm import Session from controllers.console.agent import app_helpers from models.model import App, AppMode +from tests.unit_tests.model_factories import make_app def _app() -> App: - return App(id="app-1", tenant_id="tenant-1", name="Agent App", mode=AppMode.AGENT) + return make_app(name="Agent App", mode=AppMode.AGENT, icon_type=None) def test_resolve_agent_app_model_reuses_caller_session( diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py b/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py index 0ce728baef9..077b166917f 100644 --- a/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py +++ b/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py @@ -12,6 +12,7 @@ from models.account import Account from models.model import App, AppMode, IconType from services.agent_app_sandbox_service import AgentSandboxDownload, AgentSandboxInfo, AgentSandboxInspectorError from tests.unit_tests.controllers.rbac_introspection import rbac_checks +from tests.unit_tests.model_factories import make_account class _AgentAppService: @@ -142,9 +143,7 @@ def _app_model(app_id: str = "app-1") -> App: def _account() -> Account: - account = Account(name="Sandbox Tester", email="sandbox-tester@example.com") - account.id = "account-1" - return account + return make_account(name="Sandbox Tester", email="sandbox-tester@example.com") @pytest.mark.parametrize( diff --git a/api/tests/unit_tests/controllers/console/app/test_annotation_api.py b/api/tests/unit_tests/controllers/console/app/test_annotation_api.py index 38ae25284e1..5094bb1fab9 100644 --- a/api/tests/unit_tests/controllers/console/app/test_annotation_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_annotation_api.py @@ -13,6 +13,7 @@ from controllers.console.app import annotation as annotation_module from models.account import Account from models.model import App, AppAnnotationHitHistory, AppMode, IconType, MessageAnnotation from services.app_ref_service import AnnotationRef, AppRef +from tests.unit_tests.model_factories import make_account def _persist_app(session: Session) -> App: @@ -33,9 +34,7 @@ def _persist_app(session: Session) -> App: def _account() -> Account: - account = Account(name="Owner", email="owner@example.com") - account.id = "account-1" - return account + return make_account(name="Owner", email="owner@example.com") def _annotation_model(annotation_id: str = "ann-1") -> MessageAnnotation: diff --git a/api/tests/unit_tests/controllers/console/app/test_annotation_security.py b/api/tests/unit_tests/controllers/console/app/test_annotation_security.py index 0aa896337e3..9d0f6c33cc5 100644 --- a/api/tests/unit_tests/controllers/console/app/test_annotation_security.py +++ b/api/tests/unit_tests/controllers/console/app/test_annotation_security.py @@ -21,12 +21,11 @@ from models.model import App, AppMode, IconType from services.annotation_service import AppAnnotationService from tasks.annotation.batch_import_annotations_task import batch_import_annotations_task from tests.unit_tests.config_override import config_overrides_context +from tests.unit_tests.model_factories import make_account def _account() -> Account: - account = Account(name="Annotation Tester", email="annotation-tester@example.com") - account.id = "user_id" - return account + return make_account(account_id="user_id", name="Annotation Tester", email="annotation-tester@example.com") def _persist_app(session: Session) -> App: diff --git a/api/tests/unit_tests/controllers/console/app/test_audio.py b/api/tests/unit_tests/controllers/console/app/test_audio.py index 1f8633923be..6e3efa6c985 100644 --- a/api/tests/unit_tests/controllers/console/app/test_audio.py +++ b/api/tests/unit_tests/controllers/console/app/test_audio.py @@ -50,6 +50,7 @@ from services.errors.audio import ( SpeechToTextDisabledServiceError, UnsupportedAudioTypeServiceError, ) +from tests.unit_tests.model_factories import make_account, make_app def _file_data(): @@ -57,22 +58,11 @@ def _file_data(): def _app(*, app_id: str = "a1", tenant_id: str = "tenant-1") -> App: - return App( - id=app_id, - tenant_id=tenant_id, - name="Audio app", - description="", - mode=AppMode.CHAT, - enable_site=True, - enable_api=True, - max_active_requests=0, - ) + return make_app(app_id=app_id, tenant_id=tenant_id, name="Audio app", icon_type=None, max_active_requests=0) def _account(account_id: str = "account-1") -> Account: - account = Account(name="Audio account", email=f"{account_id}@example.com") - account.id = account_id - return account + return make_account(account_id=account_id, name="Audio account", email=f"{account_id}@example.com") def test_console_audio_api_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/api/tests/unit_tests/controllers/console/app/test_conversation_api.py b/api/tests/unit_tests/controllers/console/app/test_conversation_api.py index c43af0fde83..8425c9560de 100644 --- a/api/tests/unit_tests/controllers/console/app/test_conversation_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_conversation_api.py @@ -23,9 +23,10 @@ from models.enums import ( FeedbackRating, WorkflowRunTriggeredFrom, ) -from models.model import AppMode, Conversation, IconType +from models.model import AppMode, Conversation from models.workflow import WorkflowRun, WorkflowType from services.errors.conversation import ConversationNotExistsError +from tests.unit_tests.model_factories import make_app, make_conversation def _make_account() -> Account: @@ -35,30 +36,13 @@ def _make_account() -> Account: def _app(*, mode: AppMode = AppMode.CHAT) -> App: - return App( - id="app-1", - tenant_id="tenant-1", - name="Conversation app", - description="", - mode=mode, - icon_type=IconType.EMOJI, - icon="robot", - icon_background="#FFFFFF", - enable_site=True, - enable_api=True, - max_active_requests=None, - ) + return make_app(name="Conversation app", mode=mode) def _conversation(*, conversation_id: str = "c1", app_id: str = "app-1") -> Conversation: - conversation = Conversation( + return make_conversation( + conversation_id=conversation_id, app_id=app_id, - app_model_config_id=None, - model_provider=None, - override_model_configs=None, - model_id=None, - mode=AppMode.CHAT, - name="Conversation", inputs={}, introduction="", system_instruction="", @@ -66,11 +50,8 @@ def _conversation(*, conversation_id: str = "c1", app_id: str = "app-1") -> Conv status="normal", invoke_from=InvokeFrom.EXPLORE, from_source=ConversationFromSource.CONSOLE, - from_end_user_id=None, from_account_id="u1", ) - conversation.id = conversation_id - return conversation def test_completion_conversation_list_returns_paginated_result( diff --git a/api/tests/unit_tests/controllers/console/app/test_conversation_variables_api.py b/api/tests/unit_tests/controllers/console/app/test_conversation_variables_api.py index 07249da37c8..bfd030b2e7a 100644 --- a/api/tests/unit_tests/controllers/console/app/test_conversation_variables_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_conversation_variables_api.py @@ -14,23 +14,11 @@ from controllers.console.app import conversation_variables as conversation_varia from factories import variable_factory from graphon.variables.types import SegmentType from models import App, AppMode, ConversationVariable -from models.model import IconType +from tests.unit_tests.model_factories import make_app def _app() -> App: - return App( - id="app-1", - tenant_id="tenant-1", - name="Conversation variables app", - description="", - mode=AppMode.ADVANCED_CHAT, - icon_type=IconType.EMOJI, - icon="robot", - icon_background="#FFFFFF", - enable_site=True, - enable_api=True, - max_active_requests=None, - ) + return make_app(name="Conversation variables app", mode=AppMode.ADVANCED_CHAT) def test_get_conversation_variables_returns_paginated_response( diff --git a/api/tests/unit_tests/controllers/console/app/test_generator_api.py b/api/tests/unit_tests/controllers/console/app/test_generator_api.py index b028bd15761..184f0a3f988 100644 --- a/api/tests/unit_tests/controllers/console/app/test_generator_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_generator_api.py @@ -20,7 +20,8 @@ from controllers.console.app.generator import ( ) from core.errors.error import ProviderTokenNotInitError from models.model import App, AppMode -from models.workflow import Workflow, WorkflowType +from models.workflow import Workflow +from tests.unit_tests.model_factories import make_workflow def _persist_app(session: Session, *, tenant_id: str = "t1") -> App: @@ -44,14 +45,7 @@ def _model_config_payload(): def _workflow(graph_dict: dict[str, object]) -> Workflow: - return Workflow( - id="workflow-1", - tenant_id="t1", - app_id="app-1", - type=WorkflowType.WORKFLOW, - version=Workflow.VERSION_DRAFT, - graph=json.dumps(graph_dict), - ) + return make_workflow(workflow_id="workflow-1", tenant_id="t1", graph=graph_dict) def _install_workflow_service(monkeypatch: pytest.MonkeyPatch, workflow): 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 0f53d0de7d8..edac7a463c0 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 @@ -20,8 +20,9 @@ from controllers.console.wraps import RBACPermission, RBACResourceScope from models import Account from models.account import AccountStatus from models.enums import AppMCPServerStatus -from models.model import App, AppMCPServer, AppMode, IconType +from models.model import App, AppMCPServer from tests.unit_tests.config_override import config_overrides_context +from tests.unit_tests.model_factories import make_app def _app( @@ -31,19 +32,7 @@ def _app( name: str = "Demo App", description: str = "App description", ) -> App: - return App( - id=app_id, - tenant_id=tenant_id, - name=name, - description=description, - mode=AppMode.CHAT, - icon_type=IconType.EMOJI, - icon="robot", - icon_background="#FFFFFF", - enable_site=True, - enable_api=True, - max_active_requests=None, - ) + return make_app(app_id=app_id, tenant_id=tenant_id, name=name, description=description) def _server( 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 54163d65448..f9a9bc80d06 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 @@ -9,24 +9,12 @@ from flask import Flask from controllers.console.app import workflow as workflow_module from controllers.console.app.workflow import ConvertToWorkflowApi -from models import Account, App, AppMode -from models.model import IconType +from models import Account, App +from tests.unit_tests.model_factories import make_app def _app(app_id: str) -> App: - return App( - id=app_id, - tenant_id="tenant-1", - name=f"App {app_id}", - description="", - mode=AppMode.CHAT, - icon_type=IconType.EMOJI, - icon="robot", - icon_background="#FFFFFF", - enable_site=True, - enable_api=True, - max_active_requests=None, - ) + return make_app(app_id=app_id, name=f"App {app_id}") class TestConvertToWorkflowApi: diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_run_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_run_api.py index 2f9e597bcc1..7457dae7d80 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_run_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_run_api.py @@ -20,13 +20,13 @@ from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus from machinery.context import RequestContext from models import Account, App, AppMode from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom -from models.model import IconType from models.workflow import ( WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom, WorkflowRun, WorkflowType, ) +from tests.unit_tests.model_factories import make_account, make_app def _serialize_200_response(handler, payload: Any) -> Any: @@ -41,27 +41,14 @@ def _serialize_200_response(handler, payload: Any) -> Any: def _account(session: Session) -> Account: - account = Account(name="Alice", email="alice@example.com") - account.id = "account-1" + account = make_account(name="Alice", email="alice@example.com") session.add(account) session.commit() return account def _app() -> App: - return App( - id="app-1", - tenant_id="tenant-1", - name="Workflow run app", - description="", - mode=AppMode.WORKFLOW, - icon_type=IconType.EMOJI, - icon="robot", - icon_background="#FFFFFF", - enable_site=True, - enable_api=True, - max_active_requests=None, - ) + return make_app(name="Workflow run app", mode=AppMode.WORKFLOW) def _request_context(*, workspace_id: str = "tenant-1") -> RequestContext: @@ -153,13 +140,12 @@ def test_workflow_run_list_returns_frontend_history_contract( _account(sqlite_session) workflow_run = _workflow_run_summary(sqlite_session) workflow_runs = Mock() - workflow_runs.get_paginate_workflow_runs.return_value = { - "limit": 10, - "has_more": False, - "data": [workflow_run], - } + workflow_runs.get_paginate_workflow_runs.return_value = SimpleNamespace( + limit=10, + has_more=False, + data=[workflow_run], + ) _mock_application_services(monkeypatch, workflow_runs) - monkeypatch.setattr(db, "session", sqlite_session) request_context = _request_context() api = workflow_run_module.WorkflowRunListApi() @@ -168,6 +154,7 @@ def test_workflow_run_list_returns_frontend_history_contract( with app.test_request_context("/apps/app-1/workflow-runs?limit=10", method="GET"): payload = handler( api, + sqlite_session, workflow_run_module.WorkflowRunListQuery(limit=10), request_context, app_model=_app(), @@ -208,13 +195,12 @@ def test_advanced_chat_workflow_run_list_keeps_message_fields( message_id="message-1", ) workflow_runs = Mock() - workflow_runs.get_paginate_advanced_chat_workflow_runs.return_value = { - "limit": 1, - "has_more": True, - "data": [workflow_run], - } + workflow_runs.get_paginate_advanced_chat_workflow_runs.return_value = SimpleNamespace( + limit=1, + has_more=True, + data=[workflow_run], + ) _mock_application_services(monkeypatch, workflow_runs) - monkeypatch.setattr(db, "session", sqlite_session) request_context = _request_context() api = workflow_run_module.AdvancedChatAppWorkflowRunListApi() @@ -223,6 +209,7 @@ def test_advanced_chat_workflow_run_list_keeps_message_fields( with app.test_request_context("/apps/app-1/advanced-chat/workflow-runs?limit=1", method="GET"): payload = handler( api, + sqlite_session, workflow_run_module.WorkflowRunListQuery(limit=1), request_context, app_model=_app(), @@ -288,14 +275,13 @@ def test_workflow_run_detail_returns_frontend_detail_contract( workflow_runs = Mock() workflow_runs.get_workflow_run.return_value = workflow_run _mock_application_services(monkeypatch, workflow_runs) - monkeypatch.setattr(db, "session", sqlite_session) request_context = _request_context() api = workflow_run_module.WorkflowRunDetailApi() handler = unwrap(api.get) with app.test_request_context("/apps/app-1/workflow-runs/run-1", method="GET"): - payload = handler(api, request_context, app_model=_app(), run_id="run-1") + payload = handler(api, sqlite_session, request_context, app_model=_app(), run_id="run-1") response = _serialize_200_response(api.get, payload) @@ -324,7 +310,9 @@ def test_workflow_run_detail_returns_frontend_detail_contract( ) -def test_workflow_run_detail_maps_missing_run_to_not_found(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: +def test_workflow_run_detail_maps_missing_run_to_not_found( + app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: workflow_runs = Mock() workflow_runs.get_workflow_run.return_value = None _mock_application_services(monkeypatch, workflow_runs) @@ -334,7 +322,7 @@ def test_workflow_run_detail_maps_missing_run_to_not_found(app: Flask, monkeypat with app.test_request_context("/apps/app-1/workflow-runs/run-1", method="GET"): with pytest.raises(NotFoundError, match="Workflow run not found"): - handler(api, request_context, app_model=_app(), run_id="run-1") + handler(api, sqlite_session, request_context, app_model=_app(), run_id="run-1") workflow_runs.get_workflow_run.assert_called_once_with( request_context, @@ -353,7 +341,7 @@ def test_workflow_run_node_executions_return_frontend_trace_contract( node_execution_response_source(execution, session=sqlite_session) ] _mock_application_services(monkeypatch, workflow_runs) - monkeypatch.setattr(db, "session", sqlite_session) + monkeypatch.setattr(db, "session", lambda: sqlite_session) request_context = _request_context() api = workflow_run_module.WorkflowRunNodeExecutionListApi() diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py index 63a61586ef9..c1cbbaa8682 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py @@ -30,14 +30,13 @@ from graphon.model_runtime.errors.validate import CredentialsValidateFailedError from models.account import Account from services.datasource_provider_service import DatasourceProviderService from services.plugin.oauth_service import OAuthProxyService +from tests.unit_tests.model_factories import make_account _PROVIDER_ID = "langgenius/notion_datasource/notion" def _account() -> Account: - account = Account(name="Datasource Auth Tester", email="datasource-auth@example.com") - account.id = "user-1" - return account + return make_account(account_id="user-1", name="Datasource Auth Tester", email="datasource-auth@example.com") def _i18n(text: str) -> dict[str, str]: 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 5ef83ea902f..53445379c5c 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 @@ -29,6 +29,7 @@ from services.errors.account import NoPermissionError from services.errors.rag_pipeline import RagPipelineResourceNotFoundError from tests.unit_tests.config_override import config_overrides_context from tests.unit_tests.controllers.rbac_introspection import rbac_checks +from tests.unit_tests.model_factories import make_account def _template_item() -> dict[str, object]: @@ -63,9 +64,7 @@ def _payload() -> dict[str, object]: def _account() -> Account: - account = Account(name="Test User", email="test@example.com") - account.id = "account-1" - return account + return make_account(name="Test User", email="test@example.com") def _pipeline() -> Pipeline: diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_datasets.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_datasets.py index 3e2c9cefd04..a892ad820fb 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_datasets.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_datasets.py @@ -19,12 +19,16 @@ from controllers.console.datasets.rag_pipeline.rag_pipeline_datasets import ( ) from models.account import Account, TenantAccountRole from services.entities.dsl_entities import ImportStatus +from tests.unit_tests.model_factories import make_account def _account(*, editor: bool) -> Account: - account = Account(name="RAG Pipeline Tester", email="rag-pipeline@example.com") - account.role = TenantAccountRole.EDITOR if editor else TenantAccountRole.NORMAL - return account + return make_account( + account_id=None, + name="RAG Pipeline Tester", + email="rag-pipeline@example.com", + role=TenantAccountRole.EDITOR if editor else TenantAccountRole.NORMAL, + ) class TestCreateRagPipelineDatasetApi: diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py index 33ff6a39a81..c5f43180cee 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py @@ -32,6 +32,7 @@ from models.account import Account, TenantAccountRole from models.dataset import Pipeline from models.workflow import Workflow, WorkflowDraftVariable, WorkflowType from tests.unit_tests.controllers.rbac_introspection import rbac_checks +from tests.unit_tests.model_factories import make_workflow @pytest.fixture @@ -48,17 +49,12 @@ def _pipeline(*, pipeline_id: str = "p1", tenant_id: str = "t1") -> Pipeline: def _workflow(pipeline: Pipeline, environment_variables=()) -> Workflow: - return Workflow.new( + return make_workflow( tenant_id=pipeline.tenant_id, app_id=pipeline.id, - type=WorkflowType.RAG_PIPELINE, - version=Workflow.VERSION_DRAFT, + workflow_type=WorkflowType.RAG_PIPELINE, graph="{}", - features="{}", - created_by="account-1", environment_variables=environment_variables, - conversation_variables=[], - rag_pipeline_variables=[], ) diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_import.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_import.py index c2e80c24bd5..9875ef27c1b 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_import.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_import.py @@ -24,12 +24,11 @@ from models.dataset import Pipeline from models.engine import db from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus from services.rag_pipeline.rag_pipeline_dsl_service import RagPipelineImportInfo +from tests.unit_tests.model_factories import make_account def _account() -> Account: - account = Account(name="RAG Import Tester", email="rag-import@example.com") - account.id = "account-1" - return account + return make_account(name="RAG Import Tester", email="rag-import@example.com") @pytest.fixture 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 f4a2e2c948f..294e0c04f35 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 @@ -25,7 +25,7 @@ from controllers.console.datasets.rag_pipeline.rag_pipeline_workflow import ( WorkflowUpdatePayload, ) from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError -from models.account import Account, Tenant, TenantAccountRole +from models.account import Account, TenantAccountRole from models.dataset import Dataset, Pipeline from models.engine import db from models.enums import PermissionEnum @@ -35,6 +35,7 @@ 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 +from tests.unit_tests.model_factories import make_account, make_dataset, make_tenant DEFAULT_WORKFLOW_TENANT_ID = "00000000-0000-0000-0000-000000000001" DEFAULT_WORKFLOW_APP_ID = "00000000-0000-0000-0000-000000000002" @@ -68,13 +69,13 @@ def _make_workflow(**overrides: object) -> Workflow: def _account() -> Account: - account = Account(name="Alice", email="alice@example.com") - account.id = DEFAULT_WORKFLOW_CREATED_BY - account.role = TenantAccountRole.EDITOR - tenant = Tenant(name="Tenant") - tenant.id = DEFAULT_WORKFLOW_TENANT_ID - account._current_tenant = tenant - return account + return make_account( + account_id=DEFAULT_WORKFLOW_CREATED_BY, + name="Alice", + email="alice@example.com", + role=TenantAccountRole.EDITOR, + tenant=make_tenant(tenant_id=DEFAULT_WORKFLOW_TENANT_ID, name="Tenant"), + ) def _pipeline() -> Pipeline: @@ -84,10 +85,9 @@ def _pipeline() -> Pipeline: def _dataset(*, tenant_id: str = DEFAULT_WORKFLOW_TENANT_ID, maintainer: str = DEFAULT_WORKFLOW_CREATED_BY) -> Dataset: - return Dataset( - id=DEFAULT_DATASET_ID, + return make_dataset( + dataset_id=DEFAULT_DATASET_ID, tenant_id=tenant_id, - name="Dataset", created_by=maintainer, maintainer=maintainer, permission=PermissionEnum.ONLY_ME, @@ -129,7 +129,7 @@ def test_draft_rag_pipeline_workflow_get_serializes_response_model() -> None: api = module.DraftRagPipelineApi() handler = unwrap_all(api.get) - response = handler(api, _pipeline()) + response = handler(api, db.session(), _pipeline()) assert response["id"] == DEFAULT_WORKFLOW_ID assert response["graph"] == {"nodes": [], "edges": []} diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow_apis.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow_apis.py index 46009877a09..fd26d0ec33f 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow_apis.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow_apis.py @@ -255,7 +255,7 @@ def workflow_author(sqlite_database: scoped_session[Session]) -> Account: class TestDraftWorkflowApi: - def test_get_draft_success(self, app: Flask, workflow_author: Account) -> None: + def test_get_draft_success(self, app: Flask, workflow_author: Account, sqlite_session: Session) -> None: api = DraftRagPipelineApi() method = unwrap(api.get) @@ -272,7 +272,7 @@ class TestDraftWorkflowApi: return_value=service, ), ): - result = method(api, pipeline) + result = method(api, sqlite_session, pipeline) assert result["id"] == "workflow-1" assert result["graph"] == {"nodes": [], "edges": []} @@ -285,7 +285,7 @@ class TestDraftWorkflowApi: } assert result["updated_by"] is None - def test_get_draft_not_exist(self, app: Flask) -> None: + def test_get_draft_not_exist(self, app: Flask, sqlite_session: Session) -> None: api = DraftRagPipelineApi() method = unwrap(api.get) @@ -301,7 +301,7 @@ class TestDraftWorkflowApi: ), ): with pytest.raises(DraftWorkflowNotExist): - method(api, pipeline) + method(api, sqlite_session, pipeline) def test_sync_hash_not_match(self, app: Flask) -> None: api = DraftRagPipelineApi() diff --git a/api/tests/unit_tests/controllers/console/datasets/test_data_source_notion_apis.py b/api/tests/unit_tests/controllers/console/datasets/test_data_source_notion_apis.py index b96c09334b6..a0a7e37b3e3 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_data_source_notion_apis.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_data_source_notion_apis.py @@ -20,23 +20,20 @@ from controllers.console.datasets.data_source import ( ) from core.rag.index_processor.constant.index_type import IndexStructureType from models import Account, Dataset, Document -from models.dataset import DataSourceType, DocumentCreatedFrom +from models.dataset import DataSourceType +from tests.unit_tests.model_factories import make_dataset, make_document def _dataset() -> Dataset: - return Dataset(id="ds-1", tenant_id="tenant-1", name="Dataset", created_by="u1") + return make_dataset(dataset_id="ds-1", created_by="u1") def _document() -> Document: - return Document( - id="d1", - tenant_id="tenant-1", + return make_document( + document_id="d1", dataset_id="ds-1", - position=1, data_source_type=DataSourceType.NOTION_IMPORT, - batch="batch-1", name="Notion page", - created_from=DocumentCreatedFrom.WEB, created_by="u1", ) diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets_segments.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets_segments.py index 9b258aec8bc..00e4737228d 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets_segments.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets_segments.py @@ -37,6 +37,7 @@ from models.enums import PermissionEnum, SegmentStatus, SegmentType from models.model import UploadFile from services.errors.chunk import ChildChunkDeleteIndexError as ChildChunkDeleteIndexServiceError from services.errors.chunk import ChildChunkIndexingError as ChildChunkIndexingServiceError +from tests.unit_tests.model_factories import make_account, make_dataset, make_document, make_upload_file def _segment(): @@ -82,10 +83,9 @@ def _child_chunk(): def _account() -> Account: - account = Account(name="Dataset Editor", email="dataset-editor@example.com") - account.id = "u1" - account.role = TenantAccountRole.OWNER - return account + return make_account( + account_id="u1", name="Dataset Editor", email="dataset-editor@example.com", role=TenantAccountRole.OWNER + ) def _dataset( @@ -96,10 +96,9 @@ def _dataset( embedding_model_provider: str | None = None, embedding_model: str | None = None, ) -> Dataset: - return Dataset( - id=dataset_id, + return make_dataset( + dataset_id=dataset_id, tenant_id=tenant_id, - name="Dataset", description="", provider="vendor", permission=PermissionEnum.ONLY_ME, @@ -117,14 +116,10 @@ def _document( tenant_id: str = "tenant-1", doc_form: IndexStructureType = IndexStructureType.PARAGRAPH_INDEX, ) -> Document: - return Document( - id=document_id, + return make_document( + document_id=document_id, tenant_id=tenant_id, dataset_id=dataset_id, - position=1, - data_source_type="upload_file", - batch="batch-1", - name="Document", created_from="api", created_by="u1", doc_form=doc_form, @@ -132,21 +127,17 @@ def _document( def _upload_file(*, name: str = "test.csv", file_id: str = "test-file-id") -> UploadFile: - upload_file = UploadFile( - tenant_id="tenant-1", + return make_upload_file( + file_id=file_id, storage_type="opendal", key="test-key", name=name, size=0, extension=name.rsplit(".", maxsplit=1)[-1], mime_type="text/csv", - created_by_role="account", created_by="u1", created_at=datetime.now(), - used=False, ) - upload_file.id = file_id - return upload_file class SQLiteControllerTest: diff --git a/api/tests/unit_tests/controllers/console/explore/test_trial.py b/api/tests/unit_tests/controllers/console/explore/test_trial.py index 03d93348382..dc7d3ac12c3 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_trial.py +++ b/api/tests/unit_tests/controllers/console/explore/test_trial.py @@ -49,6 +49,7 @@ from services.app_ref_service import AppRef, MessageRef from services.errors.audio import SpeechToTextDisabledServiceError from services.errors.conversation import ConversationNotExistsError from services.errors.llm import InvokeRateLimitError +from tests.unit_tests.model_factories import make_app, make_upload_file unwrap: Any = inspect_unwrap @@ -80,32 +81,19 @@ def trial_app_usage(monkeypatch: pytest.MonkeyPatch) -> MagicMock: def _app(*, app_id: str, mode: AppMode, tenant_id: str = "tenant-1") -> App: - return App( - id=app_id, - tenant_id=tenant_id, - name="Trial App", - mode=mode, - enable_site=True, - enable_api=False, - ) + return make_app(app_id=app_id, tenant_id=tenant_id, name="Trial App", mode=mode, icon_type=None, enable_api=False) def _upload_file(*, file_id: str = "upload-file-id", tenant_id: str = "app-tenant-id") -> UploadFile: - upload_file = UploadFile( + return make_upload_file( + file_id=file_id, tenant_id=tenant_id, storage_type="opendal", key="trial/file.txt", name="file.txt", size=1, - extension="txt", - mime_type="text/plain", - created_by_role="account", created_by="u1", - created_at=datetime(2024, 1, 1), - used=False, ) - upload_file.id = file_id - return upload_file def _file_data() -> Any: diff --git a/api/tests/unit_tests/controllers/console/explore/test_wraps.py b/api/tests/unit_tests/controllers/console/explore/test_wraps.py index 53f87e22c5f..c3f104de1a6 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/explore/test_wraps.py @@ -21,7 +21,8 @@ from controllers.console.explore.wraps import ( trial_feature_enable, user_allowed_to_access_app, ) -from models import Account, AccountTrialAppRecord, App, AppMode, InstalledApp, TrialApp +from models import Account, AccountTrialAppRecord, App, InstalledApp, TrialApp +from tests.unit_tests.model_factories import make_account, make_app def _bind_database(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: @@ -31,22 +32,11 @@ def _bind_database(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> def _account(*, account_id: str | None = None) -> Account: - account = Account(name="Explore user", email="user@example.com") - if account_id is not None: - account.id = account_id - return account + return make_account(account_id=account_id, name="Explore user", email="user@example.com") def _app() -> App: - app = App( - tenant_id=str(uuid4()), - name="Explore App", - mode=AppMode.CHAT, - enable_site=True, - enable_api=True, - ) - app.id = str(uuid4()) - return app + return make_app(app_id=str(uuid4()), tenant_id=str(uuid4()), name="Explore App", icon_type=None) def _installed_app(*, app_id: str, tenant_id: str) -> InstalledApp: 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 8af0e633620..6d762791958 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 @@ -15,13 +15,16 @@ from werkzeug.exceptions import HTTPException, NotFound from controllers.console.snippets import snippet_workflow as snippet_workflow_module from models.account import Account, TenantAccountRole from models.snippet import CustomizedSnippet +from tests.unit_tests.model_factories import make_account def _account(account_id: str = "account-1") -> Account: - account = Account(name="Test User", email=f"{account_id}@example.com") - account.id = account_id - account.role = TenantAccountRole.EDITOR - return account + return make_account( + account_id=account_id, + name="Test User", + email=f"{account_id}@example.com", + role=TenantAccountRole.EDITOR, + ) def _snippet(**overrides) -> CustomizedSnippet: @@ -641,7 +644,9 @@ def test_delete_published_snippet_workflow_raises_bad_request_when_in_use( assert exc_info.value.code == 400 -def test_workflow_run_detail_raises_not_found_when_run_missing(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: +def test_workflow_run_detail_raises_not_found_when_run_missing( + app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: snippet = _snippet() monkeypatch.setattr( snippet_workflow_module, @@ -654,7 +659,7 @@ def test_workflow_run_detail_raises_not_found_when_run_missing(app: Flask, monke with app.test_request_context("/snippets/snippet-1/workflow-runs/run-1"): with pytest.raises(NotFound, match="Workflow run not found"): - handler(api, snippet=snippet, run_id="run-1") + handler(api, sqlite_session, snippet=snippet, run_id="run-1") def test_draft_node_last_run_raises_not_found_when_execution_missing( 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 d40b84bd4dd..41aa923a471 100644 --- a/api/tests/unit_tests/controllers/console/tag/test_tags.py +++ b/api/tests/unit_tests/controllers/console/tag/test_tags.py @@ -20,7 +20,7 @@ from controllers.console.tag.tags import ( ) from machinery.context import RequestContext from models import Account -from models.account import AccountStatus, TenantAccountRole +from models.account import TenantAccountRole from models.enums import TagType from services.tag_application_service import ( TagApplicationError, @@ -32,6 +32,7 @@ from services.tag_application_service import ( UpdateTagInput, ) from tests.unit_tests.config_override import config_overrides_context +from tests.unit_tests.model_factories import make_account def unwrap(func): @@ -58,14 +59,7 @@ def request_context() -> RequestContext: def _account(role: TenantAccountRole) -> Account: - account = Account( - name="Tag User", - email=f"{role.value}@example.com", - status=AccountStatus.ACTIVE, - ) - account.id = "user-1" - account.role = role - return account + return make_account(account_id="user-1", name="Tag User", email=f"{role.value}@example.com", role=role) @pytest.fixture diff --git a/api/tests/unit_tests/controllers/console/test_files.py b/api/tests/unit_tests/controllers/console/test_files.py index e5db1174b83..bf2b3da2671 100644 --- a/api/tests/unit_tests/controllers/console/test_files.py +++ b/api/tests/unit_tests/controllers/console/test_files.py @@ -1,5 +1,4 @@ import io -from datetime import datetime from unittest.mock import patch import pytest @@ -22,12 +21,11 @@ from controllers.console.files import ( FileSupportTypeApi, upload_file_from_request, ) -from extensions.storage.storage_type import StorageType from machinery.context import RequestContext from models import Account from models.account import AccountStatus, TenantAccountRole -from models.enums import CreatorUserRole from models.model import UploadFile +from tests.unit_tests.model_factories import make_upload_file def unwrap(func): @@ -40,21 +38,13 @@ def unwrap(func): def _upload_file(*, file_id: str = "file-id-123", size: int = 1024) -> UploadFile: - upload_file = UploadFile( + return make_upload_file( + file_id=file_id, tenant_id="tenant-123", - storage_type=StorageType.LOCAL, key=f"upload/{file_id}/test.txt", - name="test.txt", size=size, - extension="txt", - mime_type="text/plain", - created_by_role=CreatorUserRole.ACCOUNT, created_by="user-123", - created_at=datetime(2024, 1, 1), - used=False, ) - upload_file.id = file_id - return upload_file def _request_context(*, workspace_id: str = "tenant-1") -> RequestContext: 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 39e8f089ce9..d799c634505 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 @@ -23,12 +23,12 @@ from controllers.console.human_input_form import ( from core.workflow.human_input_policy import HumanInputSurface from graphon.enums import WorkflowExecutionStatus, WorkflowType from models import Account -from models.account import AccountStatus 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 +from tests.unit_tests.model_factories import make_account, make_app @pytest.fixture(autouse=True) @@ -38,20 +38,15 @@ def bind_console_human_input_database(sqlite_engine: Engine, monkeypatch: pytest def _account(*, account_id: str = "user-1") -> Account: - account = Account(name="Console User", email=f"{account_id}@example.com", status=AccountStatus.ACTIVE) - account.id = account_id - return account + return make_account(account_id=account_id, name="Console User", email=f"{account_id}@example.com") def _app() -> App: - return App( - id="app-1", + return make_app( tenant_id="t1", name="Human Input App", - description="", mode=AppMode.WORKFLOW, - enable_site=True, - enable_api=True, + icon_type=None, max_active_requests=0, ) 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 6348a2f8ce9..5b13c907f2d 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 @@ -37,13 +37,11 @@ _ENDPOINTS = [ WorkflowRunArchiveDownloadFileApi.get, ] from tests.unit_tests.config_override import apply_config_overrides +from tests.unit_tests.model_factories import make_account def _account(role: TenantAccountRole) -> Account: - account = Account(name="Test User", email="user@example.com") - account.id = "account-1" - account.role = role - return account + return make_account(name="Test User", email="user@example.com", role=role) def _original(method): diff --git a/api/tests/unit_tests/controllers/console/test_workspace_members.py b/api/tests/unit_tests/controllers/console/test_workspace_members.py index 2528b387623..0be530a33d6 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_members.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_members.py @@ -9,12 +9,11 @@ from controllers.console.workspace.error import InvalidMemberRoleError from controllers.console.workspace.members import MemberInviteEmailApi from enums import DeploymentEdition from models.account import Account, Tenant, TenantAccountRole +from tests.unit_tests.model_factories import make_tenant def _tenant() -> Tenant: - tenant = Tenant(name="Test Tenant") - tenant.id = "tenant-1" - return tenant + return make_tenant(name="Test Tenant") @pytest.fixture diff --git a/api/tests/unit_tests/controllers/console/workspace/test_agent_providers.py b/api/tests/unit_tests/controllers/console/workspace/test_agent_providers.py index f62a0818333..462d0e3bb32 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_agent_providers.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_agent_providers.py @@ -8,12 +8,11 @@ from controllers.console.workspace.agent_providers import ( AgentProviderListApi, ) from models.account import Account +from tests.unit_tests.model_factories import make_account def _account() -> Account: - account = Account(name="Agent Provider Tester", email="agent-provider@example.com") - account.id = "user1" - return account + return make_account(account_id="user1", name="Agent Provider Tester", email="agent-provider@example.com") class TestAgentProviderListApi: diff --git a/api/tests/unit_tests/controllers/console/workspace/test_load_balancing_config.py b/api/tests/unit_tests/controllers/console/workspace/test_load_balancing_config.py index cdfdcb0ca8c..e436a4e6393 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_load_balancing_config.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_load_balancing_config.py @@ -19,6 +19,7 @@ if not hasattr(builtins, "MethodView"): builtins.MethodView = MethodView # type: ignore[attr-defined] from models.account import Account, TenantAccountRole +from tests.unit_tests.model_factories import make_account @pytest.fixture @@ -57,10 +58,7 @@ def load_balancing_module(monkeypatch: pytest.MonkeyPatch): def _account(role: TenantAccountRole) -> Account: - account = Account(name="Owner", email="owner@example.com") - account.id = "account-1" - account.role = role - return account + return make_account(name="Owner", email="owner@example.com", role=role) def _prepare_context(module, monkeypatch: pytest.MonkeyPatch, role=TenantAccountRole.OWNER): diff --git a/api/tests/unit_tests/controllers/console/workspace/test_models.py b/api/tests/unit_tests/controllers/console/workspace/test_models.py index 9f49e97d3d2..38f4d21db0d 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_models.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_models.py @@ -28,12 +28,11 @@ from controllers.console.workspace.models import ( from graphon.model_runtime.entities.model_entities import ModelType from graphon.model_runtime.errors.validate import CredentialsValidateFailedError from models import Account +from tests.unit_tests.model_factories import make_account def _account() -> Account: - account = Account(name="Model User", email="model-user@example.com") - account.id = "u1" - return account + return make_account(account_id="u1", name="Model User", email="model-user@example.com") class TestDefaultModelApi: 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 ea0f79a851c..5424dc65634 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_plugin.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_plugin.py @@ -83,6 +83,7 @@ from models.account import ( TenantPluginInstallPermission, ) from tests.unit_tests.config_override import config_overrides_context +from tests.unit_tests.model_factories import make_account def _plugin_category_list_item(category: str = "tool") -> dict[str, Any]: @@ -358,10 +359,7 @@ def _expected_dynamic_option_dump() -> dict[str, Any]: def _account(role: TenantAccountRole = TenantAccountRole.OWNER) -> Account: - account = Account(name="Test User", email="u1@example.com") - account.id = "u1" - account.role = role - return account + return make_account(account_id="u1", name="Test User", email="u1@example.com", role=role) @pytest.fixture diff --git a/api/tests/unit_tests/controllers/console/workspace/test_rbac.py b/api/tests/unit_tests/controllers/console/workspace/test_rbac.py index fcb3d739d90..a6378b97286 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_rbac.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_rbac.py @@ -28,6 +28,7 @@ from controllers.console.workspace import rbac as rbac_mod from controllers.console.workspace.rbac import _RolesListQuery from enums import DeploymentEdition from models import Account +from tests.unit_tests.model_factories import make_account @pytest.fixture @@ -47,9 +48,7 @@ def _rbac_config(config_overrides) -> None: def _account() -> Account: - account = Account(name="RBAC User", email="rbac@example.com") - account.id = "acct-1" - return account + return make_account(account_id="acct-1", name="RBAC User", email="rbac@example.com") class TestCurrentIds: diff --git a/api/tests/unit_tests/controllers/console/workspace/test_snippets.py b/api/tests/unit_tests/controllers/console/workspace/test_snippets.py index 75dc6180e90..78943e87482 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_snippets.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_snippets.py @@ -14,6 +14,7 @@ from controllers.console.workspace import snippets as snippets_module from models.account import Account, TenantAccountRole from models.snippet import CustomizedSnippet from services.snippet_dsl_service import ImportStatus, SnippetImportInfo +from tests.unit_tests.model_factories import make_account @pytest.fixture(autouse=True) @@ -33,10 +34,12 @@ def _patch_snippet_service_factory( def _account(account_id: str = "account-1") -> Account: - account = Account(name="Test User", email=f"{account_id}@example.com") - account.id = account_id - account.role = TenantAccountRole.EDITOR - return account + return make_account( + account_id=account_id, + name="Test User", + email=f"{account_id}@example.com", + role=TenantAccountRole.EDITOR, + ) def _snippet(**overrides) -> CustomizedSnippet: diff --git a/api/tests/unit_tests/controllers/files/test_appdeploy_files.py b/api/tests/unit_tests/controllers/files/test_appdeploy_files.py index b9e7d6e289e..04623b8a2fc 100644 --- a/api/tests/unit_tests/controllers/files/test_appdeploy_files.py +++ b/api/tests/unit_tests/controllers/files/test_appdeploy_files.py @@ -12,7 +12,7 @@ from uuid import UUID import jwt import pytest from flask import Flask -from sqlalchemy import update +from sqlalchemy import null, update from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker @@ -775,7 +775,7 @@ def test_content_disposition_follows_the_inline_whitelist( @pytest.mark.usefixtures("sqlite_db", "stored_bytes") def test_content_downloads_a_file_with_no_recorded_mime_type(app: Flask, sqlite_session: Session) -> None: upload_file = _persist_upload_file(sqlite_session, owner_id="anyone") - sqlite_session.execute(update(UploadFile).where(UploadFile.id == upload_file.id).values(mime_type=None)) + sqlite_session.execute(update(UploadFile).where(UploadFile.id == upload_file.id).values(mime_type=null())) sqlite_session.commit() token = _content_token(file_id=upload_file.id, kind=FileKind.UPLOAD) 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 b0e60ed5f68..03deb873ba0 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 @@ -39,12 +39,11 @@ 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 +from tests.unit_tests.model_factories import make_tenant def _tenant() -> Tenant: - tenant = Tenant(name="Test Tenant") - tenant.id = "tenant-id" - return tenant + return make_tenant(tenant_id="tenant-id", name="Test Tenant") def _user() -> Account: 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 f5bb418d76a..040a0f13abc 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 @@ -16,14 +16,13 @@ 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 +from tests.unit_tests.model_factories import make_tenant MODULE = "controllers.inner_api.agent.files" def _tenant() -> Tenant: - tenant = Tenant(name="Test Workspace") - tenant.id = "tenant-1" - return tenant + return make_tenant(name="Test Workspace") def _raw[R](method: Callable[..., R]) -> Callable[..., R]: 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 f37e1acd971..f9eb3006bc5 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 @@ -50,7 +50,7 @@ def _agent_inner_auth() -> Generator[None]: yield -def _app() -> Flask: +def _flask_app() -> Flask: app = Flask(__name__) app.config["TESTING"] = True app.register_blueprint(inner_api_bp) @@ -75,7 +75,7 @@ def test_post_streams_plugin_compatible_envelope() -> None: patch("controllers.inner_api.agent.llm.AgentLLMInnerService.invoke", return_value=iter([chunk])), ): response = ( - _app() + _flask_app() .test_client() .post( "/inner/api/agent/llm/invoke", @@ -134,7 +134,7 @@ def test_post_preserves_prompt_messages_without_transport_validation() -> None: patch("controllers.inner_api.agent.llm.AgentLLMInnerService.invoke", return_value=iter([])), ): response = ( - _app() + _flask_app() .test_client() .post( "/inner/api/agent/llm/invoke", @@ -150,7 +150,7 @@ def test_post_preserves_prompt_messages_without_transport_validation() -> None: def test_post_rejects_invalid_body_before_model_resolution() -> None: with _agent_inner_auth(): response = ( - _app() + _flask_app() .test_client() .post( "/inner/api/agent/llm/invoke", @@ -176,7 +176,7 @@ def test_post_preserves_preflight_quota_failure() -> None: ), ): response = ( - _app() + _flask_app() .test_client() .post( "/inner/api/agent/llm/invoke", @@ -208,7 +208,7 @@ def test_post_preserves_stream_quota_failure() -> None: ), ): response = ( - _app() + _flask_app() .test_client() .post( "/inner/api/agent/llm/invoke", diff --git a/api/tests/unit_tests/controllers/mcp/test_mcp.py b/api/tests/unit_tests/controllers/mcp/test_mcp.py index 03679199403..0dd960de400 100644 --- a/api/tests/unit_tests/controllers/mcp/test_mcp.py +++ b/api/tests/unit_tests/controllers/mcp/test_mcp.py @@ -18,6 +18,7 @@ from models.engine import db from models.enums import EndUserType from models.model import App, AppAnnotationSetting, AppMCPServer, AppModelConfig, EndUser, IconType from models.workflow import Workflow, WorkflowType +from tests.unit_tests.model_factories import make_end_user @pytest.fixture @@ -127,14 +128,13 @@ def _app( def _end_user() -> EndUser: - end_user = EndUser( + return make_end_user( + end_user_id=str(uuid4()), tenant_id=_TENANT_ID, app_id=_APP_ID, - type=EndUserType.MCP, + end_user_type=EndUserType.MCP, session_id=_SERVER_ID, ) - end_user.id = str(uuid4()) - return end_user class DummyResult: 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 d61a38848f0..e77bb15c9cc 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_composition.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_composition.py @@ -157,7 +157,6 @@ def _selected_webapp_steps(*, scope, app_access_mode): ) data = AuthData( token_type=TokenType.OAUTH_ACCOUNT, - token_hash="x", scopes=frozenset({scope}) if scope is not None else frozenset(), app_access_mode=app_access_mode, ) 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 59776892903..8c3f1bc20ed 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py @@ -1,3 +1,4 @@ +import uuid from unittest.mock import patch from controllers.common.rbac import PlainApp, RBACCheck @@ -27,6 +28,9 @@ from models.account import TenantAccountRole from services.enterprise.enterprise_service import WebAppAccessMode from tests.unit_tests.config_override import config_overrides_context +PROBE_ID = uuid.uuid4() +OTHER_ID = uuid.uuid4() + def _ctx(token_type=TokenType.OAUTH_ACCOUNT, path_params=None, **kwargs): return RequestContext( @@ -37,7 +41,7 @@ def _ctx(token_type=TokenType.OAUTH_ACCOUNT, path_params=None, **kwargs): def _data(**kwargs): - defaults: dict = {"token_type": TokenType.OAUTH_ACCOUNT, "token_hash": "x", "scopes": frozenset()} + defaults: dict = {"token_type": TokenType.OAUTH_ACCOUNT, "scopes": frozenset()} defaults.update(kwargs) return AuthData(**defaults) @@ -89,9 +93,9 @@ def test_data_cond_returns_false_when_data_none(): def test_data_cond_evaluates_when_data_present(): - c = data_cond(lambda data: data.token_hash == "secret") - assert c(_ctx(), _data(token_hash="secret")) is True - assert c(_ctx(), _data(token_hash="other")) is False + c = data_cond(lambda data: data.token_id == PROBE_ID) + assert c(_ctx(), _data(token_id=PROBE_ID)) is True + assert c(_ctx(), _data(token_id=OTHER_ID)) is False def test_config_cond_ignores_ctx_and_data(): diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_data.py b/api/tests/unit_tests/controllers/openapi/auth/test_data.py index e5171e64303..73aed7bd615 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_data.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_data.py @@ -39,7 +39,6 @@ def test_request_context_scope_optional(): def test_auth_data_is_mutable(): data = AuthData( token_type=TokenType.OAUTH_ACCOUNT, - token_hash="abc", scopes=frozenset({Scope.FULL}), ) data.token_type = TokenType.OAUTH_EXTERNAL_SSO @@ -49,7 +48,6 @@ def test_auth_data_is_mutable(): def test_auth_data_path_params_defaults_empty(): data = AuthData( token_type=TokenType.OAUTH_ACCOUNT, - token_hash="abc", scopes=frozenset(), ) assert data.path_params == {} @@ -58,7 +56,6 @@ def test_auth_data_path_params_defaults_empty(): def test_auth_data_account_id_optional(): data = AuthData( token_type=TokenType.OAUTH_EXTERNAL_SSO, - token_hash="abc", scopes=frozenset({Scope.APPS_RUN}), external_identity=ExternalIdentity(email="u@sso.com"), ) @@ -69,25 +66,14 @@ def test_auth_data_external_identity_none_for_account(): data = AuthData( token_type=TokenType.OAUTH_ACCOUNT, account_id=uuid.uuid4(), - token_hash="abc", scopes=frozenset({Scope.FULL}), ) assert data.external_identity is None -def test_auth_data_tenants_default_empty(): - data = AuthData( - token_type=TokenType.OAUTH_ACCOUNT, - token_hash="abc", - scopes=frozenset(), - ) - assert data.tenants == {} - - def test_auth_data_token_id_optional(): data = AuthData( token_type=TokenType.OAUTH_ACCOUNT, - token_hash="abc", scopes=frozenset(), ) assert data.token_id is None @@ -119,7 +105,6 @@ def test_request_context_allowed_roles_set(): def test_auth_data_allowed_roles_default_none(): data = AuthData( token_type=TokenType.OAUTH_ACCOUNT, - token_hash="abc", scopes=frozenset(), ) assert data.allowed_roles is None @@ -131,7 +116,6 @@ def test_auth_data_allowed_roles_set(): roles = frozenset({TenantAccountRole.ADMIN}) data = AuthData( token_type=TokenType.OAUTH_ACCOUNT, - token_hash="abc", scopes=frozenset(), allowed_roles=roles, ) @@ -141,7 +125,6 @@ def test_auth_data_allowed_roles_set(): def test_auth_data_tenant_role_default_none(): data = AuthData( token_type=TokenType.OAUTH_ACCOUNT, - token_hash="abc", scopes=frozenset(), ) assert data.tenant_role is None @@ -152,7 +135,6 @@ def test_auth_data_tenant_role_set(): data = AuthData( token_type=TokenType.OAUTH_ACCOUNT, - token_hash="abc", scopes=frozenset(), tenant_role=TenantAccountRole.ADMIN, ) diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_flow.py b/api/tests/unit_tests/controllers/openapi/auth/test_flow.py index 3ea7ac2b125..1c87c3b6450 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_flow.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_flow.py @@ -11,7 +11,7 @@ def _ctx(): def _data(): - return AuthData(token_type=TokenType.OAUTH_ACCOUNT, token_hash="x", scopes=frozenset()) + return AuthData(token_type=TokenType.OAUTH_ACCOUNT, scopes=frozenset()) def test_applies_returns_true_when_condition_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 f483c80eb38..fc215fa4dd1 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py @@ -16,20 +16,16 @@ def _make_identity( token_type=TokenType.OAUTH_ACCOUNT, account_id=None, scopes=None, - token_hash="testhash", subject_email=None, subject_issuer=None, - verified_tenants=None, token_id=None, ): identity = MagicMock() identity.token_type = token_type identity.account_id = account_id or uuid.uuid4() identity.scopes = scopes or frozenset({Scope.FULL}) - identity.token_hash = token_hash identity.subject_email = subject_email identity.subject_issuer = subject_issuer - identity.verified_tenants = verified_tenants or {} identity.token_id = token_id or uuid.uuid4() return identity diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_prepare.py b/api/tests/unit_tests/controllers/openapi/auth/test_prepare.py index 96d823b178e..fa844520e3e 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_prepare.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_prepare.py @@ -21,10 +21,9 @@ from controllers.openapi.auth.prepare import ( from libs.oauth_bearer import TokenType from models import Account, App, EndUser, Tenant, TenantAccountJoin from models.account import AccountStatus, TenantAccountRole, TenantStatus -from models.enums import AppStatus -from models.model import AppMode, IconType from services import end_user_service from services.enterprise.enterprise_service import WebAppAccessMode +from tests.unit_tests.model_factories import make_account, make_app, make_tenant APP_ID = "00000000-0000-0000-0000-000000000001" TENANT_ID = "00000000-0000-0000-0000-000000000002" @@ -34,7 +33,6 @@ ACCOUNT_ID = "00000000-0000-0000-0000-000000000003" def _make_auth_data(**kwargs: object) -> AuthData: return AuthData( token_type=kwargs.pop("token_type", TokenType.OAUTH_ACCOUNT), - token_hash=kwargs.pop("token_hash", "testhash"), scopes=kwargs.pop("scopes", frozenset()), **kwargs, ) @@ -46,32 +44,15 @@ def _app( tenant_id: str = TENANT_ID, enable_api: bool = True, ) -> App: - return App( - id=app_id, - tenant_id=tenant_id, - name="OpenAPI app", - description="", - mode=AppMode.CHAT, - icon_type=IconType.EMOJI, - icon="robot", - icon_background="#FFFFFF", - status=AppStatus.NORMAL, - enable_site=True, - enable_api=enable_api, - max_active_requests=None, - ) + return make_app(app_id=app_id, tenant_id=tenant_id, name="OpenAPI app", enable_api=enable_api) def _tenant(*, tenant_id: str = TENANT_ID, status: TenantStatus = TenantStatus.NORMAL) -> Tenant: - tenant = Tenant(name="OpenAPI tenant", status=status) - tenant.id = tenant_id - return tenant + return make_tenant(tenant_id=tenant_id, name="OpenAPI tenant", status=status) def _account(*, status: AccountStatus = AccountStatus.ACTIVE) -> Account: - account = Account(name="OpenAPI account", email="account@example.com", status=status) - account.id = ACCOUNT_ID - return account + return make_account(account_id=ACCOUNT_ID, name="OpenAPI account", email="account@example.com", status=status) def _persist(session: Session, *models: object) -> None: diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_verify.py b/api/tests/unit_tests/controllers/openapi/auth/test_verify.py index 89c38c59e4d..baad1264abf 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_verify.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_verify.py @@ -10,7 +10,6 @@ from controllers.common.rbac import PlainApp, RBACCheck from controllers.openapi.auth.data import AuthData from controllers.openapi.auth.verify import ( check_acl, - check_app_access, check_app_api_enabled, check_private_app_permission, check_rbac_permission, @@ -32,7 +31,7 @@ def _rbac_config(config_overrides: Callable[..., None]) -> None: def _data(**kwargs) -> AuthData: - defaults: dict = {"token_type": TokenType.OAUTH_ACCOUNT, "token_hash": "hash", "scopes": frozenset({Scope.FULL})} + defaults: dict = {"token_type": TokenType.OAUTH_ACCOUNT, "scopes": frozenset({Scope.FULL})} defaults.update(kwargs) return AuthData(**defaults) @@ -63,27 +62,6 @@ def test_check_workspace_member_passes_when_role_present(): check_workspace_member(_data(tenant_role=TenantAccountRole.NORMAL)) -def test_check_app_access_passes_when_tenant_none(): - check_app_access(_data(tenant=None)) - - -def test_check_app_access_passes_when_member(): - tenant = Tenant(name="Test Tenant") - tenant.id = "t1" - data = _data(account_id=uuid.uuid4(), tenant=tenant) - with patch("controllers.openapi.auth.verify.TenantService.account_belongs_to_tenant", return_value=True): - check_app_access(data) - - -def test_check_app_access_raises_when_not_member(): - tenant = Tenant(name="Test Tenant") - tenant.id = "t1" - data = _data(account_id=uuid.uuid4(), tenant=tenant) - with patch("controllers.openapi.auth.verify.TenantService.account_belongs_to_tenant", return_value=False): - with pytest.raises(Forbidden, match="subject_no_app_access"): - check_app_access(data) - - # --- check_rbac_permission --- _RBAC_REQ = RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()) diff --git a/api/tests/unit_tests/controllers/openapi/conftest.py b/api/tests/unit_tests/controllers/openapi/conftest.py index 6d17dcaa39c..f77e35a3b7f 100644 --- a/api/tests/unit_tests/controllers/openapi/conftest.py +++ b/api/tests/unit_tests/controllers/openapi/conftest.py @@ -28,7 +28,6 @@ def _stub_execute( kwargs["auth_data"] = AuthData( token_type=TokenType.OAUTH_ACCOUNT, account_id=uuid.uuid4(), - token_hash="test", token_id=uuid.uuid4(), scopes=frozenset({Scope.FULL}), required_scope=scope, diff --git a/api/tests/unit_tests/controllers/openapi/test_app_payloads.py b/api/tests/unit_tests/controllers/openapi/test_app_payloads.py index c73e6f3f454..2f548714484 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_payloads.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_payloads.py @@ -13,23 +13,12 @@ from controllers.openapi.apps import ( # pyright: ignore[reportPrivateUsage] parameters_payload, ) from controllers.service_api.app.error import AppUnavailableError -from models.model import App, AppMode, IconType +from models.model import App, AppMode +from tests.unit_tests.model_factories import make_app def _app(*, mode: AppMode = AppMode.CHAT) -> App: - return App( - id="app1", - tenant_id="tenant-1", - name="X", - description="d", - mode=mode, - icon_type=IconType.EMOJI, - icon="robot", - icon_background="#FFFFFF", - enable_site=False, - enable_api=True, - max_active_requests=0, - ) + return make_app(app_id="app1", name="X", description="d", mode=mode, enable_site=False, max_active_requests=0) def test_parameters_payload_raises_app_unavailable_when_no_config(unbound_session: Session): diff --git a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py index 7672e6414fd..88af3d3ff8f 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py @@ -109,7 +109,6 @@ def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, bypass_pipel auth_data = AuthData.model_construct( token_type=TokenType.OAUTH_ACCOUNT, account_id=uuid.UUID(_TEST_ACCOUNT_ID), - token_hash="test", scopes=frozenset({Scope.FULL}), app=_make_app(), caller=_make_account(), diff --git a/api/tests/unit_tests/controllers/openapi/test_human_input_form.py b/api/tests/unit_tests/controllers/openapi/test_human_input_form.py index 28947c69b35..97590ad6dfe 100644 --- a/api/tests/unit_tests/controllers/openapi/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/openapi/test_human_input_form.py @@ -26,7 +26,6 @@ def _make_auth_data(app_model, caller, caller_kind): return AuthData.model_construct( token_type=TokenType.OAUTH_ACCOUNT, account_id=uuid.uuid4(), - token_hash="test", scopes=frozenset({Scope.FULL}), app=app_model, caller=caller, diff --git a/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py b/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py deleted file mode 100644 index 7b84911d788..00000000000 --- a/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Unit tests for PaginationEnvelope generic Pydantic model.""" - -from __future__ import annotations - -from pydantic import BaseModel - -from controllers.openapi._models import PaginationEnvelope - - -class _Row(BaseModel): - id: str - name: str - - -def test_envelope_basic_fields(): - env = PaginationEnvelope[_Row](page=1, limit=20, total=42, has_more=True, data=[_Row(id="a", name="A")]) - dumped = env.model_dump(mode="json") - assert dumped == { - "page": 1, - "limit": 20, - "total": 42, - "has_more": True, - "data": [{"id": "a", "name": "A"}], - } - - -def test_envelope_empty_data_no_more(): - env = PaginationEnvelope[_Row](page=1, limit=20, total=0, has_more=False, data=[]) - assert env.model_dump(mode="json")["data"] == [] - assert env.model_dump(mode="json")["has_more"] is False - - -def test_envelope_has_more_true_when_total_exceeds_page_window(): - env = PaginationEnvelope[_Row].build(page=1, limit=20, total=42, items=[_Row(id="a", name="A")]) - assert env.has_more is True - - -def test_envelope_has_more_false_when_total_within_page_window(): - env = PaginationEnvelope[_Row].build(page=2, limit=20, total=22, items=[_Row(id="a", name="A")]) - assert env.has_more is False - - -def test_envelope_has_more_false_for_last_page(): - env = PaginationEnvelope[_Row].build(page=3, limit=20, total=42, items=[_Row(id="a", name="A")]) - assert env.has_more is False - - -def test_max_page_limit_is_200(): - from controllers.openapi._models import MAX_PAGE_LIMIT - - assert MAX_PAGE_LIMIT == 200 - - -def test_envelope_uses_pep695_generics(): - """Verify the class uses PEP 695 native generic syntax (not legacy Generic[T]).""" - from controllers.openapi._models import PaginationEnvelope - - # PEP 695 syntax populates __type_params__; the legacy Generic[T] form does not. - assert PaginationEnvelope.__type_params__, "expected PEP 695 native generic syntax" - - fields = PaginationEnvelope.model_fields - assert {"page", "limit", "total", "has_more", "data"} <= set(fields) - - -def test_app_info_response_dump_matches_spec(): - from controllers.openapi._models import AppInfo - - obj = AppInfo( - id="app1", - name="X", - description="d", - mode="chat", - ) - assert obj.model_dump(mode="json") == { - "id": "app1", - "name": "X", - "description": "d", - "mode": "chat", - } - - -def test_app_describe_response_nests_info_and_parameters(): - from controllers.openapi._models import AppDescribeInfo, AppDescribeResponse - - info = AppDescribeInfo( - id="app1", - name="X", - mode="chat", - description=None, - updated_at="2026-05-05T00:00:00+00:00", - service_api_enabled=True, - ) - obj = AppDescribeResponse(info=info, parameters={"opening_statement": None}) - dumped = obj.model_dump(mode="json") - assert dumped["info"]["service_api_enabled"] is True - assert dumped["parameters"]["opening_statement"] is None - - -def test_response_models_dump_per_mode(): - from controllers.openapi._models import ( - ChatMessageResponse, - CompletionMessageResponse, - WorkflowRunData, - WorkflowRunResponse, - ) - - chat = ChatMessageResponse( - event="message", - task_id="t1", - id="m1", - message_id="m1", - conversation_id="c1", - mode="chat", - answer="hi", - created_at=0, - ) - assert chat.model_dump(mode="json")["mode"] == "chat" - wf = WorkflowRunResponse( - workflow_run_id="r1", - task_id="t1", - data=WorkflowRunData(id="r1", workflow_id="w1", status="succeeded"), - ) - assert wf.model_dump(mode="json")["data"]["status"] == "succeeded" - assert wf.model_dump(mode="json")["mode"] == "workflow" - comp = CompletionMessageResponse( - event="message", - task_id="t2", - id="m2", - message_id="m2", - mode="completion", - answer="ok", - created_at=0, - ) - assert comp.model_dump(mode="json")["mode"] == "completion" diff --git a/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py b/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py index bd57467a5bc..5a51a35268c 100644 --- a/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py +++ b/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py @@ -24,7 +24,6 @@ def _make_auth_data(app_model, caller, caller_kind): return AuthData.model_construct( token_type=TokenType.OAUTH_ACCOUNT, account_id=uuid.uuid4(), - token_hash="test", scopes=frozenset({Scope.FULL}), app=app_model, caller=caller, diff --git a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py index 33a726e9c41..9b998edff57 100644 --- a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py +++ b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py @@ -9,7 +9,7 @@ Coverage: Auth-pipeline plumbing is bypassed via the `bypass_pipeline` fixture from conftest.py; the bearer identity is seeded into the openapi auth ContextVar -via `_seed` (the slot `validate_bearer` publishes). Tests that exercise +via `_seed`. Tests that exercise endpoint *bodies* skip the single `guard_workspace` decorator via ``__wrapped__`` — membership and role enforcement live in the auth pipeline and are covered in `auth/test_prepare.py` and `auth/test_verify.py`. @@ -45,7 +45,7 @@ from controllers.openapi.workspaces import ( from enums import DeploymentEdition from libs.oauth_bearer import AuthContext, Scope, SubjectType, TokenType, reset_auth_ctx, set_auth_ctx from models import Account, Tenant, TenantAccountJoin -from models.account import AccountStatus, TenantAccountRole, TenantStatus +from models.account import TenantAccountRole, TenantStatus from services.account_service import TenantService as RealTenantService from services.errors.account import ( AccountAlreadyInTenantError, @@ -57,6 +57,7 @@ from services.errors.account import ( RoleAlreadyAssignedError, ) from tests.unit_tests.config_override import config_overrides_context +from tests.unit_tests.model_factories import make_account, make_tenant if not hasattr(builtins, "MethodView"): builtins.MethodView = MethodView # type: ignore[attr-defined] @@ -112,8 +113,6 @@ def _auth_ctx(account_id: uuid.UUID | None = None) -> AuthContext: token_id=uuid.uuid4(), token_type=TokenType.OAUTH_ACCOUNT, expires_at=datetime.now(UTC), - token_hash="h", - verified_tenants={}, ) @@ -124,20 +123,16 @@ def _auth_data(account_id: uuid.UUID) -> AuthData: return AuthData( token_type=TokenType.OAUTH_ACCOUNT, account_id=account_id, - token_hash="testhash", scopes=frozenset({Scope.FULL}), ) def _account(account_id: str = "acct-1", email: str = "u@example.com") -> Account: - account = Account(name="User", email=email, status=AccountStatus.ACTIVE) - account.id = account_id - return account + return make_account(account_id=account_id, name="User", email=email) def _tenant(tenant_id: str = "ws-1", *, status: TenantStatus = TenantStatus.NORMAL) -> Tenant: - tenant = Tenant(name="WS", status=status) - tenant.id = tenant_id + tenant = make_tenant(tenant_id=tenant_id, name="WS", status=status) tenant.created_at = datetime(2026, 5, 18) return tenant 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 9e51ffea837..325c5d33f77 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 @@ -34,7 +34,7 @@ from controllers.service_api.app.error import ( from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from graphon.model_runtime.errors.invoke import InvokeError from models.enums import EndUserType -from models.model import App, AppMode, EndUser +from models.model import App, EndUser from services.app_ref_service import AppRef, MessageRef from services.audio_service import AudioService from services.errors.app_model_config import AppModelConfigBrokenError @@ -45,6 +45,7 @@ from services.errors.audio import ( SpeechToTextDisabledServiceError, UnsupportedAudioTypeServiceError, ) +from tests.unit_tests.model_factories import make_app, make_end_user def _file_data(): @@ -52,24 +53,14 @@ def _file_data(): def _app(*, app_id: str = "a1", tenant_id: str = "tenant-1") -> App: - return App( - id=app_id, - tenant_id=tenant_id, - name="Audio app", - description="", - mode=AppMode.CHAT, - enable_site=True, - enable_api=True, - max_active_requests=0, - ) + return make_app(app_id=app_id, tenant_id=tenant_id, name="Audio app", icon_type=None, max_active_requests=0) def _end_user(*, end_user_id: str = "u1", external_user_id: str | None = None) -> EndUser: - return EndUser( - id=end_user_id, - tenant_id="tenant-1", + return make_end_user( + end_user_id=end_user_id, app_id="a1", - type=EndUserType.SERVICE_API, + end_user_type=EndUserType.SERVICE_API, external_user_id=external_user_id, name="Audio user", session_id=f"session-{end_user_id}", 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 458c8be166d..35b9479065a 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 @@ -53,6 +53,7 @@ from services.errors.conversation import ( ConversationVariableNotExistsError, ConversationVariableTypeMismatchError, ) +from tests.unit_tests.model_factories import make_conversation def _app(*, app_id: str = "app-1", mode: AppMode = AppMode.CHAT) -> App: @@ -86,17 +87,15 @@ def _conversation( app_id: str = "app-1", end_user_id: str = "end-user-1", ) -> Conversation: - conversation = Conversation( + return make_conversation( + conversation_id=conversation_id, app_id=app_id, - mode=AppMode.CHAT, name="Original Name", + inputs={}, from_source=ConversationFromSource.API, from_end_user_id=end_user_id, invoke_from=InvokeFrom.SERVICE_API, ) - conversation.id = conversation_id - conversation.inputs = {} - return conversation class TestConversationListQuery: diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_segment.py b/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_segment.py index ace817a4bf3..56aefeda1c7 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_segment.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_segment.py @@ -43,6 +43,7 @@ from models.dataset import ChildChunk, Dataset, Document, DocumentSegment, Docum from models.enums import IndexingStatus, SegmentType from services.api_token_service import CachedApiToken from services.dataset_service import DocumentService, SegmentService +from tests.unit_tests.model_factories import make_account def _segment_response_dict(summary: str | None = None): @@ -85,9 +86,9 @@ def mock_tenant() -> Tenant: def _account() -> Account: - account = Account(name="Segment API User", email=f"segment-api-{uuid.uuid4()}@example.com") - account.id = str(uuid.uuid4()) - return account + return make_account( + account_id=str(uuid.uuid4()), name="Segment API User", email=f"segment-api-{uuid.uuid4()}@example.com" + ) def _api_token(tenant_id: str) -> CachedApiToken: diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py index efb38d0e340..8ea26e001f8 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py @@ -49,12 +49,10 @@ from controllers.service_api.dataset.document import ( from controllers.service_api.dataset.error import ArchivedDocumentImmutableError from core.rag.index_processor.constant.index_type import IndexStructureType from enums import DeploymentEdition -from extensions.storage.storage_type import StorageType from models.account import Account from models.dataset import Dataset, Document, DocumentSegment from models.enums import ( ApiTokenType, - CreatorUserRole, DataSourceType, DocumentCreatedFrom, DocumentDocType, @@ -67,6 +65,7 @@ from services.dataset_service import DocumentService from services.entities.knowledge_entities.knowledge_entities import ProcessRule, RetrievalModel from services.errors.file import FileTooLargeError as FileTooLargeServiceError from tests.unit_tests.config_override import config_overrides_context +from tests.unit_tests.model_factories import make_account, make_upload_file def _document_data_source_info() -> dict[str, str]: @@ -74,27 +73,17 @@ def _document_data_source_info() -> dict[str, str]: def _account() -> Account: - account = Account(name="Document API User", email=f"document-api-{uuid.uuid4()}@example.com") - account.id = "user-1" - return account + return make_account(account_id="user-1", name="Document API User", email=f"document-api-{uuid.uuid4()}@example.com") def _upload_file() -> UploadFile: - upload_file = UploadFile( - tenant_id="tenant-1", - storage_type=StorageType.LOCAL, + return make_upload_file( key="documents/file.txt", name="file.txt", - size=10, - extension="txt", - mime_type="text/plain", - created_by_role=CreatorUserRole.ACCOUNT, created_by="user-1", created_at=datetime.now(UTC), used=True, ) - upload_file.id = str(uuid.uuid4()) - return upload_file def _unwrap_non_wrapped_controller(view): diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_rag_pipeline_file_upload_serialization.py b/api/tests/unit_tests/controllers/service_api/dataset/test_rag_pipeline_file_upload_serialization.py index 9e09c8d8158..aa2b1f644af 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_rag_pipeline_file_upload_serialization.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_rag_pipeline_file_upload_serialization.py @@ -5,28 +5,20 @@ Unit tests for Service API knowledge pipeline file-upload serialization. from datetime import UTC, datetime from controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow import PipelineUploadFileResponse -from extensions.storage.storage_type import StorageType from libs.helper import dump_response -from models.enums import CreatorUserRole from models.model import UploadFile +from tests.unit_tests.model_factories import make_upload_file def _upload_file(*, created_at: datetime) -> UploadFile: - upload_file = UploadFile( - tenant_id="tenant-1", - storage_type=StorageType.LOCAL, - key="uploads/file-1", + return make_upload_file( + file_id="file-1", name="test.pdf", size=123, extension="pdf", mime_type="application/pdf", - created_by_role=CreatorUserRole.ACCOUNT, - created_by="account-1", created_at=created_at, - used=False, ) - upload_file.id = "file-1" - return upload_file def test_file_upload_created_at_is_isoformat_string(): diff --git a/api/tests/unit_tests/controllers/service_api/test_trace_session_id_parsing.py b/api/tests/unit_tests/controllers/service_api/test_trace_session_id_parsing.py index 89766162a1d..05fcc5e400d 100644 --- a/api/tests/unit_tests/controllers/service_api/test_trace_session_id_parsing.py +++ b/api/tests/unit_tests/controllers/service_api/test_trace_session_id_parsing.py @@ -9,6 +9,7 @@ from controllers.service_api.app import workflow as workflow_module from core.helper.trace_id_helper import get_trace_session_id from models.enums import EndUserType from models.model import App, AppMode, EndUser +from tests.unit_tests.model_factories import make_app, make_end_user class _Request: @@ -41,16 +42,11 @@ def test_trace_session_id_invalid_highest_priority_raises_bad_request(): def _app(mode: AppMode) -> App: - return App(id="app-1", mode=mode, tenant_id="tenant-1") + return make_app(mode=mode, icon_type=None) def _end_user() -> EndUser: - return EndUser( - id="user-1", - tenant_id="tenant-1", - type=EndUserType.SERVICE_API, - session_id="session-1", - ) + return make_end_user(end_user_id="user-1", end_user_type=EndUserType.SERVICE_API) def _assert_generate_trace_session_id(mock_generate_service: MagicMock, expected: str) -> None: diff --git a/api/tests/unit_tests/controllers/trigger/test_webhook.py b/api/tests/unit_tests/controllers/trigger/test_webhook.py index 7cfd0306ff2..087922554ae 100644 --- a/api/tests/unit_tests/controllers/trigger/test_webhook.py +++ b/api/tests/unit_tests/controllers/trigger/test_webhook.py @@ -8,6 +8,7 @@ import controllers.trigger.webhook as module from models.trigger import WorkflowWebhookTrigger from models.workflow import Workflow from services.errors.app import QuotaExceededError +from tests.unit_tests.model_factories import make_workflow @pytest.fixture(autouse=True) @@ -35,7 +36,7 @@ def _webhook_trigger() -> WorkflowWebhookTrigger: def _workflow() -> Workflow: - return Workflow(id="workflow-1") + return make_workflow(workflow_id="workflow-1") class TestPrepareWebhookExecution: diff --git a/api/tests/unit_tests/controllers/web/test_audio.py b/api/tests/unit_tests/controllers/web/test_audio.py index 10dc7a3429f..78f40d6dcb9 100644 --- a/api/tests/unit_tests/controllers/web/test_audio.py +++ b/api/tests/unit_tests/controllers/web/test_audio.py @@ -22,7 +22,6 @@ from controllers.web.error import ( ) from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from graphon.model_runtime.errors.invoke import InvokeError -from models.enums import EndUserType from models.model import App, AppMode, EndUser, IconType from services.app_ref_service import AppRef, MessageRef from services.errors.audio import ( @@ -32,6 +31,7 @@ from services.errors.audio import ( SpeechToTextDisabledServiceError, UnsupportedAudioTypeServiceError, ) +from tests.unit_tests.model_factories import make_end_user def _app_model() -> App: @@ -51,15 +51,7 @@ def _app_model() -> App: def _end_user() -> EndUser: - return EndUser( - id="eu-1", - tenant_id="tenant-1", - app_id="app-1", - type=EndUserType.BROWSER, - external_user_id="ext-1", - name="Web User", - session_id="session-1", - ) + return make_end_user(end_user_id="eu-1", app_id="app-1", external_user_id="ext-1", name="Web User") # --------------------------------------------------------------------------- diff --git a/api/tests/unit_tests/controllers/web/test_completion.py b/api/tests/unit_tests/controllers/web/test_completion.py index 6c7dce3c4ba..73f2ce6d20c 100644 --- a/api/tests/unit_tests/controllers/web/test_completion.py +++ b/api/tests/unit_tests/controllers/web/test_completion.py @@ -23,24 +23,12 @@ from controllers.web.error import ( from core.app.apps.agent_app.errors import AgentAppNotPublishedError from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from graphon.model_runtime.errors.invoke import InvokeError -from models.enums import EndUserType -from models.model import App, AppMode, EndUser, IconType +from models.model import App, AppMode, EndUser +from tests.unit_tests.model_factories import make_app, make_end_user def _app(mode: AppMode) -> App: - return App( - id="app-1", - tenant_id="tenant-1", - name="Web App", - description="", - mode=mode, - icon_type=IconType.EMOJI, - icon="robot", - icon_background="#FFFFFF", - enable_site=True, - enable_api=False, - max_active_requests=0, - ) + return make_app(name="Web App", mode=mode, enable_api=False, max_active_requests=0) def _completion_app() -> App: @@ -52,14 +40,7 @@ def _chat_app() -> App: def _end_user() -> EndUser: - return EndUser( - id="eu-1", - tenant_id="tenant-1", - app_id="app-1", - type=EndUserType.BROWSER, - name="Web User", - session_id="session-1", - ) + return make_end_user(end_user_id="eu-1", app_id="app-1", name="Web User") # --------------------------------------------------------------------------- diff --git a/api/tests/unit_tests/controllers/web/test_files.py b/api/tests/unit_tests/controllers/web/test_files.py index 03d4241b62f..30c36a8af24 100644 --- a/api/tests/unit_tests/controllers/web/test_files.py +++ b/api/tests/unit_tests/controllers/web/test_files.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import datetime from io import BytesIO from unittest.mock import MagicMock, patch @@ -18,11 +17,11 @@ from controllers.common.errors import ( UnsupportedFileTypeError, ) from controllers.web.files import FileApi -from extensions.storage.storage_type import StorageType from libs.exception import BaseHTTPException -from models.enums import CreatorUserRole, EndUserType +from models.enums import CreatorUserRole from models.model import App, AppMode, EndUser, UploadFile from services.errors import file as file_errors +from tests.unit_tests.model_factories import make_end_user, make_upload_file def _app_model() -> App: @@ -39,31 +38,17 @@ def _app_model() -> App: def _end_user() -> EndUser: - return EndUser( - id="eu-1", - tenant_id="tenant-1", - app_id="app-1", - type=EndUserType.BROWSER, - session_id="session-1", - ) + return make_end_user(end_user_id="eu-1", app_id="app-1") def _upload_file() -> UploadFile: - upload_file = UploadFile( - tenant_id="tenant-1", - storage_type=StorageType.LOCAL, + return make_upload_file( + file_id="file-1", key="upload/test.txt", - name="test.txt", size=100, - extension="txt", - mime_type="text/plain", created_by_role=CreatorUserRole.END_USER, created_by="eu-1", - created_at=datetime(2024, 1, 1), - used=False, ) - upload_file.id = "file-1" - return upload_file class TestFileApi: diff --git a/api/tests/unit_tests/controllers/web/test_human_input_file_upload.py b/api/tests/unit_tests/controllers/web/test_human_input_file_upload.py index a3d2c8d188a..9285a6c8fc2 100644 --- a/api/tests/unit_tests/controllers/web/test_human_input_file_upload.py +++ b/api/tests/unit_tests/controllers/web/test_human_input_file_upload.py @@ -28,10 +28,7 @@ from controllers.web.human_input_file_upload import ( InvalidUploadTokenForbiddenError, InvalidUploadTokenUnauthorizedError, ) -from extensions.storage.storage_type import StorageType from models import Account -from models.account import AccountStatus -from models.enums import CreatorUserRole from models.model import UploadFile from services.errors.file import ( BlockedFileExtensionError as BlockedFileExtensionServiceError, @@ -53,6 +50,7 @@ from services.remote_file_service import RemoteFileNotFoundError as RemoteFileNo from services.remote_file_service import RemoteFileUnavailableError as RemoteFileUnavailableServiceError from services.remote_file_service import RemoteFileUploadResult from services.remote_file_service import RemoteFileUrlBlockedError as RemoteFileUrlBlockedServiceError +from tests.unit_tests.model_factories import make_account, make_upload_file @pytest.fixture @@ -63,9 +61,7 @@ def app() -> Flask: def _account() -> Account: - account = Account(name="Form Owner", email="owner@example.com", status=AccountStatus.ACTIVE) - account.id = "owner-1" - return account + return make_account(account_id="owner-1", name="Form Owner", email="owner@example.com") def _upload_context() -> HumanInputUploadContext: @@ -80,22 +76,14 @@ def _upload_context() -> HumanInputUploadContext: def _upload_file() -> UploadFile: - upload_file = UploadFile( - tenant_id="tenant-1", - storage_type=StorageType.LOCAL, + return make_upload_file( + file_id="file-1", key="upload/sample.txt", name="sample.txt", size=7, - extension="txt", - mime_type="text/plain", - created_by_role=CreatorUserRole.ACCOUNT, created_by="end-user-1", - created_at=datetime(2024, 1, 1), - used=False, source_url="signed-source-url", ) - upload_file.id = "file-1" - return upload_file def _remote_upload_file() -> RemoteFileUploadResult: diff --git a/api/tests/unit_tests/controllers/web/test_message_endpoints.py b/api/tests/unit_tests/controllers/web/test_message_endpoints.py index 5feea96596d..9fc9ad2fedc 100644 --- a/api/tests/unit_tests/controllers/web/test_message_endpoints.py +++ b/api/tests/unit_tests/controllers/web/test_message_endpoints.py @@ -22,10 +22,10 @@ from controllers.web.message import ( MessageMoreLikeThisQuery, MessageSuggestedQuestionApi, ) -from models.enums import EndUserType from models.model import App, AppMode, EndUser from services.errors.app import MoreLikeThisDisabledError from services.errors.message import MessageNotExistsError +from tests.unit_tests.model_factories import make_end_user def _chat_app() -> App: @@ -37,12 +37,7 @@ def _completion_app() -> App: def _end_user() -> EndUser: - return EndUser( - id="eu-1", - tenant_id="tenant-1", - type=EndUserType.BROWSER, - session_id="session-1", - ) + return make_end_user(end_user_id="eu-1") # The @model_validate and @with_session decorators wrap the handlers; tests 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 791ec2770fb..0ca69debd55 100644 --- a/api/tests/unit_tests/controllers/web/test_remote_files.py +++ b/api/tests/unit_tests/controllers/web/test_remote_files.py @@ -23,7 +23,6 @@ from controllers.common.errors import ( ) from controllers.web import remote_files as remote_files_module from libs.exception import BaseHTTPException -from models.enums import EndUserType from models.model import App, AppMode, EndUser from services.errors.file import ( BlockedFileExtensionError as ServiceBlockedFileExtensionError, @@ -55,6 +54,7 @@ REMOTE_FILE_ERROR_CASES: tuple[tuple[type[RemoteFileError], type[BaseHTTPExcepti 502, ), ) +from tests.unit_tests.model_factories import make_end_user def _app_model() -> App: @@ -71,13 +71,7 @@ def _app_model() -> App: def _end_user() -> EndUser: - return EndUser( - id="eu-1", - tenant_id="tenant-1", - app_id="app-1", - type=EndUserType.BROWSER, - session_id="session-1", - ) + return make_end_user(end_user_id="eu-1", app_id="app-1") def _upload_result() -> RemoteFileUploadResult: diff --git a/api/tests/unit_tests/controllers/web/test_saved_message.py b/api/tests/unit_tests/controllers/web/test_saved_message.py index 22ec43a5b47..11ef11da686 100644 --- a/api/tests/unit_tests/controllers/web/test_saved_message.py +++ b/api/tests/unit_tests/controllers/web/test_saved_message.py @@ -14,9 +14,9 @@ from werkzeug.exceptions import NotFound from controllers.common.controller_schemas import SavedMessageCreatePayload, SavedMessageListQuery from controllers.web.error import NotCompletionAppError from controllers.web.saved_message import SavedMessageApi, SavedMessageListApi -from models.enums import EndUserType from models.model import App, AppMode, EndUser from services.errors.message import MessageNotExistsError +from tests.unit_tests.model_factories import make_end_user def _completion_app() -> App: @@ -28,12 +28,7 @@ def _chat_app() -> App: def _end_user() -> EndUser: - return EndUser( - id="eu-1", - tenant_id="tenant-1", - type=EndUserType.BROWSER, - session_id="session-1", - ) + return make_end_user(end_user_id="eu-1") # The @with_session and @model_validate decorators wrap the handler; tests call diff --git a/api/tests/unit_tests/controllers/web/test_workflow.py b/api/tests/unit_tests/controllers/web/test_workflow.py index 711976ed441..49c6ad74c6b 100644 --- a/api/tests/unit_tests/controllers/web/test_workflow.py +++ b/api/tests/unit_tests/controllers/web/test_workflow.py @@ -15,11 +15,11 @@ from controllers.web.error import ( ) 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, ) +from tests.unit_tests.model_factories import make_end_user def _workflow_app() -> App: @@ -31,12 +31,7 @@ def _chat_app() -> App: def _end_user() -> EndUser: - return EndUser( - id="eu-1", - tenant_id="tenant-1", - type=EndUserType.BROWSER, - session_id="session-1", - ) + return make_end_user(end_user_id="eu-1") # --------------------------------------------------------------------------- diff --git a/api/tests/unit_tests/controllers/web/test_workflow_events.py b/api/tests/unit_tests/controllers/web/test_workflow_events.py index 049ab04cb4f..e6a4abf86df 100644 --- a/api/tests/unit_tests/controllers/web/test_workflow_events.py +++ b/api/tests/unit_tests/controllers/web/test_workflow_events.py @@ -11,9 +11,10 @@ from flask import Flask from controllers.common.errors import NotFoundError from controllers.web.workflow_events import WorkflowEventsApi from graphon.enums import WorkflowExecutionStatus -from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom +from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom from models.model import App, AppMode, EndUser from models.workflow import WorkflowRun, WorkflowType +from tests.unit_tests.model_factories import make_end_user def _workflow_app() -> App: @@ -21,12 +22,7 @@ def _workflow_app() -> App: def _end_user() -> EndUser: - return EndUser( - id="eu-1", - tenant_id="tenant-1", - type=EndUserType.BROWSER, - session_id="session-1", - ) + return make_end_user(end_user_id="eu-1") def _workflow_run( diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_config_manager.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_config_manager.py index aaea73832ef..b2ebdb96318 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_config_manager.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_config_manager.py @@ -1,26 +1,17 @@ -import json from unittest.mock import patch from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager from models.model import App, AppMode from models.workflow import Workflow, WorkflowType +from tests.unit_tests.model_factories import make_app, make_workflow def _app() -> App: - return App(id="app-1", tenant_id="tenant-1", name="Advanced Chat App", mode=AppMode.ADVANCED_CHAT) + return make_app(name="Advanced Chat App", mode=AppMode.ADVANCED_CHAT, icon_type=None) def _workflow() -> Workflow: - return Workflow( - id="wf-1", - tenant_id="tenant-1", - app_id="app-1", - type=WorkflowType.CHAT, - version=Workflow.VERSION_DRAFT, - graph="{}", - features=json.dumps({}), - created_by="account-1", - ) + return make_workflow(workflow_id="wf-1", workflow_type=WorkflowType.CHAT, graph="{}") class TestAdvancedChatAppConfigManager: diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py b/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py index 9445bc800d4..eaf50049612 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py @@ -23,6 +23,7 @@ from core.app.entities.queue_entities import ( ) from core.moderation.base import ModerationError from models.model import App, AppMode, Message, MessageAnnotation +from tests.unit_tests.model_factories import make_app, make_message class _FakeQueueManager: @@ -46,11 +47,11 @@ def _make_entity(query: str = "hello") -> SimpleNamespace: def _app() -> App: - return App(id="app-1", tenant_id="tenant-1", name="Agent App", mode=AppMode.AGENT_CHAT) + return make_app(name="Agent App", mode=AppMode.AGENT_CHAT, icon_type=None) def _message() -> Message: - return Message(id="msg-1", app_id="app-1", conversation_id="conversation-1") + return make_message(message_id="msg-1") def _patch_moderation(monkeypatch: pytest.MonkeyPatch, *, returns=None, raises: Exception | None = None) -> None: diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py b/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py index 17f5fcfd19a..9ed6f108680 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py @@ -34,6 +34,7 @@ from services.agent.workspace_service import ( AgentWorkspaceBindingGenerationMismatchError, AgentWorkspaceService, ) +from tests.unit_tests.model_factories import make_account, make_app, make_conversation _SOUL_DICT = { "model": { @@ -84,24 +85,22 @@ def _snapshot( def _app() -> App: - return App( - id="app-1", + return make_app( tenant_id="t1", name="Agent App", mode=AppMode.AGENT_CHAT, + icon_type=None, enable_site=False, enable_api=False, ) def _account() -> Account: - account = Account(name="Agent User", email="agent-user@example.com") - account.id = "user-1" - return account + return make_account(account_id="user-1", name="Agent User", email="agent-user@example.com") def _conversation(*, binding_id: str) -> Conversation: - return Conversation(id="conversation-1", app_id="app-1", agent_workspace_binding_id=binding_id) + return make_conversation(agent_workspace_binding_id=binding_id) def _binding( diff --git a/api/tests/unit_tests/core/app/apps/chat/test_app_config_manager.py b/api/tests/unit_tests/core/app/apps/chat/test_app_config_manager.py index 7df8a5d8e4e..00052feff1e 100644 --- a/api/tests/unit_tests/core/app/apps/chat/test_app_config_manager.py +++ b/api/tests/unit_tests/core/app/apps/chat/test_app_config_manager.py @@ -6,10 +6,11 @@ from sqlalchemy.orm import Session from core.app.app_config.entities import EasyUIBasedAppModelConfigFrom, ModelConfigEntity, PromptTemplateEntity from core.app.apps.chat.app_config_manager import ChatAppConfigManager from models.model import App, AppMode, AppModelConfig +from tests.unit_tests.model_factories import make_app def _app() -> App: - return App(id="app-1", tenant_id="tenant-1", name="Chat App", mode=AppMode.CHAT) + return make_app(name="Chat App", icon_type=None) def _app_model_config() -> AppModelConfig: diff --git a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py index 3e847f65d73..37e3912ba68 100644 --- a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py +++ b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py @@ -26,9 +26,10 @@ from core.app.apps.pipeline.pipeline_runner import PipelineRunner from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from graphon.graph_events import GraphRunFailedEvent from models.dataset import Dataset, Document, Pipeline -from models.enums import DataSourceType, DocumentCreatedFrom, EndUserType +from models.enums import DocumentCreatedFrom from models.model import EndUser from models.workflow import Workflow, WorkflowType +from tests.unit_tests.model_factories import make_dataset, make_document, make_end_user, make_workflow def _pipeline(*, tenant_id: str = "tenant", pipeline_id: str = "pipe") -> Pipeline: @@ -39,10 +40,9 @@ def _pipeline(*, tenant_id: str = "tenant", pipeline_id: str = "pipe") -> Pipeli def _dataset(*, tenant_id: str = "tenant", dataset_id: str = "ds", pipeline_id: str = "pipe") -> Dataset: - return Dataset( - id=dataset_id, + return make_dataset( + dataset_id=dataset_id, tenant_id=tenant_id, - name="Dataset", description="", created_by="user", pipeline_id=pipeline_id, @@ -50,40 +50,26 @@ def _dataset(*, tenant_id: str = "tenant", dataset_id: str = "ds", pipeline_id: def _workflow(*, tenant_id: str = "tenant", pipeline_id: str = "pipe", graph: dict | None = None) -> Workflow: - return Workflow.new( + return make_workflow( tenant_id=tenant_id, app_id=pipeline_id, - type=WorkflowType.RAG_PIPELINE.value, + workflow_type=WorkflowType.RAG_PIPELINE, version="v1", - graph=json.dumps(graph if graph is not None else {"nodes": [], "edges": []}), - features="{}", + graph=graph, created_by="user", - environment_variables=[], - conversation_variables=[], - rag_pipeline_variables=[], ) def _end_user() -> EndUser: - return EndUser( - id="user", - tenant_id="tenant", - app_id="pipe", - type=EndUserType.BROWSER, - name="User", - session_id="sess", - ) + return make_end_user(end_user_id="user", tenant_id="tenant", app_id="pipe", name="User", session_id="sess") def _document(*, document_id: str = "doc", dataset_id: str = "ds", tenant_id: str = "tenant") -> Document: - return Document( - id=document_id, + return make_document( + document_id=document_id, tenant_id=tenant_id, dataset_id=dataset_id, - position=1, - data_source_type=DataSourceType.UPLOAD_FILE, batch="batch", - name="Document", created_from=DocumentCreatedFrom.API, created_by="user", ) diff --git a/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py b/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py index 410ec9db8a9..fdd7b53c50a 100644 --- a/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py @@ -18,6 +18,7 @@ from core.app.entities.app_invoke_entities import ChatAppGenerateEntity, InvokeF from models.account import Account from models.model import App, AppMode, Conversation, Message from services.errors.app_model_config import AppModelConfigBrokenError +from tests.unit_tests.model_factories import make_account, make_app class DummyModelConf: @@ -47,19 +48,11 @@ class DummyCompletionGenerateEntity: def _app(*, app_id: str = "app") -> App: - return App( - id=app_id, - tenant_id="tenant-id", - name="Message App", - mode=AppMode.CHAT, - app_model_config_id=None, - ) + return make_app(app_id=app_id, tenant_id="tenant-id", name="Message App", icon_type=None) def _account() -> Account: - account = Account(name="Message User", email="message-user@example.com") - account.id = "user-id" - return account + return make_account(account_id="user-id", name="Message User", email="message-user@example.com") def _make_app_config(app_mode: AppMode) -> EasyUIBasedAppConfig: diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py index 43dcab8c241..c381fb1bbc4 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py @@ -1,7 +1,6 @@ """SQLite-backed tests for workflow app generation and worker reload behavior.""" import contextlib -import json from types import SimpleNamespace from unittest.mock import MagicMock @@ -22,7 +21,8 @@ from graphon.runtime import GraphRuntimeState, VariablePool from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom from models.model import App, AppMode, EndUser from models.snippet import CustomizedSnippet -from models.workflow import Workflow, WorkflowKind, WorkflowNodeExecutionTriggeredFrom, WorkflowRun, WorkflowType +from models.workflow import Workflow, WorkflowKind, WorkflowNodeExecutionTriggeredFrom, WorkflowRun +from tests.unit_tests.model_factories import make_workflow def _workflow( @@ -32,19 +32,13 @@ def _workflow( tenant_id: str = "tenant", kind: WorkflowKind = WorkflowKind.STANDARD, ) -> Workflow: - return Workflow( - id=workflow_id, + return make_workflow( + workflow_id=workflow_id, tenant_id=tenant_id, app_id=app_id, - type=WorkflowType.WORKFLOW, kind=kind, version="1", - graph=json.dumps({"nodes": [], "edges": []}), - features="{}", created_by="creator", - environment_variables=[], - conversation_variables=[], - rag_pipeline_variables=[], ) diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py index fbe5035d3fa..94dac233314 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py @@ -49,21 +49,16 @@ from graphon.node_events import NodeRunResult from graphon.runtime import GraphRuntimeState, VariablePool from graphon.variables.segments import StringSegment from graphon.variables.variables import StringVariable -from models.workflow import Workflow, WorkflowType +from models.workflow import Workflow +from tests.unit_tests.model_factories import make_workflow def _workflow(graph: dict[str, object] | None = None) -> Workflow: - return Workflow.new( + return make_workflow( tenant_id="tenant", app_id="app", - type=WorkflowType.WORKFLOW, - version=Workflow.VERSION_DRAFT, graph=json.dumps(graph or {}, default=str), - features="{}", created_by="account", - environment_variables=[], - conversation_variables=[], - rag_pipeline_variables=[], ) diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_app_config_manager.py b/api/tests/unit_tests/core/app/apps/workflow/test_app_config_manager.py index 55911e1cedf..a443dbd4a29 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_app_config_manager.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_app_config_manager.py @@ -1,26 +1,17 @@ -import json from unittest.mock import patch from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager from models.model import App, AppMode -from models.workflow import Workflow, WorkflowType +from models.workflow import Workflow +from tests.unit_tests.model_factories import make_app, make_workflow def _app() -> App: - return App(id="app-1", tenant_id="tenant-1", name="Workflow App", mode=AppMode.WORKFLOW) + return make_app(name="Workflow App", mode=AppMode.WORKFLOW, icon_type=None) def _workflow() -> Workflow: - return Workflow( - id="wf-1", - tenant_id="tenant-1", - app_id="app-1", - type=WorkflowType.WORKFLOW, - version=Workflow.VERSION_DRAFT, - graph="{}", - features=json.dumps({}), - created_by="account-1", - ) + return make_workflow(workflow_id="wf-1", graph="{}") class TestWorkflowAppConfigManager: diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py index b4d8dbde01f..14af58bae44 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json from types import SimpleNamespace import pytest @@ -53,33 +52,19 @@ from graphon.enums import BuiltinNodeTypes, WorkflowExecutionStatus from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.runtime import GraphRuntimeState, VariablePool from libs.datetime_utils import naive_utc_now -from models.enums import CreatorUserRole, EndUserType +from models.enums import CreatorUserRole from models.model import AppMode, EndUser -from models.workflow import Workflow, WorkflowAppLog, WorkflowType +from models.workflow import Workflow, WorkflowAppLog +from tests.unit_tests.model_factories import make_end_user, make_workflow from tests.workflow_test_utils import build_test_variable_pool def _workflow() -> Workflow: - return Workflow( - id="workflow-id", - tenant_id="tenant", - app_id="app", - type=WorkflowType.WORKFLOW, - version=Workflow.VERSION_DRAFT, - graph="{}", - features=json.dumps({}), - created_by="user", - ) + return make_workflow(workflow_id="workflow-id", tenant_id="tenant", app_id="app", graph="{}", created_by="user") def _end_user(*, end_user_id: str = "user", session_id: str = "session") -> EndUser: - return EndUser( - id=end_user_id, - tenant_id="tenant", - app_id="app", - type=EndUserType.BROWSER, - session_id=session_id, - ) + return make_end_user(end_user_id=end_user_id, tenant_id="tenant", app_id="app", session_id=session_id) def _make_pipeline(): diff --git a/api/tests/unit_tests/core/app/features/test_annotation_reply.py b/api/tests/unit_tests/core/app/features/test_annotation_reply.py index a9ae870fabc..a9001c96c61 100644 --- a/api/tests/unit_tests/core/app/features/test_annotation_reply.py +++ b/api/tests/unit_tests/core/app/features/test_annotation_reply.py @@ -10,26 +10,18 @@ from core.app.entities.app_invoke_entities import InvokeFrom from core.app.features.annotation_reply.annotation_reply import AnnotationReplyFeature from models.dataset import DatasetCollectionBinding from models.enums import CollectionBindingType, ConversationFromSource -from models.model import App, AppAnnotationHitHistory, AppAnnotationSetting, AppMode, Message, MessageAnnotation +from models.model import App, AppAnnotationHitHistory, AppAnnotationSetting, Message, MessageAnnotation +from tests.unit_tests.model_factories import make_app, make_message TABLES = (AppAnnotationSetting, DatasetCollectionBinding, MessageAnnotation, AppAnnotationHitHistory) def _app() -> App: - return App( - id="app-1", - tenant_id="tenant-1", - name="Test App", - description="", - mode=AppMode.CHAT, - enable_site=True, - enable_api=True, - max_active_requests=0, - ) + return make_app(icon_type=None, max_active_requests=0) def _message() -> Message: - return Message(id="msg-1") + return make_message(message_id="msg-1", app_id=None, conversation_id=None) def _persist_binding(session: Session) -> DatasetCollectionBinding: diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py index 7c696955c97..5ffb62a8a67 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py @@ -55,12 +55,12 @@ from core.app.task_pipeline.easy_ui_based_generate_task_pipeline import EasyUIBa from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk from core.ops.entities.trace_entity import TraceTaskName from core.ops.ops_trace_manager import TraceQueueManager -from extensions.storage.storage_type import StorageType from graphon.file import FileTransferMethod, FileType from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, TextPromptMessageContent from models.enums import ConversationFromSource, CreatorUserRole from models.model import AppMode, Conversation, Message, MessageAgentThought, MessageFile, UploadFile +from tests.unit_tests.model_factories import make_upload_file class _DummyModelConf: @@ -212,21 +212,17 @@ def _message_file( def _upload_file(*, file_id: str, name: str, mime_type: str, size: int, extension: str) -> UploadFile: - upload_file = UploadFile( + return make_upload_file( + file_id=file_id, tenant_id="tenant", - storage_type=StorageType.LOCAL, key=f"uploads/{file_id}", name=name, size=size, extension=extension, mime_type=mime_type, - created_by_role=CreatorUserRole.ACCOUNT, created_by="user", created_at=datetime.now(UTC), - used=False, ) - upload_file.id = file_id - return upload_file def _agent_thought() -> MessageAgentThought: diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py b/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py index 79582176826..76cdd91757a 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py @@ -20,7 +20,8 @@ from graphon.file import FileTransferMethod, FileType from models import model as model_module from models.base import TypeBase from models.enums import ConversationFromSource, CreatorUserRole, MessageFileBelongsTo -from models.model import App, AppMode, Conversation, MessageAnnotation, MessageFile +from models.model import App, Conversation, MessageAnnotation, MessageFile +from tests.unit_tests.model_factories import make_app, make_conversation @dataclass(frozen=True) @@ -46,29 +47,18 @@ def cycle_db(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator def _app(*, app_id: str = "app-id", tenant_id: str = "tenant-1") -> App: - return App( - id=app_id, - tenant_id=tenant_id, - name="Test App", - description="", - mode=AppMode.CHAT, - enable_site=True, - enable_api=True, - max_active_requests=0, - ) + return make_app(app_id=app_id, tenant_id=tenant_id, icon_type=None, max_active_requests=0) def _conversation(*, conversation_id: str = "conv-1", app_id: str = "app-id") -> Conversation: - conversation = Conversation( + return make_conversation( + conversation_id=conversation_id, app_id=app_id, - mode=AppMode.CHAT, name="", status="normal", from_source=ConversationFromSource.API, inputs={}, ) - conversation.id = conversation_id - return conversation def _message_file( diff --git a/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py b/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py index c61b170eb0d..efd856dfbf9 100644 --- a/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py +++ b/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py @@ -6,28 +6,23 @@ import pytest from sqlalchemy.orm import Session from core.datasource.datasource_file_manager import DatasourceFileManager -from extensions.storage.storage_type import StorageType from models.enums import CreatorUserRole from models.model import MessageFile, UploadFile from models.tools import ToolFile +from tests.unit_tests.model_factories import make_upload_file def _upload_file(id: str, *, key: str, mime_type: str) -> UploadFile: - upload_file = UploadFile( - tenant_id="tenant-1", - storage_type=StorageType.LOCAL, + return make_upload_file( + file_id=id, key=key, name="file.png", size=4, extension=".png", mime_type=mime_type, - created_by_role=CreatorUserRole.ACCOUNT, created_by="user-1", created_at=datetime.now(UTC).replace(tzinfo=None), - used=False, ) - upload_file.id = id - return upload_file def _tool_file(id: str, *, key: str = "tool_key", mimetype: str = "image/png") -> ToolFile: diff --git a/api/tests/unit_tests/core/llm_generator/test_llm_generator_missing.py b/api/tests/unit_tests/core/llm_generator/test_llm_generator_missing.py index 77c887fef8d..6fbf18afbbb 100644 --- a/api/tests/unit_tests/core/llm_generator/test_llm_generator_missing.py +++ b/api/tests/unit_tests/core/llm_generator/test_llm_generator_missing.py @@ -15,6 +15,7 @@ from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage from graphon.model_runtime.entities.message_entities import AssistantPromptMessage from models.dataset import Dataset from services.workflow_service import WorkflowService +from tests.unit_tests.model_factories import make_dataset @pytest.fixture @@ -45,8 +46,8 @@ def _model_manager() -> tuple[MagicMock, MagicMock]: def _dataset(*, dataset_id: str, tenant_id: str, name: str, created_at: datetime) -> Dataset: - return Dataset( - id=dataset_id, + return make_dataset( + dataset_id=dataset_id, tenant_id=tenant_id, name=name, created_by="account-id", 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 b37553f8510..c0675a15900 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 @@ -26,6 +26,7 @@ from models.model import App, AppMode, AppModelConfig, Conversation, Message, Me 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 +from tests.unit_tests.model_factories import make_app class DummyConfig: @@ -183,20 +184,7 @@ def encryption_functions( def _app(session: Session, *, app_id: str = "app-id", tracing: str | None = None) -> App: - app = App( - id=app_id, - tenant_id="tenant-1", - name="App", - description="description", - mode=AppMode.CHAT, - icon_type=None, - icon=None, - icon_background=None, - enable_site=True, - enable_api=True, - max_active_requests=None, - tracing=tracing, - ) + app = make_app(app_id=app_id, name="App", description="description", icon_type=None, tracing=tracing) session.add(app) session.commit() return app diff --git a/api/tests/unit_tests/core/ops/test_utils.py b/api/tests/unit_tests/core/ops/test_utils.py index 16fd87cfaa4..34752b2a447 100644 --- a/api/tests/unit_tests/core/ops/test_utils.py +++ b/api/tests/unit_tests/core/ops/test_utils.py @@ -19,6 +19,7 @@ from core.ops.utils import ( ) from models.enums import ConversationFromSource from models.model import Message +from tests.unit_tests.model_factories import make_message class _DatabaseBinding: @@ -39,10 +40,11 @@ def message_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> def _message(message_id: str) -> Message: - message = Message( - id=message_id, + return make_message( + message_id=message_id, app_id="app-id", conversation_id="conversation-id", + inputs={}, query="question", message={"role": "user", "content": "question"}, answer="answer", @@ -51,8 +53,6 @@ def _message(message_id: str) -> Message: currency="USD", from_source=ConversationFromSource.API, ) - message._inputs = {} - return message class TestValidateUrl: diff --git a/api/tests/unit_tests/core/plugin/test_backwards_invocation_app.py b/api/tests/unit_tests/core/plugin/test_backwards_invocation_app.py index 3716628927b..adfd4084b3e 100644 --- a/api/tests/unit_tests/core/plugin/test_backwards_invocation_app.py +++ b/api/tests/unit_tests/core/plugin/test_backwards_invocation_app.py @@ -13,7 +13,8 @@ from core.plugin.backwards_invocation.base import BaseBackwardsInvocation from models import Account, Tenant, TenantAccountJoin from models.enums import EndUserType from models.model import App, AppMode, AppModelConfig, EndUser -from models.workflow import Workflow, WorkflowType +from models.workflow import Workflow +from tests.unit_tests.model_factories import make_app, make_end_user, make_workflow class _Chunk(BaseModel): @@ -33,30 +34,21 @@ def _app( workflow_id: str | None = None, app_model_config_id: str | None = None, ) -> App: - return App( - id=app_id, + return make_app( + app_id=app_id, tenant_id=tenant_id, name="Plugin app", - description="", mode=mode, - enable_site=False, - enable_api=False, + icon_type=None, workflow_id=workflow_id, app_model_config_id=app_model_config_id, + enable_site=False, + enable_api=False, ) def _workflow(*, workflow_id: str = "workflow-1", app_id: str = "app-1", tenant_id: str = "tenant-1") -> Workflow: - return Workflow( - id=workflow_id, - tenant_id=tenant_id, - app_id=app_id, - type=WorkflowType.WORKFLOW, - version=Workflow.VERSION_DRAFT, - graph="{}", - _features="{}", - created_by="account-1", - ) + return make_workflow(workflow_id=workflow_id, tenant_id=tenant_id, app_id=app_id, graph="{}") def _end_user( @@ -66,14 +58,12 @@ def _end_user( app_id: str = "app-1", session_id: str = "browser-session", ) -> EndUser: - return EndUser( - id=user_id, + return make_end_user( + end_user_id=user_id, tenant_id=tenant_id, app_id=app_id, - type=EndUserType.BROWSER, session_id=session_id, name="Browser user", - is_anonymous=True, ) 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 835e6b54e87..2cfbfd5db68 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 @@ -11,6 +11,7 @@ import core.rag.datasource.keyword.jieba.jieba as jieba_module from core.rag.datasource.keyword.jieba.jieba import Jieba, dumps_with_sets, set_orjson_default from core.rag.models.document import Document from models.dataset import Dataset, DatasetKeywordTable, DocumentSegment +from tests.unit_tests.model_factories import make_dataset class _DummyLock: @@ -34,11 +35,7 @@ def _dataset_keyword_table( def _dataset(dataset_keyword_table: DatasetKeywordTable | None = None, keyword_number: int | None = None) -> Dataset: - dataset = Dataset( - id="dataset-1", - tenant_id="tenant-1", - keyword_number=keyword_number, - ) + dataset = make_dataset(keyword_number=keyword_number) dataset.get_dataset_keyword_table = MagicMock(return_value=dataset_keyword_table) return dataset diff --git a/api/tests/unit_tests/core/rag/datasource/keyword/test_keyword_base.py b/api/tests/unit_tests/core/rag/datasource/keyword/test_keyword_base.py index e96472699b9..e3b781d3168 100644 --- a/api/tests/unit_tests/core/rag/datasource/keyword/test_keyword_base.py +++ b/api/tests/unit_tests/core/rag/datasource/keyword/test_keyword_base.py @@ -7,10 +7,11 @@ from sqlalchemy.orm import Session from core.rag.datasource.keyword.keyword_base import BaseKeyword from core.rag.models.document import Document from models import Dataset +from tests.unit_tests.model_factories import make_dataset def _dataset() -> Dataset: - return Dataset(id="dataset-1", tenant_id="tenant-1", name="Dataset", created_by="account-1") + return make_dataset() class _KeywordThatRaises(BaseKeyword): diff --git a/api/tests/unit_tests/core/rag/docstore/test_dataset_docstore.py b/api/tests/unit_tests/core/rag/docstore/test_dataset_docstore.py index 91e1ff4b391..07f7a87372a 100644 --- a/api/tests/unit_tests/core/rag/docstore/test_dataset_docstore.py +++ b/api/tests/unit_tests/core/rag/docstore/test_dataset_docstore.py @@ -14,6 +14,7 @@ from sqlalchemy.orm import Session from core.rag.docstore.dataset_docstore import DatasetDocumentStore, DocumentSegment from core.rag.models.document import AttachmentDocument, ChildDocument, Document from models.dataset import ChildChunk, Dataset, SegmentAttachmentBinding +from tests.unit_tests.model_factories import make_dataset TENANT_ID = "00000000-0000-0000-0000-000000000001" DATASET_ID = "00000000-0000-0000-0000-000000000002" @@ -23,11 +24,7 @@ ATTACHMENT_ID = "00000000-0000-0000-0000-000000000005" def _dataset() -> Dataset: - dataset = Dataset( - id=DATASET_ID, - tenant_id=TENANT_ID, - ) - return dataset + return make_dataset(dataset_id=DATASET_ID, tenant_id=TENANT_ID) def _persist_segment( 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 2f00fda06db..b065556125e 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 @@ -9,28 +9,23 @@ import core.rag.extractor.extract_processor as processor_module from core.rag.extractor.entity.datasource_type import DatasourceType from core.rag.extractor.extract_processor import ExtractProcessor 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 +from tests.unit_tests.model_factories import make_upload_file def _upload_file(*, key: str, file_id: str = "upload-file-1") -> UploadFile: - upload_file = UploadFile( - tenant_id="tenant-1", - storage_type=StorageType.LOCAL, + return make_upload_file( + file_id=file_id, key=key, name=Path(key).name, size=1, extension=Path(key).suffix.lstrip("."), mime_type="application/octet-stream", - created_by_role=CreatorUserRole.ACCOUNT, created_by="user-1", created_at=datetime(2025, 1, 1), used=True, ) - upload_file.id = file_id - return upload_file class _ExtractorFactory: diff --git a/api/tests/unit_tests/core/repositories/test_sqlalchemy_workflow_node_execution_repository.py b/api/tests/unit_tests/core/repositories/test_sqlalchemy_workflow_node_execution_repository.py index b437cdcf205..953529b4c55 100644 --- a/api/tests/unit_tests/core/repositories/test_sqlalchemy_workflow_node_execution_repository.py +++ b/api/tests/unit_tests/core/repositories/test_sqlalchemy_workflow_node_execution_repository.py @@ -26,21 +26,22 @@ from core.repositories.sqlalchemy_workflow_node_execution_repository import ( _find_first, _replace_or_append_offload, ) -from extensions.storage.storage_type import StorageType from graphon.entities import WorkflowNodeExecution from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus -from models import Account, EndUser, Tenant +from models import Account, EndUser from models.enums import CreatorUserRole, ExecutionOffLoadType from models.model import UploadFile from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionOffload, WorkflowNodeExecutionTriggeredFrom +from tests.unit_tests.model_factories import make_account, make_tenant, make_upload_file def _account(*, tenant_id: str = "tenant-1", user_id: str = "user-1") -> Account: - user = Account(name="Test Account", email="test@example.com") - user.id = user_id - user._current_tenant = Tenant(name="Test Tenant") - user._current_tenant.id = tenant_id - return user + return make_account( + account_id=user_id, + name="Test Account", + email="test@example.com", + tenant=make_tenant(tenant_id=tenant_id, name="Test Tenant"), + ) def _end_user(*, tenant_id: str = "tenant-1", user_id: str = "end-user-1") -> EndUser: @@ -48,18 +49,14 @@ def _end_user(*, tenant_id: str = "tenant-1", user_id: str = "end-user-1") -> En def _upload_file(*, key: str = "storage-key") -> UploadFile: - return UploadFile( - tenant_id="tenant-1", - storage_type=StorageType.LOCAL, + return make_upload_file( key=key, name="offload.json", size=1, extension="json", mime_type="application/json", - created_by_role=CreatorUserRole.ACCOUNT, created_by="user-1", created_at=datetime.now(UTC), - used=False, ) diff --git a/api/tests/unit_tests/core/repositories/test_workflow_node_execution_conflict_handling.py b/api/tests/unit_tests/core/repositories/test_workflow_node_execution_conflict_handling.py index 0335bff634b..d9f39da073f 100644 --- a/api/tests/unit_tests/core/repositories/test_workflow_node_execution_conflict_handling.py +++ b/api/tests/unit_tests/core/repositories/test_workflow_node_execution_conflict_handling.py @@ -19,7 +19,8 @@ from graphon.entities.workflow_node_execution import ( ) from graphon.enums import BuiltinNodeTypes from libs.datetime_utils import naive_utc_now -from models import Account, Tenant, WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom +from models import Account, WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom +from tests.unit_tests.model_factories import make_account, make_tenant @dataclass(frozen=True) @@ -51,12 +52,12 @@ def conflict_database( def _account() -> Account: - tenant = Tenant(name="Conflict Tenant") - tenant.id = "test-tenant-id" - account = Account(name="Conflict User", email="conflict@example.com") - account.id = "test-user-id" - account._current_tenant = tenant - return account + return make_account( + account_id="test-user-id", + name="Conflict User", + email="conflict@example.com", + tenant=make_tenant(tenant_id="test-tenant-id", name="Conflict Tenant"), + ) @pytest.fixture diff --git a/api/tests/unit_tests/core/tools/test_signature.py b/api/tests/unit_tests/core/tools/test_signature.py index ed1b043087a..1214d8a4caf 100644 --- a/api/tests/unit_tests/core/tools/test_signature.py +++ b/api/tests/unit_tests/core/tools/test_signature.py @@ -10,6 +10,7 @@ from typing import Literal from urllib.parse import parse_qs, urlparse import pytest +from typing_extensions import TypedDict from core.tools.signature import ( bind_file_uri, @@ -22,6 +23,16 @@ from core.tools.signature import ( ) +class _SignedPluginFile(TypedDict, closed=True): + filename: str + mimetype: str + tenant_id: str + user_id: str + timestamp: str + nonce: str + sign: str + + @pytest.fixture(autouse=True) def _signature_config(config_overrides: Callable[..., None]) -> None: config_overrides( @@ -213,7 +224,7 @@ def test_plugin_upload_signature_binds_max_size_without_legacy_payload_ambiguity max_size=1024, ) query = parse_qs(urlparse(uri).query) - signed = { + signed: _SignedPluginFile = { "filename": "report.pdf", "mimetype": "application/pdf", "tenant_id": "tenant-id", @@ -227,7 +238,7 @@ def test_plugin_upload_signature_binds_max_size_without_legacy_payload_ambiguity assert verify_plugin_file_signature(**signed, user_from=user_from, max_size=1024) is True assert verify_plugin_file_signature(**signed, user_from=user_from, max_size=2048) is False assert verify_plugin_file_signature(**signed, user_from=user_from) is False - forged = {**signed, "nonce": f"{signed['nonce']}{forged_nonce_suffix}"} + forged: _SignedPluginFile = {**signed, "nonce": f"{signed['nonce']}{forged_nonce_suffix}"} assert verify_plugin_file_signature(**forged) is False @@ -248,7 +259,7 @@ def test_plugin_upload_signature_binds_account_user_from( query = parse_qs(urlparse(uri).query) assert query["user_from"] == ["account"] - signed = { + signed: _SignedPluginFile = { "filename": "report.pdf", "mimetype": "application/pdf", "tenant_id": "tenant-id", diff --git a/api/tests/unit_tests/core/tools/test_tool_engine.py b/api/tests/unit_tests/core/tools/test_tool_engine.py index f688c68cfa6..941dbb22a7d 100644 --- a/api/tests/unit_tests/core/tools/test_tool_engine.py +++ b/api/tests/unit_tests/core/tools/test_tool_engine.py @@ -162,6 +162,90 @@ def test_convert_tool_response_to_str_and_extract_binary_messages(): ) +@pytest.mark.parametrize("json_first", [True, False]) +@pytest.mark.parametrize( + ("text", "json_object"), + [ + pytest.param('{"a":1,"b":2}', {"a": 1, "b": 2}, id="compact"), + pytest.param(' \r\n{\n "a": 1,\n "b": 2\n}\t', {"a": 1, "b": 2}, id="whitespace"), + pytest.param('{"b": 2, "a": 1}', {"a": 1, "b": 2}, id="key-order"), + pytest.param( + '{"items":[{"b":2,"a":1}],"nested":{"y":2,"x":1}}', + {"nested": {"x": 1, "y": 2}, "items": [{"a": 1, "b": 2}]}, + id="nested-key-order", + ), + pytest.param(r'{"\u540d\u79f0":"\u4e2d\u6587"}', {"名称": "中文"}, id="unicode-escapes"), + pytest.param(r'{"path":"\/a\/b"}', {"path": "/a/b"}, id="slash-escapes"), + pytest.param('{"a":1e0}', {"a": 1.0}, id="exponent"), + pytest.param(" { } ", {}, id="empty-object"), + pytest.param('[ {"b":2,"a":1},null,true ]', [{"a": 1, "b": 2}, None, True], id="array"), + ], +) +def test_convert_tool_response_deduplicates_equivalent_json( + text: str, json_object: dict[str, Any] | list[Any], json_first: bool +): + tool = _build_tool() + messages = [ + ToolInvokeMessage( + type=ToolInvokeMessage.MessageType.JSON, + message=ToolInvokeMessage.JsonMessage(json_object=json_object), + ), + tool.create_text_message(text), + ] + if not json_first: + messages.reverse() + + assert ToolEngine.tool_response_to_str(messages) == text + + +@pytest.mark.parametrize( + ("text", "json_object", "json_text"), + [ + pytest.param("plain text", {"a": 1}, '{"a": 1}', id="plain-text"), + pytest.param("[" * 2000 + "0" + "]" * 2000, {"a": 1}, '{"a": 1}', id="deeply-nested-text"), + pytest.param("9" * 5000, {"a": 1}, '{"a": 1}', id="oversized-integer"), + pytest.param("{'a': 1}", {"a": 1}, '{"a": 1}', id="invalid-json"), + pytest.param('result: {"a":1}', {"a": 1}, '{"a": 1}', id="embedded-json"), + pytest.param('{"a":1}{"a":1}', {"a": 1}, '{"a": 1}', id="concatenated-json"), + pytest.param('{"a":1}', {"a": 2}, '{"a": 2}', id="different-value"), + pytest.param('{"a":1}', {"b": 1}, '{"b": 1}', id="different-key"), + pytest.param('{"a":[1,2]}', {"a": [2, 1]}, '{"a": [2, 1]}', id="array-order"), + pytest.param('{"a":true}', {"a": 1}, '{"a": 1}', id="boolean-vs-number"), + pytest.param('{"a":null}', {"a": False}, '{"a": false}', id="null-vs-boolean"), + pytest.param('{"a":"1"}', {"a": 1}, '{"a": 1}', id="string-vs-number"), + pytest.param('{"a":"a b"}', {"a": "ab"}, '{"a": "ab"}', id="string-whitespace"), + ], +) +def test_convert_tool_response_preserves_distinct_json(text: str, json_object: dict[str, Any], json_text: str): + tool = _build_tool() + messages = [tool.create_json_message(json_object), tool.create_text_message(text)] + + assert ToolEngine.tool_response_to_str(messages) == text + json_text + + +@pytest.mark.parametrize("suppress_output", [True, False]) +def test_convert_tool_response_preserves_json_only_output(suppress_output: bool): + tool = _build_tool() + messages = [tool.create_json_message({"b": 2, "a": "中文"}, suppress_output=suppress_output)] + + assert ToolEngine.tool_response_to_str(messages) == ("" if suppress_output else '{"b": 2, "a": "中文"}') + + +def test_convert_tool_response_deduplicates_only_matching_json(): + tool = _build_tool() + text = '{"b":2,"a":1}' + messages = [ + tool.create_json_message({"a": 1, "b": 2}), + tool.create_text_message(text), + tool.create_text_message("result"), + tool.create_text_message(text), + tool.create_json_message({"c": 3}), + tool.create_json_message({"c": 3}), + ] + + assert ToolEngine.tool_response_to_str(messages) == text + "result" + text + '{"c": 3}{"c": 3}' + + @pytest.mark.parametrize("sqlite_session", [(MessageFile,)], indirect=True) def test_create_message_files_and_invoke_generator(sqlite_engine: Engine, sqlite_session: Session): binaries = [ diff --git a/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py b/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py index 45d9bdbf770..efac03fa7ae 100644 --- a/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py +++ b/api/tests/unit_tests/core/tools/workflow_as_tool/test_provider.py @@ -29,9 +29,10 @@ from extensions.ext_database import db from graphon.variables.input_entities import VariableEntity, VariableEntityType from models.account import Account from models.base import TypeBase -from models.model import App, AppMode, IconType +from models.model import App, AppMode from models.tools import WorkflowToolProvider -from models.workflow import Workflow, WorkflowType +from models.workflow import Workflow +from tests.unit_tests.model_factories import make_account, make_app, make_workflow @pytest.fixture @@ -64,35 +65,27 @@ def _controller(provider_id: str = "provider-1") -> WorkflowToolProviderControll def _app(*, tenant_id: str | None = None) -> App: - return App( - id=str(uuid.uuid4()), + return make_app( + app_id=str(uuid.uuid4()), tenant_id=tenant_id or str(uuid.uuid4()), name="Workflow App", mode=AppMode.WORKFLOW, - icon_type=IconType.EMOJI, icon="workflow", - icon_background="#FFFFFF", - enable_site=True, enable_api=False, ) def _account() -> Account: - return Account(name="Alice", email="alice@example.com") + return make_account(account_id=None, name="Alice", email="alice@example.com") def _workflow(app: App, account: Account | None = None) -> Workflow: - return Workflow.new( + return make_workflow( tenant_id=app.tenant_id, app_id=app.id, - type=WorkflowType.WORKFLOW.value, version="1", - graph=json.dumps({"nodes": []}), - features="{}", + graph={"nodes": []}, created_by=account.id if account else str(uuid.uuid4()), - environment_variables=[], - conversation_variables=[], - rag_pipeline_variables=[], ) diff --git a/api/tests/unit_tests/core/trigger/debug/test_debug_event_selectors.py b/api/tests/unit_tests/core/trigger/debug/test_debug_event_selectors.py index 616ec72dcaa..32f8a47ac41 100644 --- a/api/tests/unit_tests/core/trigger/debug/test_debug_event_selectors.py +++ b/api/tests/unit_tests/core/trigger/debug/test_debug_event_selectors.py @@ -32,6 +32,7 @@ from graphon.enums import BuiltinNodeTypes, NodeType, WorkflowType from models.model import App, AppMode from models.workflow import Workflow from tests.unit_tests.core.trigger.conftest import VALID_PROVIDER_ID +from tests.unit_tests.model_factories import make_app def _make_poller_args(node_config: dict[str, Any] | None = None) -> dict[str, Any]: @@ -75,14 +76,12 @@ def _workflow_with_node(node_type: NodeType | None) -> Workflow: def _app() -> App: - return App( - id="a1", + return make_app( + app_id="a1", tenant_id="t1", name="Trigger App", - description="", mode=AppMode.WORKFLOW, - enable_site=True, - enable_api=True, + icon_type=None, max_active_requests=0, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py index f5361f9cc71..a49468aeea2 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py @@ -1,4 +1,3 @@ -import json from datetime import UTC, datetime from unittest.mock import Mock @@ -23,15 +22,11 @@ from models.agent_config_entities import AgentSoulConfig, AgentSoulModelConfig, from models.enums import CreatorUserRole from models.model import UploadFile from models.workflow import Workflow +from tests.unit_tests.model_factories import make_workflow def _workflow(graph: dict) -> Workflow: - return Workflow( - id="workflow-1", - tenant_id="tenant-1", - app_id="app-1", - graph=json.dumps(graph), - ) + return make_workflow(workflow_id="workflow-1", graph=graph) def _binding(node_job: WorkflowNodeJobConfig) -> WorkflowAgentNodeBinding: 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 938fe289b0a..384340c7d0b 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 @@ -1,4 +1,3 @@ -import json from collections import UserString from datetime import datetime from types import SimpleNamespace @@ -21,8 +20,9 @@ from graphon.node_events import NodeRunResult from graphon.nodes import BuiltinNodeTypes from graphon.runtime import VariablePool from graphon.variables.variables import StringVariable -from models.workflow import Workflow, WorkflowType +from models.workflow import Workflow from tests.unit_tests.config_override import config_overrides_context +from tests.unit_tests.model_factories import make_workflow def _build_typed_node_config(node_type: NodeType): @@ -31,16 +31,7 @@ def _build_typed_node_config(node_type: NodeType): def _workflow() -> Workflow: """Build a real transient workflow for single-step orchestration tests.""" - return Workflow( - id="workflow-id", - tenant_id="tenant-id", - app_id="app-id", - type=WorkflowType.WORKFLOW, - version=Workflow.VERSION_DRAFT, - graph=json.dumps({"nodes": [], "edges": []}), - _features="{}", - created_by="user-id", - ) + return make_workflow(workflow_id="workflow-id", tenant_id="tenant-id", app_id="app-id", created_by="user-id") def _build_minimal_workflow_entry( 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 14303aef5d9..21c64cdd2fb 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 @@ -6,12 +6,11 @@ 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 +from tests.unit_tests.model_factories import make_tenant def _tenant() -> Tenant: - tenant = Tenant(name="Test tenant") - tenant.id = "tenant-1" - return tenant + return make_tenant(name="Test tenant") def test_handle_skips_when_no_default_plugins_are_configured(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/api/tests/unit_tests/libs/test_oauth_bearer_layer0_cache.py b/api/tests/unit_tests/libs/test_oauth_bearer_layer0_cache.py deleted file mode 100644 index 0023f17119e..00000000000 --- a/api/tests/unit_tests/libs/test_oauth_bearer_layer0_cache.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Unit tests for record_layer0_verdict — merge L0 verdict into AuthContext cache.""" - -from __future__ import annotations - -import json -from unittest.mock import MagicMock, patch - -import pytest - -from libs.oauth_bearer import record_layer0_verdict - - -@pytest.fixture -def mock_redis(): - return MagicMock() - - -@patch("libs.oauth_bearer.redis_client") -def test_no_op_when_cache_entry_missing(mock_redis): - mock_redis.get.return_value = None - record_layer0_verdict("h1", "t1", True) - mock_redis.setex.assert_not_called() - - -@patch("libs.oauth_bearer.redis_client") -def test_no_op_when_cache_entry_invalid_marker(mock_redis): - mock_redis.get.return_value = b"invalid" - record_layer0_verdict("h1", "t1", True) - mock_redis.setex.assert_not_called() - - -@patch("libs.oauth_bearer.redis_client") -def test_no_op_when_json_malformed(mock_redis): - mock_redis.get.return_value = b"not json" - record_layer0_verdict("h1", "t1", True) - mock_redis.setex.assert_not_called() - - -@patch("libs.oauth_bearer.redis_client") -def test_no_op_when_ttl_expired(mock_redis): - mock_redis.get.return_value = json.dumps( - { - "subject_email": "e", - "subject_issuer": None, - "account_id": None, - "token_id": "tid", - "expires_at": None, - } - ).encode() - mock_redis.ttl.return_value = -1 - record_layer0_verdict("h1", "t1", True) - mock_redis.setex.assert_not_called() - - -@patch("libs.oauth_bearer.redis_client") -def test_merges_new_tenant_verdict(mock_redis): - mock_redis.get.return_value = json.dumps( - { - "subject_email": "e", - "subject_issuer": None, - "account_id": None, - "token_id": "tid", - "expires_at": None, - "verified_tenants": {"t0": True}, - } - ).encode() - mock_redis.ttl.return_value = 42 - - record_layer0_verdict("h1", "t1", False) - - mock_redis.setex.assert_called_once() - args = mock_redis.setex.call_args - assert args.args[0] == "auth:token:h1" - assert args.args[1] == 42 # remaining TTL preserved - written = json.loads(args.args[2]) - assert written["verified_tenants"] == {"t0": True, "t1": False} - - -@patch("libs.oauth_bearer.redis_client") -def test_merges_when_field_absent_from_legacy_entry(mock_redis): - """Backward compat: legacy cache entry without verified_tenants field.""" - mock_redis.get.return_value = json.dumps( - { - "subject_email": "e", - "subject_issuer": None, - "account_id": None, - "token_id": "tid", - "expires_at": None, - } - ).encode() - mock_redis.ttl.return_value = 42 - record_layer0_verdict("h1", "t1", True) - written = json.loads(mock_redis.setex.call_args.args[2]) - assert written["verified_tenants"] == {"t1": True} diff --git a/api/tests/unit_tests/libs/test_oauth_bearer_require_scope.py b/api/tests/unit_tests/libs/test_oauth_bearer_require_scope.py deleted file mode 100644 index e8204a6e2e0..00000000000 --- a/api/tests/unit_tests/libs/test_oauth_bearer_require_scope.py +++ /dev/null @@ -1,98 +0,0 @@ -"""require_scope is a route-level gate run after validate_bearer. -Tests use a fake auth_ctx published via the openapi auth ContextVar (no -authenticator wiring needed). The `_publish_auth_ctx` helper guarantees -the ContextVar is reset between tests so worker-thread reuse can't leak -identity into the next test. -""" - -from __future__ import annotations - -import uuid -from collections.abc import Iterator -from contextlib import contextmanager - -import pytest -from flask import Flask -from werkzeug.exceptions import Forbidden - -from libs.oauth_bearer import ( - AuthContext, - Scope, - SubjectType, - TokenType, - require_scope, - reset_auth_ctx, - set_auth_ctx, -) - - -@contextmanager -def _publish_auth_ctx(ctx: AuthContext) -> Iterator[None]: - token = set_auth_ctx(ctx) - try: - yield - finally: - reset_auth_ctx(token) - - -@pytest.fixture -def app() -> Flask: - app = Flask(__name__) - app.config["TESTING"] = True - return app - - -def _ctx(scopes) -> AuthContext: - return AuthContext( - subject_type=SubjectType.ACCOUNT, - subject_email="user@example.com", - subject_issuer="dify:account", - account_id=uuid.uuid4(), - client_id="difyctl", - scopes=scopes, - token_id=uuid.uuid4(), - token_type=TokenType.OAUTH_ACCOUNT, - expires_at=None, - token_hash="h1", - verified_tenants={}, - ) - - -def test_require_scope_allows_when_scope_present(app: Flask): - @require_scope("apps:read") - def view(): - return "ok" - - with app.test_request_context(), _publish_auth_ctx(_ctx(frozenset({"apps:read"}))): - assert view() == "ok" - - -def test_require_scope_rejects_when_scope_missing(app: Flask): - @require_scope("apps:write") - def view(): - return "ok" - - with app.test_request_context(), _publish_auth_ctx(_ctx(frozenset({"apps:read"}))): - with pytest.raises(Forbidden) as exc: - view() - assert "insufficient_scope: apps:write" in str(exc.value.description) - - -def test_require_scope_full_passes_any_check(app: Flask): - @require_scope("apps:write") - def view(): - return "ok" - - with app.test_request_context(), _publish_auth_ctx(_ctx(frozenset({Scope.FULL}))): - assert view() == "ok" - - -def test_require_scope_without_validate_bearer_raises_runtime_error(app: Flask): - @require_scope("apps:read") - def view(): - return "ok" - - with app.test_request_context(): - # No auth ContextVar published — validate_bearer was forgotten. - with pytest.raises(RuntimeError, match="stack @validate_bearer above @require_scope"): - 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 deleted file mode 100644 index 6a202e79fa4..00000000000 --- a/api/tests/unit_tests/libs/test_workspace_member_helper.py +++ /dev/null @@ -1,201 +0,0 @@ -"""SQLite-backed unit tests for workspace membership enforcement.""" - -from __future__ import annotations - -import uuid -from collections.abc import Iterator -from dataclasses import dataclass -from unittest.mock import Mock - -import pytest -from sqlalchemy import event -from sqlalchemy.orm import Session -from werkzeug.exceptions import Forbidden - -from 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") - - -@dataclass(frozen=True) -class Database: - """Real ORM binding and executed-statement log for one isolated test.""" - - session: Session - statements: list[str] - - -@pytest.fixture -def database(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Iterator[Database]: - statements: list[str] = [] - - def record_statement(_connection, _cursor, statement, _parameters, _context, _executemany) -> None: - statements.append(statement) - - engine = sqlite_session.get_bind() - event.listen(engine, "before_cursor_execute", record_statement) - binding = Database(session=sqlite_session, statements=statements) - monkeypatch.setattr(oauth_bearer, "db", binding) - try: - yield binding - finally: - event.remove(engine, "before_cursor_execute", record_statement) - - -@pytest.fixture -def community_edition(monkeypatch: pytest.MonkeyPatch) -> None: - apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) - - -def _ctx( - verified: dict[str, bool] | None = None, - *, - account_id: uuid.UUID | None = None, - account: bool = True, -) -> AuthContext: - return AuthContext( - subject_type=SubjectType.ACCOUNT if account else SubjectType.EXTERNAL_SSO, - subject_email="e@example.com", - subject_issuer=None, - account_id=account_id or (uuid.uuid4() if account else None), - client_id="difyctl", - scopes=frozenset({Scope.FULL}), - token_id=uuid.uuid4(), - token_type=TokenType.OAUTH_ACCOUNT if account else TokenType.OAUTH_EXTERNAL_SSO, - expires_at=None, - token_hash="h1", - verified_tenants=dict(verified or {}), - ) - - -def _persist_membership( - session: Session, - *, - account_id: uuid.UUID, - tenant_id: str, - status: AccountStatus = AccountStatus.ACTIVE, -) -> None: - account = _account(account_id, status=status) - tenant = Tenant(name=f"Tenant {tenant_id}") - tenant.id = tenant_id - membership = TenantAccountJoin( - tenant_id=tenant_id, - account_id=account_id.hex, - role=TenantAccountRole.NORMAL, - ) - session.add_all([account, tenant, membership]) - session.commit() - - -def _account(account_id: uuid.UUID, *, status: AccountStatus = AccountStatus.ACTIVE) -> Account: - account = Account(name="Workspace member", email=f"{account_id}@example.com", status=status) - # SQLite's StringUUID adapter binds UUID objects as compact hex, while - # PostgreSQL binds their dashed string form. Persist the SQLite-bound form - # so the production query can keep accepting the AuthContext UUID object. - account.id = account_id.hex - return account - - -def test_skips_for_enterprise_edition(database: Database, monkeypatch: pytest.MonkeyPatch) -> None: - apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) - before = len(database.statements) - - require_workspace_member(_ctx(), "tenant-1") - - assert len(database.statements) == before - - -def test_skips_for_external_sso(database: Database) -> None: - before = len(database.statements) - - require_workspace_member(_ctx(account=False), "tenant-1") - - assert len(database.statements) == before - - -def test_uses_cached_allow_without_database_access(database: Database) -> None: - before = len(database.statements) - - require_workspace_member(_ctx({"tenant-1": True}), "tenant-1") - - assert len(database.statements) == before - - -def test_uses_cached_denial_without_database_access(database: Database) -> None: - before = len(database.statements) - - with pytest.raises(Forbidden, match="workspace_membership_revoked"): - require_workspace_member(_ctx({"tenant-1": False}), "tenant-1") - - assert len(database.statements) == before - - -@pytest.mark.usefixtures("database") -def test_denies_when_membership_is_absent( - monkeypatch: pytest.MonkeyPatch, -) -> None: - record_verdict = Mock() - monkeypatch.setattr(oauth_bearer, "record_layer0_verdict", record_verdict) - - with pytest.raises(Forbidden, match="workspace_membership_revoked"): - require_workspace_member(_ctx(), "tenant-1") - - record_verdict.assert_called_once_with("h1", "tenant-1", False) - - -def test_denies_membership_from_another_tenant( - database: Database, - monkeypatch: pytest.MonkeyPatch, -) -> None: - account_id = uuid.uuid4() - requested_tenant_member_id = uuid.uuid4() - status_decoy_id = uuid.uuid4() - _persist_membership(database.session, account_id=account_id, tenant_id="tenant-2") - _persist_membership(database.session, account_id=requested_tenant_member_id, tenant_id="tenant-1") - database.session.add(_account(status_decoy_id, status=AccountStatus.BANNED)) - database.session.commit() - record_verdict = Mock() - monkeypatch.setattr(oauth_bearer, "record_layer0_verdict", record_verdict) - - with pytest.raises(Forbidden, match="workspace_membership_revoked"): - require_workspace_member(_ctx(account_id=account_id), "tenant-1") - - record_verdict.assert_called_once_with("h1", "tenant-1", False) - - -def test_denies_when_account_is_inactive( - database: Database, - monkeypatch: pytest.MonkeyPatch, -) -> None: - account_id = uuid.uuid4() - _persist_membership( - database.session, - account_id=account_id, - tenant_id="tenant-1", - status=AccountStatus.BANNED, - ) - record_verdict = Mock() - monkeypatch.setattr(oauth_bearer, "record_layer0_verdict", record_verdict) - - with pytest.raises(Forbidden, match="workspace_membership_revoked"): - require_workspace_member(_ctx(account_id=account_id), "tenant-1") - - record_verdict.assert_called_once_with("h1", "tenant-1", False) - - -def test_allows_active_member_and_records_verdict( - database: Database, - monkeypatch: pytest.MonkeyPatch, -) -> None: - account_id = uuid.uuid4() - _persist_membership(database.session, account_id=account_id, tenant_id="tenant-1") - record_verdict = Mock() - monkeypatch.setattr(oauth_bearer, "record_layer0_verdict", record_verdict) - - require_workspace_member(_ctx(account_id=account_id), "tenant-1") - - record_verdict.assert_called_once_with("h1", "tenant-1", True) diff --git a/api/tests/unit_tests/model_factories.py b/api/tests/unit_tests/model_factories.py new file mode 100644 index 00000000000..3aeb0f061b5 --- /dev/null +++ b/api/tests/unit_tests/model_factories.py @@ -0,0 +1,489 @@ +"""Builders for the model instances unit tests construct over and over. + +Every builder returns a transient (unpersisted) instance carrying the field set +that most call sites need, so a test module only spells out the values it +actually asserts on. Keyword arguments map one-to-one onto model columns. +""" + +import json +from collections.abc import Mapping, Sequence +from datetime import datetime +from decimal import Decimal + +from extensions.storage.storage_type import StorageType +from graphon.variables import VariableBase +from models.account import Account, AccountStatus, Tenant, TenantAccountRole, TenantStatus +from models.dataset import Dataset, Document +from models.enums import ( + AppStatus, + CreatorUserRole, + DataSourceType, + DocumentCreatedFrom, + EndUserType, + PermissionEnum, +) +from models.model import ( + App, + AppMode, + Conversation, + ConversationFromSource, + EndUser, + IconType, + Message, + UploadFile, +) +from models.workflow import Workflow, WorkflowKind, WorkflowType +from services.entities.account_entities import AccountSnapshot + + +def make_app( + *, + app_id: str = "app-1", + tenant_id: str = "tenant-1", + name: str = "Test App", + description: str = "", + mode: AppMode = AppMode.CHAT, + icon_type: IconType | None = IconType.EMOJI, + icon: str | None = "robot", + icon_background: str | None = "#FFFFFF", + status: AppStatus = AppStatus.NORMAL, + app_model_config_id: str | None = None, + workflow_id: str | None = None, + enable_site: bool = True, + enable_api: bool = True, + api_rpm: int = 0, + api_rph: int = 0, + is_demo: bool = False, + is_public: bool = False, + is_universal: bool = False, + max_active_requests: int | None = None, + tracing: str | None = None, + created_by: str | None = None, + maintainer: str | None = None, + use_icon_as_answer_icon: bool = False, +) -> App: + """Build a transient ``App``. + + ``app_id`` is spelled out instead of ``id`` so call sites never shadow the + builtin. ``icon_type=None`` clears ``icon`` and ``icon_background`` too, for + the tests that assert on an app without icon metadata. + """ + if icon_type is None: + icon = None + icon_background = None + return App( + id=app_id, + tenant_id=tenant_id, + name=name, + description=description, + mode=mode, + icon_type=icon_type, + icon=icon, + icon_background=icon_background, + status=status, + app_model_config_id=app_model_config_id, + workflow_id=workflow_id, + enable_site=enable_site, + enable_api=enable_api, + api_rpm=api_rpm, + api_rph=api_rph, + is_demo=is_demo, + is_public=is_public, + is_universal=is_universal, + max_active_requests=max_active_requests, + tracing=tracing, + created_by=created_by, + maintainer=maintainer, + use_icon_as_answer_icon=use_icon_as_answer_icon, + ) + + +def make_account( + *, + account_id: str | None = "account-1", + name: str = "Test User", + email: str = "test@example.com", + status: AccountStatus = AccountStatus.ACTIVE, + interface_language: str | None = None, + timezone: str | None = None, + role: TenantAccountRole | None = None, + tenant: Tenant | None = None, +) -> Account: + """Build a transient ``Account``. + + ``id``, ``role`` and ``_current_tenant`` are ``init=False`` on the dataclass + model, so they are assigned after construction. Passing ``account_id=None`` + keeps the generated identifier. + """ + account = Account( + name=name, + email=email, + status=status, + interface_language=interface_language, + timezone=timezone, + ) + if account_id is not None: + account.id = account_id + account.role = role + account._current_tenant = tenant + return account + + +def make_tenant( + *, + tenant_id: str | None = "tenant-1", + name: str = "Test Tenant", + status: TenantStatus = TenantStatus.NORMAL, + encrypt_public_key: str | None = None, +) -> Tenant: + """Build a transient ``Tenant``; ``tenant_id=None`` keeps the generated one.""" + tenant = Tenant(name=name, status=status, encrypt_public_key=encrypt_public_key) + if tenant_id is not None: + tenant.id = tenant_id + return tenant + + +def make_end_user( + *, + end_user_id: str = "end-user-1", + tenant_id: str = "tenant-1", + app_id: str | None = None, + end_user_type: EndUserType = EndUserType.BROWSER, + session_id: str = "session-1", + external_user_id: str | None = None, + name: str | None = None, + is_anonymous: bool = True, +) -> EndUser: + """Build a transient ``EndUser``.""" + return EndUser( + id=end_user_id, + tenant_id=tenant_id, + app_id=app_id, + type=end_user_type, + session_id=session_id, + external_user_id=external_user_id, + name=name, + is_anonymous=is_anonymous, + ) + + +def make_account_snapshot( + *, + account_id: str = "account-1", + name: str = "Account", + email: str = "account@example.com", + avatar: str | None = None, + is_password_set: bool = False, + interface_language: str | None = "en-US", + interface_theme: str | None = "light", + timezone: str | None = "UTC", + last_login_at: datetime | None = None, + last_login_ip: str | None = None, + status: str = "active", + initialized_at: datetime | None = None, + created_at: datetime = datetime(2026, 1, 1), +) -> AccountSnapshot: + """Build the framework-neutral ``AccountSnapshot`` the account services return.""" + return AccountSnapshot( + id=account_id, + name=name, + email=email, + avatar=avatar, + is_password_set=is_password_set, + interface_language=interface_language, + interface_theme=interface_theme, + timezone=timezone, + last_login_at=last_login_at, + last_login_ip=last_login_ip, + status=status, + initialized_at=initialized_at, + created_at=created_at, + ) + + +def make_workflow( + *, + workflow_id: str | None = None, + tenant_id: str = "tenant-1", + app_id: str = "app-1", + workflow_type: WorkflowType = WorkflowType.WORKFLOW, + kind: WorkflowKind = WorkflowKind.STANDARD, + version: str = Workflow.VERSION_DRAFT, + graph: str | Mapping[str, object] | None = None, + features: str | Mapping[str, object] = "{}", + created_by: str = "account-1", + environment_variables: Sequence[VariableBase] = (), + conversation_variables: Sequence[VariableBase] = (), + rag_pipeline_variables: list[dict[str, object]] | None = None, + marked_name: str = "", + marked_comment: str = "", + version_number: int | None = None, +) -> Workflow: + """Build a transient ``Workflow`` through ``Workflow.new``. + + ``graph`` and ``features`` accept either the serialized column value or the + mapping to serialize, since most call sites start from a graph literal. + ``workflow_id=None`` keeps the identifier ``Workflow.new`` generates. + """ + workflow = Workflow.new( + tenant_id=tenant_id, + app_id=app_id, + type=workflow_type.value, + kind=kind.value, + version=version, + graph=graph if isinstance(graph, str) else json.dumps({"nodes": [], "edges": []} if graph is None else graph), + features=features if isinstance(features, str) else json.dumps(features), + created_by=created_by, + environment_variables=environment_variables, + conversation_variables=conversation_variables, + rag_pipeline_variables=rag_pipeline_variables or [], + marked_name=marked_name, + marked_comment=marked_comment, + version_number=version_number, + ) + if workflow_id is not None: + workflow.id = workflow_id + return workflow + + +def make_upload_file( + *, + file_id: str | None = None, + tenant_id: str = "tenant-1", + storage_type: StorageType = StorageType.LOCAL, + key: str = "uploads/file-1", + name: str = "test.txt", + size: int = 10, + extension: str = "txt", + mime_type: str = "text/plain", + created_by_role: CreatorUserRole = CreatorUserRole.ACCOUNT, + created_by: str = "account-1", + created_at: datetime | None = None, + used: bool = False, + source_url: str = "", +) -> UploadFile: + """Build a transient ``UploadFile``; ``file_id=None`` keeps the generated one.""" + upload_file = UploadFile( + tenant_id=tenant_id, + storage_type=storage_type, + key=key, + name=name, + size=size, + extension=extension, + mime_type=mime_type, + created_by_role=created_by_role, + created_by=created_by, + created_at=created_at if created_at is not None else datetime(2024, 1, 1), + used=used, + source_url=source_url, + ) + if file_id is not None: + upload_file.id = file_id + return upload_file + + +def make_dataset( + *, + dataset_id: str | None = "dataset-1", + tenant_id: str = "tenant-1", + name: str = "Dataset", + description: str | None = None, + provider: str | None = None, + permission: PermissionEnum | None = None, + data_source_type: DataSourceType | None = None, + indexing_technique: str | None = None, + index_struct: str | None = None, + created_by: str = "account-1", + maintainer: str | None = None, + created_at: datetime | None = None, + embedding_model: str | None = None, + embedding_model_provider: str | None = None, + keyword_number: int | None = None, + built_in_field_enabled: bool | None = None, + icon_info: dict[str, str] | None = None, + pipeline_id: str | None = None, + chunk_structure: str | None = None, + enable_api: bool | None = None, +) -> Dataset: + """Build a transient ``Dataset``. + + Optional columns default to ``None`` so an unset keyword leaves the column + unset, matching a bare ``Dataset(...)``. ``dataset_id=None`` keeps the + generated identifier. + """ + values: dict[str, object] = {"tenant_id": tenant_id, "name": name, "created_by": created_by} + optional: dict[str, object | None] = { + "id": dataset_id, + "description": description, + "provider": provider, + "permission": permission, + "data_source_type": data_source_type, + "indexing_technique": indexing_technique, + "index_struct": index_struct, + "maintainer": maintainer, + "created_at": created_at, + "embedding_model": embedding_model, + "embedding_model_provider": embedding_model_provider, + "keyword_number": keyword_number, + "built_in_field_enabled": built_in_field_enabled, + "icon_info": icon_info, + "pipeline_id": pipeline_id, + "chunk_structure": chunk_structure, + "enable_api": enable_api, + } + values.update({key: value for key, value in optional.items() if value is not None}) + return Dataset(**values) + + +def make_conversation( + *, + conversation_id: str | None = "conversation-1", + app_id: str = "app-1", + mode: AppMode = AppMode.CHAT, + name: str = "Conversation", + inputs: Mapping[str, object] | None = None, + status: str | None = None, + from_source: ConversationFromSource | None = None, + from_account_id: str | None = None, + from_end_user_id: str | None = None, + invoke_from: str | None = None, + introduction: str | None = None, + system_instruction: str | None = None, + system_instruction_tokens: int | None = None, + override_model_configs: str | None = None, + summary: str | None = None, + dialogue_count: int | None = None, + is_deleted: bool | None = None, + agent_workspace_binding_id: str | None = None, +) -> Conversation: + """Build a transient ``Conversation``; unset keywords leave their column unset.""" + values: dict[str, object] = {"app_id": app_id, "mode": mode, "name": name} + optional: dict[str, object | None] = { + "id": conversation_id, + "inputs": inputs, + "status": status, + "from_source": from_source, + "from_account_id": from_account_id, + "from_end_user_id": from_end_user_id, + "invoke_from": invoke_from, + "introduction": introduction, + "system_instruction": system_instruction, + "system_instruction_tokens": system_instruction_tokens, + "override_model_configs": override_model_configs, + "summary": summary, + "dialogue_count": dialogue_count, + "is_deleted": is_deleted, + "agent_workspace_binding_id": agent_workspace_binding_id, + } + values.update({key: value for key, value in optional.items() if value is not None}) + return Conversation(**values) + + +def make_message( + *, + message_id: str | None = "message-1", + app_id: str | None = "app-1", + conversation_id: str | None = "conversation-1", + inputs: Mapping[str, object] | None = None, + query: str | None = None, + message: object | None = None, + answer: str | None = None, + status: str | None = None, + message_unit_price: Decimal | None = None, + answer_unit_price: Decimal | None = None, + total_price: Decimal | None = None, + currency: str | None = None, + from_source: ConversationFromSource | None = None, + from_account_id: str | None = None, + from_end_user_id: str | None = None, + invoke_from: str | None = None, + message_tokens: int | None = None, + answer_tokens: int | None = None, + provider_response_latency: float | None = None, + workflow_run_id: str | None = None, + created_at: datetime | None = None, + updated_at: datetime | None = None, +) -> Message: + """Build a transient ``Message``; unset keywords leave their column unset.""" + values: dict[str, object] = {} + optional: dict[str, object | None] = { + "id": message_id, + "app_id": app_id, + "conversation_id": conversation_id, + "inputs": inputs, + "query": query, + "message": message, + "answer": answer, + "status": status, + "message_unit_price": message_unit_price, + "answer_unit_price": answer_unit_price, + "total_price": total_price, + "currency": currency, + "from_source": from_source, + "from_account_id": from_account_id, + "from_end_user_id": from_end_user_id, + "invoke_from": invoke_from, + "message_tokens": message_tokens, + "answer_tokens": answer_tokens, + "provider_response_latency": provider_response_latency, + "workflow_run_id": workflow_run_id, + "created_at": created_at, + "updated_at": updated_at, + } + values.update({key: value for key, value in optional.items() if value is not None}) + return Message(**values) + + +def make_document( + *, + document_id: str | None = "document-1", + tenant_id: str = "tenant-1", + dataset_id: str = "dataset-1", + position: int = 1, + data_source_type: DataSourceType = DataSourceType.UPLOAD_FILE, + data_source_info: str | None = None, + batch: str = "batch-1", + name: str = "Document", + created_from: str = DocumentCreatedFrom.WEB, + created_by: str = "account-1", + doc_form: str | None = None, + doc_metadata: dict[str, object] | None = None, + created_at: datetime | None = None, + updated_at: datetime | None = None, +) -> Document: + """Build a transient ``Document``; unset keywords leave their column unset.""" + values: dict[str, object] = { + "tenant_id": tenant_id, + "dataset_id": dataset_id, + "position": position, + "data_source_type": data_source_type, + "batch": batch, + "name": name, + "created_from": created_from, + "created_by": created_by, + } + optional: dict[str, object | None] = { + "id": document_id, + "data_source_info": data_source_info, + "doc_form": doc_form, + "doc_metadata": doc_metadata, + "created_at": created_at, + "updated_at": updated_at, + } + values.update({key: value for key, value in optional.items() if value is not None}) + return Document(**values) + + +__all__ = [ + "make_account", + "make_account_snapshot", + "make_app", + "make_conversation", + "make_dataset", + "make_document", + "make_end_user", + "make_message", + "make_tenant", + "make_upload_file", + "make_workflow", +] diff --git a/api/tests/unit_tests/models/test_comment_models.py b/api/tests/unit_tests/models/test_comment_models.py index 480b6fadcc3..0dc1e3339d2 100644 --- a/api/tests/unit_tests/models/test_comment_models.py +++ b/api/tests/unit_tests/models/test_comment_models.py @@ -5,12 +5,11 @@ from sqlalchemy.orm import Session from models.account import Account from models.comment import WorkflowComment, WorkflowCommentMention, WorkflowCommentReply +from tests.unit_tests.model_factories import make_account def _account(name: str) -> Account: - account = Account(name=name, email=f"{name.lower()}@example.com") - account.id = str(uuid4()) - return account + return make_account(account_id=str(uuid4()), name=name, email=f"{name.lower()}@example.com") def _comment(created_by: str, resolved_by: str | None = None) -> WorkflowComment: diff --git a/api/tests/unit_tests/models/test_workflow_run_accessors.py b/api/tests/unit_tests/models/test_workflow_run_accessors.py new file mode 100644 index 00000000000..62c94d004ee --- /dev/null +++ b/api/tests/unit_tests/models/test_workflow_run_accessors.py @@ -0,0 +1,114 @@ +"""Regression coverage for ``WorkflowRun`` 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. Also covers the session-carrying response +sources used by the console endpoints that validate the run detail / pagination responses +with ``from_attributes=True``. +""" + +from types import SimpleNamespace +from uuid import uuid4 + +from sqlalchemy.orm import Session + +from fields.workflow_run_fields import workflow_run_pagination_response_source, workflow_run_response_source +from models.account import Account +from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom +from models.model import EndUser +from models.workflow import WorkflowRun + + +def _run(role: CreatorUserRole, created_by: str) -> WorkflowRun: + """Construct a WorkflowRun without touching the database.""" + return WorkflowRun( + id=str(uuid4()), + tenant_id="00000000-0000-0000-0000-000000000001", + app_id="00000000-0000-0000-0000-000000000002", + workflow_id=str(uuid4()), + type="workflow", + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + version="1.0", + status="succeeded", + 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() + run = _run(CreatorUserRole.ACCOUNT, created_by=account.id) + + result = run.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() + run = _run(CreatorUserRole.END_USER, created_by=account.id) + + assert run.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() + run = _run(CreatorUserRole.END_USER, created_by=end_user.id) + + result = run.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() + run = _run(CreatorUserRole.ACCOUNT, created_by=end_user.id) + + assert run.created_by_end_user(session=sqlite_session) is None + + +class TestWorkflowRunResponseSources: + def test_detail_source_resolves_accessors_and_proxies_other_attributes(self, sqlite_session: Session) -> None: + account = Account(name="Test Account", email="test@example.com") + sqlite_session.add(account) + sqlite_session.flush() + run = _run(CreatorUserRole.ACCOUNT, created_by=account.id) + + source = workflow_run_response_source(run, session=sqlite_session) + + resolved = source.created_by_account + assert resolved is not None + assert resolved.id == account.id + assert source.created_by_end_user is None + assert source.version == "1.0" + + def test_pagination_source_wraps_each_run(self, sqlite_session: Session) -> None: + account = Account(name="Test Account", email="test@example.com") + sqlite_session.add(account) + sqlite_session.flush() + runs = [_run(CreatorUserRole.ACCOUNT, created_by=account.id) for _ in range(2)] + pagination = SimpleNamespace(data=runs, limit=20, has_more=False) + + payload = workflow_run_pagination_response_source(pagination, session=sqlite_session) + + assert payload["limit"] == 20 + assert payload["has_more"] is False + assert [item.created_by_account.id for item in payload["data"]] == [account.id, account.id] 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 32a6e5a54d8..db6a8c19cc0 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 @@ -18,7 +18,7 @@ from models.enums import ( FeedbackRating, MessageStatus, ) -from models.model import App, AppMode, Conversation, IconType, Message, MessageFeedback +from models.model import App, AppMode, Conversation, Message, MessageFeedback from models.workflow import ( WorkflowExecutionStatus, WorkflowNodeExecutionModel, @@ -30,25 +30,16 @@ 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 +from tests.unit_tests.model_factories import make_app, make_conversation, make_message def _app(*, app_id: str = "app-1", name: str = "Iris", mode: AppMode = AppMode.AGENT_CHAT) -> App: - return App( - id=app_id, - tenant_id="tenant-1", - name=name, - mode=mode, - icon_type=IconType.EMOJI, - icon="robot", - icon_background="#fff", - enable_site=False, - enable_api=False, - ) + return make_app(app_id=app_id, name=name, mode=mode, icon_background="#fff", enable_site=False, enable_api=False) def _conversation(*, conversation_id: str = "conversation-1", app_id: str = "app-1") -> Conversation: - return Conversation( - id=conversation_id, + return make_conversation( + conversation_id=conversation_id, app_id=app_id, mode=AppMode.AGENT_CHAT, name="Debug conversation", @@ -66,8 +57,8 @@ def _message( created_at: datetime | None = None, ) -> Message: timestamp = created_at or naive_utc_now() - return Message( - id=message_id, + return make_message( + message_id=message_id, app_id=app_id, conversation_id=conversation_id, inputs={}, diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index 7b1558e7999..ef219521c6b 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -37,11 +37,11 @@ from models.agent_config_entities import ( DeclaredOutputType, WorkflowNodeJobConfig, ) -from models.enums import AppStatus, ConversationFromSource, ConversationStatus +from models.enums import ConversationFromSource, ConversationStatus from models.model import App, AppMode, AppModelConfig, Conversation, IconType, Message from models.skill import AgentSkillBindingSnapshot, Skill, SkillVersion, SkillVersionManifest from models.tools import ToolFile -from models.workflow import Workflow, WorkflowType +from models.workflow import Workflow from services.agent import composer_service, roster_service from services.agent.agent_soul_state import agent_soul_has_model from services.agent.composer_service import AgentComposerService @@ -66,6 +66,7 @@ from services.entities.agent_entities import ( ComposerSaveStrategy, ComposerVariant, ) +from tests.unit_tests.model_factories import make_account, make_app, make_conversation, make_workflow def _agent_soul_with_model() -> AgentSoulConfig: @@ -124,14 +125,13 @@ def _snapshot( def _conversation(*, conversation_id: str = "conversation-1", account_id: str = "account-1") -> Conversation: - return Conversation( - id=conversation_id, - app_id="app-1", - override_model_configs="{}", + return make_conversation( + conversation_id=conversation_id, mode=AppMode.AGENT_CHAT, name="Debug", + inputs={}, + override_model_configs="{}", summary="", - _inputs={}, introduction="", system_instruction="", status=ConversationStatus.NORMAL, @@ -142,19 +142,7 @@ def _conversation(*, conversation_id: str = "conversation-1", account_id: str = def _workflow(*, workflow_id: str = "workflow-1", tenant_id: str = "tenant-1", app_id: str = "app-1") -> Workflow: - return Workflow( - id=workflow_id, - tenant_id=tenant_id, - app_id=app_id, - type=WorkflowType.WORKFLOW, - version=Workflow.VERSION_DRAFT, - graph='{"nodes": [], "edges": []}', - _features="{}", - created_by="account-1", - _environment_variables="{}", - _conversation_variables="{}", - _rag_pipeline_variables="{}", - ) + return make_workflow(workflow_id=workflow_id, tenant_id=tenant_id, app_id=app_id) def _app( @@ -164,27 +152,20 @@ def _app( name: str = "Agent App", mode: AppMode = AppMode.AGENT_CHAT, ) -> App: - return App( - id=app_id, + return make_app( + app_id=app_id, tenant_id=tenant_id, name=name, - description="", mode=mode, - icon_type=IconType.EMOJI, icon="🤖", icon_background="#fff", - status=AppStatus.NORMAL, enable_site=False, - enable_api=True, - max_active_requests=None, created_by="account-1", ) def _account(*, account_id: str = "account-1") -> Account: - account = Account(name="Agent Tester", email=f"{account_id}@example.com") - account.id = account_id - return account + return make_account(account_id=account_id, name="Agent Tester", email=f"{account_id}@example.com") def test_agent_soul_has_model(): 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 d50f3589b68..fa8eee4c361 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 @@ -17,24 +17,14 @@ from models.agent import ( 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 models.workflow import Workflow from services.agent.dsl_service import AgentDslService from services.agent.workflow_publish_service import WorkflowAgentPublishService +from tests.unit_tests.model_factories import make_workflow def _workflow(*, workflow_id: str = "workflow-1", version: str = Workflow.VERSION_DRAFT) -> Workflow: - return Workflow( - id=workflow_id, - tenant_id="tenant-1", - app_id="app-1", - type=WorkflowType.WORKFLOW, - version=version, - graph={"nodes": [], "edges": []}, - features={}, - created_by="account-1", - environment_variables=[], - conversation_variables=[], - ) + return make_workflow(workflow_id=workflow_id, version=version) def _inline_agent( diff --git a/api/tests/unit_tests/services/openapi/test_mint_policy.py b/api/tests/unit_tests/services/openapi/test_mint_policy.py index e3bf6cd9d0e..c3df657ed4c 100644 --- a/api/tests/unit_tests/services/openapi/test_mint_policy.py +++ b/api/tests/unit_tests/services/openapi/test_mint_policy.py @@ -10,7 +10,6 @@ from __future__ import annotations import pytest -from enums import DeploymentEdition from libs.oauth_bearer import MINTABLE_PROFILES, Scope, SubjectType from services.openapi.mint_policy import MintPolicyViolation, validate_mint_policy @@ -73,50 +72,3 @@ def test_message_carries_both_drift_reasons(): msg = str(exc.value) assert "prefix" in msg assert "scopes" in msg - - -def test_license_required_decorator_skips_on_ce(config_overrides): - from services.openapi.license_gate import license_required - - @license_required - def view(): - return "ok" - - config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) - assert view() == "ok" - - -def test_license_required_decorator_403_on_invalid_ee_license(config_overrides): - from unittest.mock import patch - - from werkzeug.exceptions import Forbidden - - from services.openapi.license_gate import license_required - - @license_required - def view(): - return "ok" - - config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) - with ( - patch("services.openapi.license_gate._is_license_valid", return_value=False), - ): - with pytest.raises(Forbidden) as exc: - view() - assert "license_required" in exc.value.description - - -def test_license_required_decorator_passes_on_valid_ee_license(config_overrides): - from unittest.mock import patch - - from services.openapi.license_gate import license_required - - @license_required - def view(): - return "ok" - - config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) - with ( - patch("services.openapi.license_gate._is_license_valid", return_value=True), - ): - assert view() == "ok" 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 44859fb7e2d..de56869d4a2 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 @@ -22,7 +22,7 @@ from sqlalchemy.orm import Session, sessionmaker from core.workflow.llm_environment_variable import LLMEnvironmentVariable from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE from graphon.enums import BuiltinNodeTypes -from models import Account, Tenant +from models import Account from models.dataset import ( Dataset, Pipeline, @@ -39,6 +39,7 @@ from services.rag_pipeline.rag_pipeline_dsl_service import ( RagPipelineDslService, RagPipelinePendingData, ) +from tests.unit_tests.model_factories import make_account, make_dataset, make_tenant, make_workflow @pytest.fixture @@ -47,12 +48,12 @@ def service(sqlite_session: Session) -> RagPipelineDslService: def _account(*, tenant_id: str = "tenant-1", account_id: str = "account-1") -> Account: - tenant = Tenant(name="Tenant") - tenant.id = tenant_id - account = Account(name="Account", email="account@example.com") - account.id = account_id - account._current_tenant = tenant - return account + return make_account( + account_id=account_id, + name="Account", + email="account@example.com", + tenant=make_tenant(tenant_id=tenant_id, name="Tenant"), + ) def _pipeline( @@ -76,19 +77,12 @@ def _pipeline( def _workflow(session: Session, pipeline: Pipeline, *, graph: dict[str, Any] | None = None) -> Workflow: - workflow = Workflow( - id=f"workflow-{pipeline.id}", + workflow = make_workflow( + workflow_id=f"workflow-{pipeline.id}", tenant_id=pipeline.tenant_id, app_id=pipeline.id, - type=WorkflowType.RAG_PIPELINE, - kind=WorkflowKind.STANDARD, - version=Workflow.VERSION_DRAFT, - graph=json.dumps(graph or {"nodes": [], "edges": []}), - features="{}", - created_by="account-1", - environment_variables=[], - conversation_variables=[], - rag_pipeline_variables=[], + workflow_type=WorkflowType.RAG_PIPELINE, + graph=graph, ) pipeline.workflow_id = workflow.id session.add(workflow) @@ -124,13 +118,13 @@ def _dataset( name: str = "Dataset", chunk_structure: str = "text_model", ) -> Dataset: - dataset = Dataset( + dataset = make_dataset( + dataset_id=None, tenant_id=pipeline.tenant_id, name=name, description="description", data_source_type=DataSourceType.UPLOAD_FILE, indexing_technique="high_quality", - created_by="account-1", maintainer="account-1", chunk_structure=chunk_structure, pipeline_id=pipeline.id, 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 be5728f396e..43cfb85a828 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 @@ -7,14 +7,14 @@ from pytest_mock import MockerFixture from sqlalchemy import select from sqlalchemy.orm import Session -from extensions.storage.storage_type import StorageType from models.dataset import Dataset, Document, DocumentPipelineExecutionLog, Pipeline -from models.enums import CreatorUserRole, DataSourceType, DocumentCreatedFrom +from models.enums import DataSourceType, DocumentCreatedFrom 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 +from tests.unit_tests.model_factories import make_upload_file def _dataset(**overrides: object) -> Dataset: @@ -60,21 +60,14 @@ def _pipeline(*, pipeline_id: str = "p-new", tenant_id: str = "t1") -> Pipeline: def _upload_file(*, file_id: str = "file-1", tenant_id: str = "tenant-1") -> UploadFile: - upload_file = UploadFile( + return make_upload_file( + file_id=file_id, tenant_id=tenant_id, - storage_type=StorageType.LOCAL, key="files/f.txt", name="f.txt", - size=10, - extension="txt", - mime_type="text/plain", - created_by_role=CreatorUserRole.ACCOUNT, created_by="user-1", created_at=datetime.now(UTC).replace(tzinfo=None), - used=False, ) - upload_file.id = file_id - return upload_file @pytest.mark.parametrize( diff --git a/api/tests/unit_tests/services/test_account_change_email_service.py b/api/tests/unit_tests/services/test_account_change_email_service.py index 3fb3e8d6837..1556657038e 100644 --- a/api/tests/unit_tests/services/test_account_change_email_service.py +++ b/api/tests/unit_tests/services/test_account_change_email_service.py @@ -33,6 +33,7 @@ from services.entities.account_entities import ( AccountEmailResetStatus, AccountSnapshot, ) +from tests.unit_tests.model_factories import make_account_snapshot def _context() -> RequestContext: @@ -45,21 +46,7 @@ def _context() -> RequestContext: def _account(*, email: str = "old@example.com") -> AccountSnapshot: - return AccountSnapshot( - id="account-1", - name="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), - ) + return make_account_snapshot(email=email, is_password_set=True, initialized_at=datetime(2026, 1, 1)) def _service() -> tuple[AccountChangeEmailService, dict[str, Mock]]: diff --git a/api/tests/unit_tests/services/test_account_deletion_service.py b/api/tests/unit_tests/services/test_account_deletion_service.py index 0ba68d9d5f8..7d0f0e05ca6 100644 --- a/api/tests/unit_tests/services/test_account_deletion_service.py +++ b/api/tests/unit_tests/services/test_account_deletion_service.py @@ -17,6 +17,7 @@ from services.account_ports import ( AccountWorkspaceMembershipQuery, ) from services.entities.account_entities import AccountDeletionChallenge, AccountSnapshot +from tests.unit_tests.model_factories import make_account_snapshot def _context() -> RequestContext: @@ -29,21 +30,7 @@ def _context() -> RequestContext: def _account() -> AccountSnapshot: - return AccountSnapshot( - id="account-1", - name="Account", - email="account@example.com", - 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), - ) + return make_account_snapshot(is_password_set=True, initialized_at=datetime(2026, 1, 1)) def _service(*, accounts: Mock | None = None) -> tuple[AccountDeletionService, dict[str, Mock]]: diff --git a/api/tests/unit_tests/services/test_account_education_service.py b/api/tests/unit_tests/services/test_account_education_service.py index 5f413eeee62..96194f44e31 100644 --- a/api/tests/unit_tests/services/test_account_education_service.py +++ b/api/tests/unit_tests/services/test_account_education_service.py @@ -20,6 +20,7 @@ from services.entities.account_entities import ( AccountEducationVerification, AccountSnapshot, ) +from tests.unit_tests.model_factories import make_account_snapshot def _context() -> RequestContext: @@ -32,20 +33,11 @@ def _context() -> RequestContext: def _account() -> AccountSnapshot: - return AccountSnapshot( - id="account-1", + return make_account_snapshot( name="Student", email="student@example.edu", - 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), ) 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 index b3091f51438..513b727e581 100644 --- a/api/tests/unit_tests/services/test_account_email_registration_service.py +++ b/api/tests/unit_tests/services/test_account_email_registration_service.py @@ -29,23 +29,15 @@ from services.entities.account_entities import ( AccountSessionTokens, AccountSnapshot, ) +from tests.unit_tests.model_factories import make_account_snapshot def _account(*, email: str = "stored@example.com") -> AccountSnapshot: - return AccountSnapshot( - id="account-1", + return make_account_snapshot( 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), ) diff --git a/api/tests/unit_tests/services/test_account_initialization_service.py b/api/tests/unit_tests/services/test_account_initialization_service.py index e929e1ff11d..212fc61d149 100644 --- a/api/tests/unit_tests/services/test_account_initialization_service.py +++ b/api/tests/unit_tests/services/test_account_initialization_service.py @@ -19,6 +19,7 @@ from services.entities.account_entities import ( AccountInitializationStatus, AccountSnapshot, ) +from tests.unit_tests.model_factories import make_account_snapshot def _context() -> RequestContext: @@ -31,21 +32,7 @@ def _context() -> RequestContext: def _account(*, status: str = "uninitialized") -> 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=status, - initialized_at=None, - created_at=datetime(2026, 1, 1), - ) + return make_account_snapshot(status=status) def test_cloud_initialization_consumes_invitation_and_updates_account_atomically() -> None: diff --git a/api/tests/unit_tests/services/test_account_password_service.py b/api/tests/unit_tests/services/test_account_password_service.py index d4b9f7c2050..19ccad4125e 100644 --- a/api/tests/unit_tests/services/test_account_password_service.py +++ b/api/tests/unit_tests/services/test_account_password_service.py @@ -1,6 +1,5 @@ from __future__ import annotations -from datetime import datetime from unittest.mock import Mock import pytest @@ -10,6 +9,7 @@ from services.account_errors import AccountNotFoundError, CurrentAccountPassword from services.account_password_service import AccountPasswordService from services.account_ports import AccountPasswordHasher, AccountRepository from services.entities.account_entities import AccountCredentials, AccountPasswordDigest, AccountSnapshot +from tests.unit_tests.model_factories import make_account_snapshot def _context() -> RequestContext: @@ -22,21 +22,7 @@ def _context() -> RequestContext: def _account() -> AccountSnapshot: - return AccountSnapshot( - id="account-1", - name="Account", - email="account@example.com", - 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=None, - created_at=datetime(2026, 1, 1), - ) + return make_account_snapshot(is_password_set=True) def test_change_verifies_current_password_and_updates_digest() -> None: diff --git a/api/tests/unit_tests/services/test_account_profile_service.py b/api/tests/unit_tests/services/test_account_profile_service.py index 018cd45fc59..76837a88a3c 100644 --- a/api/tests/unit_tests/services/test_account_profile_service.py +++ b/api/tests/unit_tests/services/test_account_profile_service.py @@ -1,6 +1,5 @@ from __future__ import annotations -from datetime import datetime from unittest.mock import Mock import pytest @@ -10,6 +9,7 @@ from services.account_errors import AccountNotFoundError from services.account_ports import AccountRepository from services.account_profile_service import AccountProfileService from services.entities.account_entities import AccountProfileChanges, AccountSnapshot +from tests.unit_tests.model_factories import make_account_snapshot def _context() -> RequestContext: @@ -22,21 +22,7 @@ def _context() -> RequestContext: def _account() -> 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=None, - created_at=datetime(2026, 1, 1), - ) + return make_account_snapshot() def test_get_returns_framework_neutral_account_snapshot() -> None: diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index 6e950ea9545..c8c4a094129 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -37,6 +37,7 @@ from services.errors.account import ( NoPermissionError, ) from tests.unit_tests.config_override import config_overrides_context +from tests.unit_tests.model_factories import make_tenant type _MockDependencies = dict[str, MagicMock] @@ -75,7 +76,7 @@ class TestAccountAssociatedDataFactory: def _tenant(session: Session | None = None) -> Tenant: - tenant = Tenant(name="Test Workspace") + tenant = make_tenant(tenant_id=None, name="Test Workspace") if session is not None: session.add(tenant) return tenant 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 b7d84895d04..e4c1554f62c 100644 --- a/api/tests/unit_tests/services/test_app_dsl_service.py +++ b/api/tests/unit_tests/services/test_app_dsl_service.py @@ -1,4 +1,3 @@ -import json from collections.abc import Callable from types import SimpleNamespace from typing import cast @@ -12,9 +11,9 @@ from sqlalchemy.orm import Session, sessionmaker from constants.dsl_version import CURRENT_APP_DSL_VERSION from core.rbac import RBACPermission, RBACResourceScope from core.workflow.llm_environment_variable import LLMEnvironmentVariable -from models import Account, App, AppMode, Tenant +from models import Account, App, AppMode from models.model import AppModelConfig, AppModelConfigDict, IconType -from models.workflow import Workflow, WorkflowType +from models.workflow import Workflow from services.agent.dsl_entities import AgentPackage from services.app_dsl_service import AppDslService, Import, PendingData from services.enterprise.enterprise_service import EnterpriseService @@ -23,6 +22,7 @@ from services.errors.account import NoPermissionError from services.errors.app import WorkflowNotFoundError from services.system_feature_service import SystemFeatureService from tests.unit_tests.config_override import apply_config_overrides +from tests.unit_tests.model_factories import make_account, make_app, make_tenant, make_workflow _OVERWRITE_APP_ID = "11111111-1111-4111-8111-111111111111" _TENANT_ID = "22222222-2222-4222-8222-222222222222" @@ -60,12 +60,12 @@ def _persist_overwrite_target(session: Session, *, maintainer: str = _OTHER_ACCO 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 + return make_account( + account_id=account_id, + name="DSL author", + email=f"{account_id}@example.com", + tenant=make_tenant(tenant_id=tenant_id, name="DSL workspace"), + ) def _app( @@ -75,38 +75,20 @@ def _app( mode: AppMode = AppMode.CHAT, app_model_config_id: str | None = None, ) -> App: - return App( - id=app_id, + return make_app( + 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 + return make_workflow(workflow_id="workflow-1", graph=graph, environment_variables=environment_variables or []) @pytest.mark.parametrize("status", [ImportStatus.FAILED, ImportStatus.PENDING]) diff --git a/api/tests/unit_tests/services/test_audio_service.py b/api/tests/unit_tests/services/test_audio_service.py index 82568916445..88c4fe20964 100644 --- a/api/tests/unit_tests/services/test_audio_service.py +++ b/api/tests/unit_tests/services/test_audio_service.py @@ -81,6 +81,7 @@ from services.errors.audio import ( SpeechToTextDisabledServiceError, UnsupportedAudioTypeServiceError, ) +from tests.unit_tests.model_factories import make_message APP_ID = "11111111-1111-1111-1111-111111111111" TENANT_ID = "22222222-2222-2222-2222-222222222222" @@ -92,10 +93,11 @@ OTHER_ID = "77777777-7777-7777-7777-777777777777" def _message(*, answer: str = "Message answer") -> Message: - message = Message( - id=MESSAGE_ID, + return make_message( + message_id=MESSAGE_ID, app_id=APP_ID, conversation_id=CONVERSATION_ID, + inputs={}, query="Question", message={"role": "user", "content": "Question"}, answer=answer, @@ -107,8 +109,6 @@ def _message(*, answer: str = "Message answer") -> Message: from_end_user_id=END_USER_ID, from_account_id=ACCOUNT_ID, ) - message._inputs = {} - return message class AudioServiceTestDataFactory: diff --git a/api/tests/unit_tests/services/test_billing_portal_service.py b/api/tests/unit_tests/services/test_billing_portal_service.py index 676d0686629..9af98ed8368 100644 --- a/api/tests/unit_tests/services/test_billing_portal_service.py +++ b/api/tests/unit_tests/services/test_billing_portal_service.py @@ -1,4 +1,3 @@ -from datetime import datetime from unittest.mock import MagicMock, Mock import pytest @@ -8,6 +7,7 @@ from services.account_errors import AccountNotFoundError from services.account_ports import AccountRepository from services.billing_portal_service import BillingPortalService from services.entities.account_entities import AccountSnapshot +from tests.unit_tests.model_factories import make_account_snapshot def _context() -> RequestContext: @@ -20,21 +20,7 @@ def _context() -> RequestContext: def _account() -> AccountSnapshot: - return AccountSnapshot( - id="account-1", - name="Account", - email="owner@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=None, - created_at=datetime(2026, 1, 1), - ) + return make_account_snapshot(email="owner@example.com") @pytest.fixture diff --git a/api/tests/unit_tests/services/test_billing_service.py b/api/tests/unit_tests/services/test_billing_service.py index 0e10220635b..f2acba94932 100644 --- a/api/tests/unit_tests/services/test_billing_service.py +++ b/api/tests/unit_tests/services/test_billing_service.py @@ -22,7 +22,6 @@ import pytest from werkzeug.exceptions import InternalServerError from enums import CloudPlan -from models import Account, Tenant from services.billing_service import BillingService, _BillingHTTPStatusError from services.errors.billing import ( BillingUpstreamInvalidResponseError, @@ -33,15 +32,6 @@ TENANT_ID = "11111111-1111-1111-1111-111111111111" ACCOUNT_ID = "33333333-3333-3333-3333-333333333333" -def _account(*, account_id: str = ACCOUNT_ID, email: str = "user@example.com", tenant_id: str = TENANT_ID) -> Account: - tenant = Tenant(name="Test Tenant") - tenant.id = tenant_id - account = Account(name="Test User", email=email) - account.id = account_id - account._current_tenant = tenant - return account - - class TestBillingServiceSendRequest: """Unit tests for BillingService._send_request method. 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 96523594c10..5ae48db2764 100644 --- a/api/tests/unit_tests/services/test_dataset_service_dataset.py +++ b/api/tests/unit_tests/services/test_dataset_service_dataset.py @@ -19,7 +19,6 @@ from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError from core.rag.index_processor.constant.index_type import IndexTechniqueType from graphon.model_runtime.entities.model_entities import ModelFeature, ModelType from models import Account -from models.account import Tenant from models.dataset import ( Dataset, DatasetCollectionBinding, @@ -36,6 +35,7 @@ from services.entities.knowledge_entities.rag_pipeline_entities import ( ) from services.errors.account import NoPermissionError from services.errors.dataset import DatasetNameDuplicateError +from tests.unit_tests.model_factories import make_account, make_dataset, make_tenant from .dataset_service_test_helpers import ( MagicMock, @@ -51,13 +51,13 @@ def _account( tenant_id: str = "tenant-1", role: TenantAccountRole = TenantAccountRole.OWNER, ) -> Account: - account = Account(name=f"User {account_id}", email=f"{account_id}@example.com") - account.id = account_id - account.role = role - tenant = Tenant(name=f"Tenant {tenant_id}") - tenant.id = tenant_id - account._current_tenant = tenant - return account + return make_account( + account_id=account_id, + name=f"User {account_id}", + email=f"{account_id}@example.com", + role=role, + tenant=make_tenant(tenant_id=tenant_id, name=f"Tenant {tenant_id}"), + ) def _dataset( @@ -71,8 +71,8 @@ def _dataset( indexing_technique: str = IndexTechniqueType.ECONOMY, chunk_structure: str | None = "text_model", ) -> Dataset: - return Dataset( - id=dataset_id, + return make_dataset( + dataset_id=dataset_id, tenant_id=tenant_id, name=name, description="", 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 c70468e5f65..a450560d231 100644 --- a/api/tests/unit_tests/services/test_dataset_service_document.py +++ b/api/tests/unit_tests/services/test_dataset_service_document.py @@ -7,13 +7,13 @@ from sqlalchemy import event, select from sqlalchemy.orm import Session from enums import DeploymentEdition -from models.account import Tenant from models.dataset import Dataset, DatasetCollectionBinding, DocumentSegment from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus from models.model import UploadFile from models.source import DataSourceOauthBinding from services.dataset_ref_service import DatasetRefService from tests.unit_tests.config_override import config_overrides_context +from tests.unit_tests.model_factories import make_account, make_tenant, make_upload_file from .dataset_service_test_helpers import ( Account, @@ -53,12 +53,12 @@ from .dataset_service_test_helpers import ( def _account(*, account_id: str = "user-1", tenant_id: str = "tenant-1") -> Account: - account = Account(name="User", email=f"{account_id}@example.com") - account.id = account_id - tenant = Tenant(name="Tenant") - tenant.id = tenant_id - account._current_tenant = tenant - return account + return make_account( + account_id=account_id, + name="User", + email=f"{account_id}@example.com", + tenant=make_tenant(tenant_id=tenant_id, name="Tenant"), + ) def _dataset_row( @@ -121,21 +121,16 @@ def _document_row( def _upload_file(*, file_id: str, tenant_id: str = "tenant-1", name: str = "upload.txt") -> UploadFile: - upload_file = UploadFile( + return make_upload_file( + file_id=file_id, tenant_id=tenant_id, storage_type="opendal", key=f"key-{file_id}", name=name, size=1, - extension="txt", - mime_type="text/plain", - created_by_role="account", created_by="user-1", created_at=datetime(2026, 1, 1), - used=False, ) - upload_file.id = file_id - return upload_file def _process_rule( diff --git a/api/tests/unit_tests/services/test_file_service.py b/api/tests/unit_tests/services/test_file_service.py index a807bc6aa41..22106b85cc1 100644 --- a/api/tests/unit_tests/services/test_file_service.py +++ b/api/tests/unit_tests/services/test_file_service.py @@ -22,12 +22,11 @@ from services.errors.file import ( UnsupportedFileTypeError, ) from services.file_service import FileService +from tests.unit_tests.model_factories import make_account def _account() -> Account: - account = Account(name="Test Account", email="test@example.com") - account.id = "user_id" - return account + return make_account(account_id="user_id", name="Test Account", email="test@example.com") class TestFileService: diff --git a/api/tests/unit_tests/services/test_knowledge_retrieval_inner_service.py b/api/tests/unit_tests/services/test_knowledge_retrieval_inner_service.py index 4a5511396d7..66a289d24fa 100644 --- a/api/tests/unit_tests/services/test_knowledge_retrieval_inner_service.py +++ b/api/tests/unit_tests/services/test_knowledge_retrieval_inner_service.py @@ -7,7 +7,6 @@ from sqlalchemy.orm import Session from core.workflow.nodes.knowledge_retrieval.retrieval import Source, SourceMetadata from models.dataset import Dataset -from models.enums import AppStatus from models.model import App, AppMode from services.entities.knowledge_retrieval_inner import InnerKnowledgeRetrieveRequest from services.errors.knowledge_retrieval import ( @@ -17,6 +16,7 @@ from services.errors.knowledge_retrieval import ( InnerKnowledgeRetrieveDatasetTenantMismatchError, ) from services.knowledge_retrieval_inner_service import InnerKnowledgeRetrievalService +from tests.unit_tests.model_factories import make_app, make_dataset TENANT_ID = "11111111-1111-1111-1111-111111111111" OTHER_TENANT_ID = "22222222-2222-2222-2222-222222222222" @@ -27,22 +27,19 @@ DATASET_2_ID = "66666666-6666-6666-6666-666666666666" def _app(*, tenant_id: str = TENANT_ID) -> App: - return App( - id=APP_ID, + return make_app( + app_id=APP_ID, tenant_id=tenant_id, - name="Test App", - description="", mode=AppMode.WORKFLOW, - status=AppStatus.NORMAL, + icon_type=None, enable_site=False, enable_api=False, - max_active_requests=None, ) def _dataset(*, dataset_id: str, tenant_id: str = TENANT_ID, enable_api: bool = True) -> Dataset: - return Dataset( - id=dataset_id, + return make_dataset( + dataset_id=dataset_id, tenant_id=tenant_id, name=f"Dataset {dataset_id[-1]}", description="", diff --git a/api/tests/unit_tests/services/test_metadata_service_session_boundary.py b/api/tests/unit_tests/services/test_metadata_service_session_boundary.py index 3414a1b0d10..eea773da175 100644 --- a/api/tests/unit_tests/services/test_metadata_service_session_boundary.py +++ b/api/tests/unit_tests/services/test_metadata_service_session_boundary.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session, sessionmaker from core.rag.index_processor.constant.built_in_field import BuiltInField from models import Account from models.dataset import Dataset, DatasetMetadata, DatasetMetadataBinding, Document -from models.enums import DataSourceType, DocumentCreatedFrom +from models.enums import DocumentCreatedFrom from services.dataset_service import DocumentService from services.entities.knowledge_entities.knowledge_entities import ( DocumentMetadataOperation, @@ -18,6 +18,7 @@ from services.entities.knowledge_entities.knowledge_entities import ( ) from services.errors.metadata import MetadataResourceNotFoundError from services.metadata_service import MetadataService +from tests.unit_tests.model_factories import make_account, make_dataset, make_document DOCUMENT_ID = "11111111-1111-1111-1111-111111111111" FOREIGN_DOCUMENT_ID = "22222222-2222-2222-2222-222222222222" @@ -26,9 +27,7 @@ FOREIGN_METADATA_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" def _account() -> Account: - account = Account(name="User", email="user@example.com") - account.id = "account-1" - return account + return make_account(name="User", email="user@example.com") def test_create_metadata_flushes_without_committing_caller_session(sqlite_session: Session) -> None: @@ -50,28 +49,17 @@ def test_create_metadata_flushes_without_committing_caller_session(sqlite_sessio def _dataset(*, built_in_field_enabled: bool) -> Dataset: - return Dataset( - id="dataset-1", - tenant_id="tenant-1", - name="Dataset", + return make_dataset( description="", provider="vendor", - created_by="account-1", built_in_field_enabled=built_in_field_enabled, ) def _document() -> Document: - return Document( - id=DOCUMENT_ID, - tenant_id="tenant-1", - dataset_id="dataset-1", - position=1, - data_source_type=DataSourceType.UPLOAD_FILE, - batch="batch-1", - name="Document", + return make_document( + document_id=DOCUMENT_ID, created_from=DocumentCreatedFrom.API, - created_by="account-1", created_at=datetime(2026, 1, 1), updated_at=datetime(2026, 1, 2), doc_metadata={}, diff --git a/api/tests/unit_tests/services/test_schedule_service.py b/api/tests/unit_tests/services/test_schedule_service.py index 4f5df2f8c8a..40f1801c561 100644 --- a/api/tests/unit_tests/services/test_schedule_service.py +++ b/api/tests/unit_tests/services/test_schedule_service.py @@ -10,8 +10,9 @@ from core.trigger.constants import TRIGGER_SCHEDULE_NODE_TYPE from core.workflow.nodes.trigger_schedule.entities import VisualConfig from core.workflow.nodes.trigger_schedule.exc import ScheduleConfigError from libs.schedule_utils import calculate_next_run_at, convert_12h_to_24h -from models.workflow import Workflow, WorkflowType +from models.workflow import Workflow from services.trigger.schedule_service import ScheduleService +from tests.unit_tests.model_factories import make_workflow class TestScheduleService(unittest.TestCase): @@ -507,18 +508,7 @@ class TestScheduleWithTimezone(unittest.TestCase): def _workflow(*, graph_dict: dict[str, Any]) -> Workflow: - return Workflow.new( - tenant_id="tenant-1", - app_id="app-1", - type=WorkflowType.WORKFLOW, - version="draft", - graph=json.dumps(graph_dict), - features="{}", - created_by="account-1", - environment_variables=[], - conversation_variables=[], - rag_pipeline_variables=[], - ) + return make_workflow(graph=graph_dict) def test_to_schedule_config_should_build_from_cron_mode() -> None: 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 900f321c43b..bd06fe98613 100644 --- a/api/tests/unit_tests/services/test_snippet_dsl_service.py +++ b/api/tests/unit_tests/services/test_snippet_dsl_service.py @@ -8,9 +8,9 @@ from sqlalchemy import event from sqlalchemy.orm import Session from graphon.nodes import BuiltinNodeTypes -from models import Account, Tenant +from models import Account from models.snippet import CustomizedSnippet, SnippetType -from models.workflow import Workflow, WorkflowType +from models.workflow import Workflow from services.snippet_dsl_service import ( ImportMode, ImportStatus, @@ -18,6 +18,7 @@ from services.snippet_dsl_service import ( SnippetPendingData, _check_version_compatibility, ) +from tests.unit_tests.model_factories import make_account, make_tenant, make_workflow SQLITE_MODELS = (CustomizedSnippet,) pytestmark = [ @@ -33,12 +34,12 @@ def service(sqlite_session: Session) -> SnippetDslService: 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 + return make_account( + account_id=account_id, + name="Snippet author", + email=f"{account_id}@example.com", + tenant=make_tenant(tenant_id=tenant_id, name="Snippet workspace"), + ) def _snippet( @@ -64,16 +65,7 @@ def _snippet( 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", - ) + return make_workflow(workflow_id="workflow-1", app_id="snippet-1", graph=graph) @pytest.mark.parametrize( diff --git a/api/tests/unit_tests/services/test_snippet_generate_service.py b/api/tests/unit_tests/services/test_snippet_generate_service.py index d1b3f428c7b..cc2a81ecdc4 100644 --- a/api/tests/unit_tests/services/test_snippet_generate_service.py +++ b/api/tests/unit_tests/services/test_snippet_generate_service.py @@ -8,25 +8,13 @@ from sqlalchemy.orm import Session, sessionmaker from core.workflow.snippet_start import SNIPPET_VIRTUAL_START_NODE_ID from models.account import Account from models.snippet import CustomizedSnippet, SnippetType -from models.workflow import Workflow, WorkflowKind, WorkflowNodeExecutionModel, WorkflowType +from models.workflow import Workflow, WorkflowKind, WorkflowNodeExecutionModel from services.snippet_generate_service import SnippetGenerateService +from tests.unit_tests.model_factories import make_account, make_workflow def _workflow(graph: dict) -> Workflow: - return Workflow( - id="workflow-1", - tenant_id="tenant-1", - app_id="snippet-1", - type=WorkflowType.WORKFLOW, - kind=WorkflowKind.SNIPPET, - version=Workflow.VERSION_DRAFT, - graph=json.dumps(graph), - features="{}", - created_by="account-1", - environment_variables=[], - conversation_variables=[], - rag_pipeline_variables=[], - ) + return make_workflow(workflow_id="workflow-1", app_id="snippet-1", kind=WorkflowKind.SNIPPET, graph=graph) def _snippet(*, input_fields: list[dict] | None = None) -> CustomizedSnippet: @@ -42,9 +30,7 @@ def _snippet(*, input_fields: list[dict] | None = None) -> CustomizedSnippet: def _account(account_id: str = "user-1") -> Account: - account = Account(name="Test User", email=f"{account_id}@example.com") - account.id = account_id - return account + return make_account(account_id=account_id, name="Test User", email=f"{account_id}@example.com") def test_filter_virtual_start_events_keeps_blocking_response_unchanged(): diff --git a/api/tests/unit_tests/services/test_snippet_service.py b/api/tests/unit_tests/services/test_snippet_service.py index ea2d83c810a..843c1857a81 100644 --- a/api/tests/unit_tests/services/test_snippet_service.py +++ b/api/tests/unit_tests/services/test_snippet_service.py @@ -37,6 +37,7 @@ from models.workflow import ( from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError from services.errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError from services.snippet_service import SnippetService +from tests.unit_tests.model_factories import make_account def _create_workflow(*, workflow_id: str, version: str, graph: dict, features: dict) -> Workflow: @@ -70,9 +71,7 @@ def _snippet(**overrides) -> CustomizedSnippet: def _account(account_id: str = "account-1") -> Account: - account = Account(name="Test User", email=f"{account_id}@example.com") - account.id = account_id - return account + return make_account(account_id=account_id, name="Test User", email=f"{account_id}@example.com") def test_create_snippet_allows_duplicate_names( 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 1290987ffce..25413aa3449 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 @@ -12,6 +12,7 @@ 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 +from tests.unit_tests.model_factories import make_account_snapshot def _context(*, workspace_id: str = "workspace-1") -> RequestContext: @@ -55,21 +56,7 @@ class StateRepositoryStub: 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, - ) + return make_account_snapshot(initialized_at=started_at, created_at=started_at) def _accounts(account: AccountSnapshot | None) -> Mock: diff --git a/api/tests/unit_tests/services/test_vector_service.py b/api/tests/unit_tests/services/test_vector_service.py index 50b0d2c58c4..a8914f41f1c 100644 --- a/api/tests/unit_tests/services/test_vector_service.py +++ b/api/tests/unit_tests/services/test_vector_service.py @@ -14,12 +14,12 @@ from sqlalchemy.orm import Session import services.vector_service as vector_service_module from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType -from extensions.storage.storage_type import StorageType from models import UploadFile from models.dataset import ChildChunk, Dataset, DatasetProcessRule, DocumentSegment, SegmentAttachmentBinding from models.dataset import Document as DatasetDocument -from models.enums import CreatorUserRole, DataSourceType, DocumentCreatedFrom, ProcessRuleMode +from models.enums import DataSourceType, DocumentCreatedFrom, ProcessRuleMode from services.vector_service import VectorService +from tests.unit_tests.model_factories import make_upload_file @dataclass(frozen=True) @@ -107,21 +107,15 @@ def _make_segment( def _upload_file(*, file_id: str = "file-1", name: str = "img.png", tenant_id: str = "tenant-1") -> UploadFile: - upload_file = UploadFile( + return make_upload_file( + file_id=file_id, tenant_id=tenant_id, - storage_type=StorageType.LOCAL, key=f"uploads/{file_id}", name=name, - size=10, extension="png", mime_type="image/png", - created_by_role=CreatorUserRole.ACCOUNT, - created_by="account-1", created_at=datetime(2026, 1, 1), - used=False, ) - upload_file.id = file_id - return upload_file def _make_child_chunk( 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 267d35f7757..db66f40ec66 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 @@ -26,6 +26,7 @@ from services.vector_space_admission_service import ( get_vector_space_admission_error_fields, parse_vector_space_estimate_limits, ) +from tests.unit_tests.model_factories import make_dataset _MEBIBYTE = 1024 * 1024 _ESTIMATE_LIMITS = "sandbox:60,professional:6400,team:25600" @@ -74,10 +75,8 @@ class _FakeRedis: def _dataset(session: Session) -> Dataset: - dataset = Dataset( - id=str(uuid4()), - tenant_id="tenant-1", - name="Dataset", + dataset = make_dataset( + dataset_id=str(uuid4()), created_by=str(uuid4()), indexing_technique=IndexTechniqueType.HIGH_QUALITY, embedding_model_provider="provider", diff --git a/api/tests/unit_tests/services/test_webhook_service.py b/api/tests/unit_tests/services/test_webhook_service.py index f52541d3506..301997252cf 100644 --- a/api/tests/unit_tests/services/test_webhook_service.py +++ b/api/tests/unit_tests/services/test_webhook_service.py @@ -10,7 +10,6 @@ from sqlalchemy.engine import Engine from sqlalchemy.orm import Session from werkzeug.datastructures import FileStorage -from graphon.enums import WorkflowType from models.enums import AppTriggerStatus, AppTriggerType, EndUserType from models.model import App, AppMode, EndUser from models.tools import ToolFile @@ -19,6 +18,7 @@ from models.workflow import Workflow from services.errors.app import QuotaExceededError from services.trigger import webhook_service as webhook_service_module from services.trigger.webhook_service import WebhookService +from tests.unit_tests.model_factories import make_app, make_end_user, make_workflow def _webhook_trigger( @@ -59,31 +59,22 @@ def _workflow( app_id: str = "app-123", version: str = Workflow.VERSION_DRAFT, ) -> Workflow: - workflow = Workflow.new( + return make_workflow( + workflow_id=workflow_id, tenant_id=tenant_id, app_id=app_id, - type=WorkflowType.WORKFLOW.value, version=version, - graph='{"nodes": [], "edges": []}', - features="{}", created_by="account-123", - environment_variables=[], - conversation_variables=[], - rag_pipeline_variables=[], ) - workflow.id = workflow_id - return workflow def _app(*, tenant_id: str = "tenant-123", app_id: str = "app-123") -> App: - return App( - id=app_id, + return make_app( + app_id=app_id, tenant_id=tenant_id, name="Webhook App", - description="", mode=AppMode.WORKFLOW, - enable_site=True, - enable_api=True, + icon_type=None, max_active_requests=0, ) @@ -100,11 +91,11 @@ def _app_trigger(*, status: AppTriggerStatus = AppTriggerStatus.ENABLED) -> AppT def _end_user() -> EndUser: - return EndUser( - id="end-user-123", + return make_end_user( + end_user_id="end-user-123", tenant_id="tenant-123", app_id="app-123", - type=EndUserType.TRIGGER, + end_user_type=EndUserType.TRIGGER, session_id="webhook-session", ) 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 943677502f7..c1cbe203d63 100644 --- a/api/tests/unit_tests/services/test_workflow_collaboration_service.py +++ b/api/tests/unit_tests/services/test_workflow_collaboration_service.py @@ -11,10 +11,11 @@ from sqlalchemy.orm import Session from core.rbac import RBACPermission, RBACResourceScope from models.account import Account, Tenant from models.base import TypeBase -from models.model import App, AppMode, IconType +from models.model import App, AppMode 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 +from tests.unit_tests.model_factories import make_app @pytest.fixture @@ -27,25 +28,15 @@ def db_session(sqlite_engine: Engine) -> Iterator[Session]: def _app(*, app_id: str, tenant_id: str, maintainer: str | None = None) -> App: - return App( - id=app_id, + return make_app( + app_id=app_id, tenant_id=tenant_id, name="Workflow app", - description="", mode=AppMode.WORKFLOW, - icon_type=IconType.EMOJI, - icon="robot", icon_background="#ffffff", enable_site=False, enable_api=False, - api_rpm=0, - api_rph=0, - is_demo=False, - is_public=False, - is_universal=False, - max_active_requests=None, maintainer=maintainer, - use_icon_as_answer_icon=False, ) diff --git a/api/tests/unit_tests/services/test_workflow_comment_service.py b/api/tests/unit_tests/services/test_workflow_comment_service.py index f850169d09d..31b428ac1ab 100644 --- a/api/tests/unit_tests/services/test_workflow_comment_service.py +++ b/api/tests/unit_tests/services/test_workflow_comment_service.py @@ -19,6 +19,7 @@ from models.account import Account, TenantAccountRole from models.model import AppMode from services import workflow_comment_service as service_module from services.workflow_comment_service import WorkflowCommentService +from tests.unit_tests.model_factories import make_account, make_app TENANT_ID = "11111111-1111-1111-1111-111111111111" OTHER_TENANT_ID = "11111111-1111-1111-1111-111111111112" @@ -55,22 +56,20 @@ def _account( email: str = "user@example.com", interface_language: str | None = "en-US", ) -> Account: - account = Account(name=name, email=email, interface_language=interface_language) - account.id = account_id - return account + return make_account(account_id=account_id, name=name, email=email, interface_language=interface_language) def _app(*, app_id: str = APP_ID, tenant_id: str = TENANT_ID, name: str = "My App") -> App: - app = App( + return make_app( + app_id=app_id, tenant_id=tenant_id, name=name, mode=AppMode.WORKFLOW, + icon_type=None, enable_site=False, enable_api=False, created_by=OWNER_ID, ) - app.id = app_id - return app def _comment( diff --git a/api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py b/api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py index 62c4b456c63..1f75c576b78 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py @@ -17,11 +17,11 @@ from core.workflow.human_input_adapter import ( from core.workflow.nodes.human_input.entities import HumanInputNodeData from graphon.enums import BuiltinNodeTypes from models import Account -from models.enums import AppStatus from models.model import App, AppMode from models.workflow import Workflow, WorkflowType from services import workflow_service as workflow_service_module from services.workflow_service import WorkflowService +from tests.unit_tests.model_factories import make_account, make_app TENANT_ID = "11111111-1111-1111-1111-111111111111" APP_ID = "22222222-2222-2222-2222-222222222222" @@ -35,23 +35,18 @@ def _make_service(sqlite_session: Session) -> WorkflowService: def _app() -> App: - return App( - id=APP_ID, + return make_app( + app_id=APP_ID, tenant_id=TENANT_ID, - name="Test App", - description="", mode=AppMode.WORKFLOW, - status=AppStatus.NORMAL, + icon_type=None, enable_site=False, enable_api=False, - max_active_requests=None, ) def _account() -> Account: - account = Account(name="Test User", email="test@example.com") - account.id = ACCOUNT_ID - return account + return make_account(account_id=ACCOUNT_ID, name="Test User", email="test@example.com") def _build_node_config(delivery_methods: list[EmailDeliveryMethod]) -> dict[str, object]: diff --git a/api/tests/unit_tests/tasks/test_clean_document_task.py b/api/tests/unit_tests/tasks/test_clean_document_task.py index 7f1eac13399..7409515cfb2 100644 --- a/api/tests/unit_tests/tasks/test_clean_document_task.py +++ b/api/tests/unit_tests/tasks/test_clean_document_task.py @@ -22,10 +22,11 @@ from models.dataset import ( DocumentSegment, SegmentAttachmentBinding, ) -from models.enums import CreatorUserRole, DataSourceType, DocumentCreatedFrom +from models.enums import CreatorUserRole, DataSourceType from models.knowledge_fs import KnowledgeFSUpgradeFileLease, KnowledgeFSUpgradeJob from models.model import UploadFile from tasks.clean_document_task import clean_document_task +from tests.unit_tests.model_factories import make_document SQLITE_MODELS = ( Dataset, @@ -90,15 +91,11 @@ def mock_index_processor_factory(): def _document(*, document_id: str, dataset_id: str, tenant_id: str, created_by: str) -> Document: - return Document( - id=document_id, + return make_document( + document_id=document_id, tenant_id=tenant_id, dataset_id=dataset_id, - position=1, - data_source_type=DataSourceType.UPLOAD_FILE, - batch="batch-1", name=f"{document_id}.txt", - created_from=DocumentCreatedFrom.WEB, created_by=created_by, ) diff --git a/api/tests/unit_tests/tasks/test_delete_conversation_task.py b/api/tests/unit_tests/tasks/test_delete_conversation_task.py index e3e5d84488c..aa86ac156a0 100644 --- a/api/tests/unit_tests/tasks/test_delete_conversation_task.py +++ b/api/tests/unit_tests/tasks/test_delete_conversation_task.py @@ -12,7 +12,6 @@ from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInput from graphon.file import FileTransferMethod, FileType from models import ( AgentDebugConversation, - AppMode, Conversation, ConversationVariable, HumanInputForm, @@ -39,6 +38,7 @@ from models.enums import ( from models.human_input import HumanInputDelivery, HumanInputFormRecipient, RecipientType from models.tools import ToolConversationVariables, ToolFile from tasks.delete_conversation_task import _cleanup_conversation_related_data, sweep_deleted_conversations +from tests.unit_tests.model_factories import make_conversation, make_message TENANT_ID = "11111111-1111-1111-1111-111111111111" APP_ID = "22222222-2222-2222-2222-222222222222" @@ -50,10 +50,9 @@ AGENT_ID = "77777777-7777-7777-7777-777777777777" def _conversation(conversation_id: str, *, deleted: bool) -> Conversation: - return Conversation( - id=conversation_id, + return make_conversation( + conversation_id=conversation_id, app_id=APP_ID, - mode=AppMode.CHAT, name="Test conversation", inputs={}, status=ConversationStatus.NORMAL, @@ -64,8 +63,8 @@ def _conversation(conversation_id: str, *, deleted: bool) -> Conversation: def _message() -> Message: - return Message( - id=MESSAGE_ID, + return make_message( + message_id=MESSAGE_ID, app_id=APP_ID, conversation_id=CONVERSATION_ID, inputs={}, 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 c9d20849af7..ce063d3a6bb 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 @@ -18,6 +18,7 @@ from models.enums import InvokeFrom as StoredInvokeFrom from models.human_input import HumanInputForm from models.model import App, AppMode, Conversation, EndUser from tasks.app_generate import resume_agent_app_task as mod +from tests.unit_tests.model_factories import make_app, make_conversation MODULE = "tasks.app_generate.resume_agent_app_task" @@ -33,15 +34,12 @@ def task_session(mocker: MockerFixture, sqlite_session_factory: sessionmaker[Ses def _app(*, app_id: str, tenant_id: str) -> App: - return App( - id=app_id, + return make_app( + 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, ) @@ -55,8 +53,8 @@ def _conversation( end_user_id: str | None = None, invoke_from: StoredInvokeFrom = StoredInvokeFrom.WEB_APP, ) -> Conversation: - return Conversation( - id=conversation_id, + return make_conversation( + conversation_id=conversation_id, app_id=app_id, mode=AppMode.AGENT_CHAT, name="Agent conversation", diff --git a/api/tests/unit_tests/tasks/test_sync_website_document_indexing_task.py b/api/tests/unit_tests/tasks/test_sync_website_document_indexing_task.py index 041e9ba55e5..594c4295305 100644 --- a/api/tests/unit_tests/tasks/test_sync_website_document_indexing_task.py +++ b/api/tests/unit_tests/tasks/test_sync_website_document_indexing_task.py @@ -7,14 +7,15 @@ from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexStructureType from enums import DeploymentEdition from models.dataset import Dataset, Document, DocumentSegment -from models.enums import DataSourceType, DocumentCreatedFrom +from models.enums import DataSourceType from tasks.sync_website_document_indexing_task import sync_website_document_indexing_task from tests.unit_tests.config_override import config_overrides_context +from tests.unit_tests.model_factories import make_dataset, make_document def _dataset(tenant_id: str) -> Dataset: - return Dataset( - id=str(uuid.uuid4()), + return make_dataset( + dataset_id=str(uuid.uuid4()), tenant_id=tenant_id, name="Website dataset", data_source_type=DataSourceType.WEBSITE_CRAWL, @@ -23,15 +24,12 @@ def _dataset(tenant_id: str) -> Dataset: def _document(dataset: Dataset) -> Document: - return Document( - id=str(uuid.uuid4()), + return make_document( + document_id=str(uuid.uuid4()), tenant_id=dataset.tenant_id, dataset_id=dataset.id, - position=1, data_source_type=DataSourceType.WEBSITE_CRAWL, - batch="batch-1", name="Website document", - created_from=DocumentCreatedFrom.WEB, created_by=str(uuid.uuid4()), doc_form=IndexStructureType.PARAGRAPH_INDEX, ) diff --git a/api/tests/unit_tests/tasks/test_trigger_processing_tasks.py b/api/tests/unit_tests/tasks/test_trigger_processing_tasks.py index 6609633a9cb..ecb320b5a9d 100644 --- a/api/tests/unit_tests/tasks/test_trigger_processing_tasks.py +++ b/api/tests/unit_tests/tasks/test_trigger_processing_tasks.py @@ -1,52 +1,43 @@ -import json from unittest.mock import MagicMock, patch import pytest import tasks.trigger_processing_tasks as trigger_processing_tasks_module from core.plugin.entities.plugin_daemon import CredentialType -from graphon.enums import WorkflowType from models.enums import EndUserType from models.model import EndUser from models.trigger import TriggerSubscription, WorkflowPluginTrigger from models.workflow import Workflow from services.errors.app import QuotaExceededError from tasks.trigger_processing_tasks import dispatch_triggered_workflow +from tests.unit_tests.model_factories import make_end_user, make_workflow def _workflow(*, app_id: str = "app-123") -> Workflow: - workflow = Workflow.new( + return make_workflow( + workflow_id="workflow-123", tenant_id="tenant-123", app_id=app_id, - type=WorkflowType.WORKFLOW.value, version="published", - graph=json.dumps( - { - "nodes": [ - { - "id": "node-123", - "data": {"type": trigger_processing_tasks_module.TRIGGER_PLUGIN_NODE_TYPE}, - } - ], - "edges": [], - } - ), - features="{}", + graph={ + "nodes": [ + { + "id": "node-123", + "data": {"type": trigger_processing_tasks_module.TRIGGER_PLUGIN_NODE_TYPE}, + } + ], + "edges": [], + }, created_by="user-123", - environment_variables=[], - conversation_variables=[], - rag_pipeline_variables=[], ) - workflow.id = "workflow-123" - return workflow def _end_user() -> EndUser: - return EndUser( - id="end-user-123", + return make_end_user( + end_user_id="end-user-123", tenant_id="tenant-123", app_id="app-123", - type=EndUserType.TRIGGER, + end_user_type=EndUserType.TRIGGER, session_id="trigger-session", ) diff --git a/api/tests/unit_tests/tasks/test_workflow_node_execution_tasks.py b/api/tests/unit_tests/tasks/test_workflow_node_execution_tasks.py index 0b3e314a3f4..2a87d73fd35 100644 --- a/api/tests/unit_tests/tasks/test_workflow_node_execution_tasks.py +++ b/api/tests/unit_tests/tasks/test_workflow_node_execution_tasks.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock import pytest from sqlalchemy import func, select from sqlalchemy.orm import Session, sessionmaker +from typing_extensions import TypedDict from graphon.entities import WorkflowNodeExecution from graphon.entities.workflow_node_execution import WorkflowNodeExecutionMetadataKey @@ -29,6 +30,14 @@ ACCOUNT_ID = "00000000-0000-0000-0000-000000000050" EXECUTION_ID = "00000000-0000-0000-0000-000000000060" +class _ExecutionTaskKwargs(TypedDict, closed=True): + tenant_id: str + app_id: str + triggered_from: str + creator_user_id: str + creator_user_role: str + + class _TaskWithRequestContext(Protocol): def push_request(self, *, retries: int) -> None: ... @@ -313,7 +322,7 @@ def test_task_repeated_delivery_updates_one_row( outputs={"delivery": 2}, finished_at=created_at + timedelta(seconds=5), ) - task_kwargs = { + task_kwargs: _ExecutionTaskKwargs = { "tenant_id": TENANT_ID, "app_id": APP_ID, "triggered_from": WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN.value, diff --git a/api/uv.lock b/api/uv.lock index b54b3108b89..93a08208fe3 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1743,7 +1743,7 @@ dev = [ { name = "lxml-stubs", specifier = ">=0.5.1" }, { name = "mypy", specifier = ">=2.3.1" }, { name = "pandas-stubs", specifier = ">=3.0.5.260730" }, - { name = "pyrefly", specifier = ">=1.2.0" }, + { name = "pyrefly", specifier = ">=1.3.1" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-benchmark", specifier = ">=5.3.0" }, { name = "pytest-cov", specifier = ">=7.1.0" }, @@ -3357,8 +3357,8 @@ name = "httpcore2" version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11", marker = "sys_platform != 'emscripten'" }, - { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "h11" }, + { name = "truststore" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ @@ -5608,21 +5608,21 @@ wheels = [ [[package]] name = "pyrefly" -version = "1.2.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/89/01/a86e9f24722b095c3f88e3616132b75a21b0df53804bdc6a45314dd4d93c/pyrefly-1.2.0.tar.gz", hash = "sha256:5485f960fc2481617068c918335c39ab1507ef90b6b5bd35bf57726e60e73185", size = 6243654, upload-time = "2026-08-01T02:56:27.592Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/7e/082cc1b392318bacfbc087732524f3e089e9e7fe45b7645c4649b6f1f1e9/pyrefly-1.3.1.tar.gz", hash = "sha256:b774f1dfac82867fc16fe2175d80a630e2a72a6835d5b7e492568d6bfb6b05e7", size = 6679145, upload-time = "2026-09-15T05:09:34.49Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/9d/3c0ef1d4843987b22f996ed381ec9cf5a3b1273e29804db276252e4c95eb/pyrefly-1.2.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7f46d983ac49ddd2b043694960a01dc6a19a5cfd8eec609d6bd9c42866f91b4e", size = 14026305, upload-time = "2026-08-01T02:56:02.611Z" }, - { url = "https://files.pythonhosted.org/packages/0a/06/03bbb78fbea54cdc65b626619f3597d5611aca4fdef11e72a4e8360e7e63/pyrefly-1.2.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:756f669b5555090f5c1a4fef30db1785fabe657764f7e4e6dc88994dfb8ca82d", size = 13463880, upload-time = "2026-08-01T02:56:04.93Z" }, - { url = "https://files.pythonhosted.org/packages/13/5a/7d8bc00a38e93bbc9c3e7bd14d305f7948717e667c9bcddeab9dd42fd255/pyrefly-1.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3465812ce5ef4781fb592edbf2724547296f0a3124be115d73c7e8b2401862d", size = 13907329, upload-time = "2026-08-01T02:56:07.104Z" }, - { url = "https://files.pythonhosted.org/packages/be/94/9e08b4bf799d0b8f36b55a2783c7ba5f51730cf0632a85a67b5b5ed876cd/pyrefly-1.2.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5de7b2ad2bba5c8055181681a84b74143eac2234a48ba5d1b7ed7e7a722b02bd", size = 15039020, upload-time = "2026-08-01T02:56:09.208Z" }, - { url = "https://files.pythonhosted.org/packages/5b/bd/bca5fd0c80f4daf8ee6903a29df9f3de1feb05ff0946b8f35ec8c5096b13/pyrefly-1.2.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25822ea9505f589ea8a725e4268b475132fb89e038fbf092e446510443ac142a", size = 14986199, upload-time = "2026-08-01T02:56:11.924Z" }, - { url = "https://files.pythonhosted.org/packages/97/f7/f07087f3d185ad2eced0c56cef89ca5474dfb4ff25f146cd50a861c97553/pyrefly-1.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90efe75e17491ef5d636e10469e9278d7d0256b3b4c5e1f4750069bf3ae0f5d1", size = 14393715, upload-time = "2026-08-01T02:56:14.143Z" }, - { url = "https://files.pythonhosted.org/packages/d3/70/0d142c320e284b9e3ce35e9b1e58b8ce2ee1f578f2a7234bc30e5022b94f/pyrefly-1.2.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:368aaf7eee4f511ddc0f8e564cf14e01ab2f10b0db9105c6d5b153bf498d07bf", size = 13933008, upload-time = "2026-08-01T02:56:16.525Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e8/e84f11b6e1f63fd453ad3654213b9a0f6f4de8cef6b58038eef2d0d5955d/pyrefly-1.2.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52d5da7bc65fb7675fbaa80eda879d4f8787c494f04cac21603330d3abbdbbe", size = 14431827, upload-time = "2026-08-01T02:56:18.645Z" }, - { url = "https://files.pythonhosted.org/packages/0f/06/810d31380f66c75e1c0779a408d3b16117b1b368b57894f6aa66bef21686/pyrefly-1.2.0-py3-none-win32.whl", hash = "sha256:8c90751de8506d938e8f802659c74cf35bd7a0036510ee6c634a38eebb280bfa", size = 13229447, upload-time = "2026-08-01T02:56:20.921Z" }, - { url = "https://files.pythonhosted.org/packages/ed/98/4dafa3c7a1caed2dc8cc708dde09ba27963c7736508f55b626fff3024113/pyrefly-1.2.0-py3-none-win_amd64.whl", hash = "sha256:8a8964c224ccc4882730130955815de21ff443c1ac3f0b90685b19bf63848170", size = 14087387, upload-time = "2026-08-01T02:56:23.188Z" }, - { url = "https://files.pythonhosted.org/packages/1b/1c/df3cb0a2e5591660ded7a1836cd2f29dc48c91adb1c0a3a700a96f6d09e1/pyrefly-1.2.0-py3-none-win_arm64.whl", hash = "sha256:3a90bb8df39dfbac74b1f3b2e9d7c526b8f80568884c3944d955023a73ebf61e", size = 13430873, upload-time = "2026-08-01T02:56:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/6b/bc/f09b5bd7eed796349bd93627aa2c3086df7ed0a114dafc3d2b2841599ee7/pyrefly-1.3.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0a2a7dfed824fb066aa8a7c8d856c152b98e998fb84b954d5f892fb9834c0bd1", size = 15090117, upload-time = "2026-09-15T05:08:58.477Z" }, + { url = "https://files.pythonhosted.org/packages/78/24/a31e7de8e0f310c9f0753826d0df8b6a8ff86793941a6bd88ba24b735633/pyrefly-1.3.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37a1a1fb41e7190fe3964a877875dd809d6ee2887809e2c3d6e5f25a69805bfd", size = 14430773, upload-time = "2026-09-15T05:09:01.67Z" }, + { url = "https://files.pythonhosted.org/packages/75/e1/022352beb186f36d6213a95a975c42676ee418ff5e730629378d25564c5e/pyrefly-1.3.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45b7650bc651a5a784701f2fb4f579da9a421c4d1b5fd91f9048f4529e46093c", size = 14877762, upload-time = "2026-09-15T05:09:04.767Z" }, + { url = "https://files.pythonhosted.org/packages/19/2c/d62a1b445aad1fd46375b83393b8a67da53883aeeba73d269ce71b3e8fd1/pyrefly-1.3.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad57f38bc73679c14a5f62995a2bd3bc43649b7270c858487e2eab78a6f95362", size = 16118225, upload-time = "2026-09-15T05:09:07.828Z" }, + { url = "https://files.pythonhosted.org/packages/32/67/3c3fa8a13e3ce8d8322ffb3627255a41a52261f17793e1a24e9214f10857/pyrefly-1.3.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3506b1252e9f5d755d0693c5a46d46b85579d36534f6e22a41eb8e098d0dde90", size = 16051197, upload-time = "2026-09-15T05:09:10.81Z" }, + { url = "https://files.pythonhosted.org/packages/19/d1/84f81b9e32288b5d11c75e006f67e0f6b78b85da575673bd1a21524c8172/pyrefly-1.3.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d5a17c1ad655e5f93a6e8e0be90b8db28c6015fe07d6ed4e054891c9e14ef8", size = 15440062, upload-time = "2026-09-15T05:09:14.641Z" }, + { url = "https://files.pythonhosted.org/packages/46/3c/2aa0c5ad6a2957a728bb38cfab70d1e9cafad21b0b1c1d92df74a3cf22bd/pyrefly-1.3.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c61e9dcf888beb81ca0e0c60ea4442640fdcf2f1f65bfe3d9f5d89b85516eb03", size = 14898534, upload-time = "2026-09-15T05:09:17.823Z" }, + { url = "https://files.pythonhosted.org/packages/03/5d/2974f249fb2e285f966485719d977c5a2248cc4866403af94d935775ff15/pyrefly-1.3.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a78eed1c53f37807f0e2997be8edbe833b91eb9915f2cb553a1bdcd599e7105c", size = 15477758, upload-time = "2026-09-15T05:09:20.964Z" }, + { url = "https://files.pythonhosted.org/packages/68/79/d31f8f0abf5b0610ec24183286ee8a1ee5b76c9aaaa91091399d7e1aaf60/pyrefly-1.3.1-py3-none-win32.whl", hash = "sha256:ea0d0349f1bc80fe8e00a7b3640e40db7486398b685f87b9bd5d2207063668f0", size = 14133005, upload-time = "2026-09-15T05:09:25.03Z" }, + { url = "https://files.pythonhosted.org/packages/3f/23/1859f47f536314755d80c4ec2a76d2698435ae8b73604bcd1dfa57ecb39b/pyrefly-1.3.1-py3-none-win_amd64.whl", hash = "sha256:4c24e7d99df9237916c33be23714219a7c8eaf22bad2ac0c3e1d45adcf7df8fb", size = 15066557, upload-time = "2026-09-15T05:09:28.265Z" }, + { url = "https://files.pythonhosted.org/packages/92/2a/73e3721921c2bb2c950a9f8c362ee5e2367556b5d4416eb4f8f88503e66f/pyrefly-1.3.1-py3-none-win_arm64.whl", hash = "sha256:1ed85e2b957a78e93353125d890ce3668ce450e49b74025a15c87b6e8ec7f3d1", size = 14348209, upload-time = "2026-09-15T05:09:31.422Z" }, ] [[package]] @@ -6306,11 +6306,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8.4" +version = "2.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/f1/93422647dd7e461f23d254e6b2bfa687a85b53aeb4903fcdbb74474d4584/soupsieve-2.9.tar.gz", hash = "sha256:acee8417325c5653e1377dc31eccad59eb82cbc65942afe6174c53b3aaad63fc", size = 122122, upload-time = "2026-07-19T01:35:18.425Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d6/3185ab5ad1280319b31986898f3206dd7227cd75e293d4dba2a5e6bf27a0/soupsieve-2.9-py3-none-any.whl", hash = "sha256:a2b2c76d67df2382d245409fd71e321a571717e58463efa32ace87dcadac2c12", size = 37387, upload-time = "2026-07-19T01:35:17.106Z" }, ] [[package]] diff --git a/docs/ar-SA/README.md b/docs/ar-SA/README.md index 47ec128c0ac..b095b2d850d 100644 --- a/docs/ar-SA/README.md +++ b/docs/ar-SA/README.md @@ -129,7 +129,7 @@ docker compose up -d - **الشفرة البرمجية**: اقرأ [دليل المساهمة](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md)، ثم تصفح [المشكلات المناسبة للمساهمين الجدد](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22). - **الأفكار والملاحظات**: ابدأ أو انضم إلى [مناقشة على GitHub](https://github.com/langgenius/dify/discussions). -- **الترجمات**: اتبع [دليل التدويل](https://github.com/langgenius/dify/blob/main/web/i18n-config/README.md) لإضافة لغة أو تحديثها. +- **الترجمات**: اتبع [دليل التدويل](https://github.com/langgenius/dify/blob/main/web/i18n/README.md) لإضافة لغة أو تحديثها. - **المجتمع**: شارك التطبيقات التي تبنيها، وساعد المستخدمين الآخرين، وساهم في نشر Dify. ### المساهمون diff --git a/docs/bn-BD/README.md b/docs/bn-BD/README.md index 2e30e5ee25b..91dc71bd034 100644 --- a/docs/bn-BD/README.md +++ b/docs/bn-BD/README.md @@ -145,7 +145,7 @@ Dify সব ধরনের অবদানকে স্বাগত জান - **কোড**: [অবদান নির্দেশিকা](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) পড়ুন, তারপর [নতুন অবদানকারীদের উপযোগী সমস্যাগুলো](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22) দেখুন। - **ধারণা ও প্রতিক্রিয়া**: একটি [GitHub আলোচনা](https://github.com/langgenius/dify/discussions) শুরু করুন বা বিদ্যমান আলোচনায় যোগ দিন। -- **অনুবাদ**: কোনো লোকেল যোগ বা হালনাগাদ করতে [আন্তর্জাতিকীকরণ নির্দেশিকা](https://github.com/langgenius/dify/blob/main/web/i18n-config/README.md) অনুসরণ করুন। +- **অনুবাদ**: কোনো লোকেল যোগ বা হালনাগাদ করতে [আন্তর্জাতিকীকরণ নির্দেশিকা](https://github.com/langgenius/dify/blob/main/web/i18n/README.md) অনুসরণ করুন। - **কমিউনিটি**: আপনার তৈরি অ্যাপ শেয়ার করুন, অন্য ব্যবহারকারীদের সাহায্য করুন এবং Dify সম্পর্কে সবাইকে জানান। ### অবদানকারীরা diff --git a/docs/de-DE/README.md b/docs/de-DE/README.md index 1608b0106e6..7f295d84534 100644 --- a/docs/de-DE/README.md +++ b/docs/de-DE/README.md @@ -144,7 +144,7 @@ Dify freut sich über Beiträge jeder Art: - **Code**: Lesen Sie den [Contribution Guide](./CONTRIBUTING.md) und stöbern Sie anschließend in den [Good First Issues](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22). - **Ideen und Feedback**: Starten Sie eine [GitHub-Diskussion](https://github.com/langgenius/dify/discussions) oder beteiligen Sie sich an einer bestehenden. -- **Übersetzungen**: Folgen Sie dem [Internationalisierungsleitfaden](https://github.com/langgenius/dify/blob/main/web/i18n-config/README.md), um eine Sprache hinzuzufügen oder zu aktualisieren. +- **Übersetzungen**: Folgen Sie dem [Internationalisierungsleitfaden](https://github.com/langgenius/dify/blob/main/web/i18n/README.md), um eine Sprache hinzuzufügen oder zu aktualisieren. - **Community**: Teilen Sie Ihre Apps, helfen Sie anderen Nutzern und machen Sie Dify bekannter. ### Mitwirkende diff --git a/docs/es-ES/README.md b/docs/es-ES/README.md index fb914dc1be7..f578318ba65 100644 --- a/docs/es-ES/README.md +++ b/docs/es-ES/README.md @@ -140,7 +140,7 @@ Dify acepta contribuciones de todo tipo: - **Código**: Lee la [Guía de contribución](./CONTRIBUTING.md) y consulta las [incidencias para principiantes](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22). - **Ideas y comentarios**: Inicia o únete a una [discusión en GitHub](https://github.com/langgenius/dify/discussions). -- **Traducciones**: Sigue la [guía de internacionalización](https://github.com/langgenius/dify/blob/main/web/i18n-config/README.md) para añadir o actualizar un idioma. +- **Traducciones**: Sigue la [guía de internacionalización](https://github.com/langgenius/dify/blob/main/web/i18n/README.md) para añadir o actualizar un idioma. - **Comunidad**: Comparte las aplicaciones que crees, ayuda a otros usuarios y da a conocer Dify. ### Contribuidores diff --git a/docs/fr-FR/README.md b/docs/fr-FR/README.md index 8d9f3941f8e..72b4fc3a7d6 100644 --- a/docs/fr-FR/README.md +++ b/docs/fr-FR/README.md @@ -140,7 +140,7 @@ Dify accueille les contributions de toutes sortes : - **Code** : consultez le [guide de contribution](./CONTRIBUTING.md), puis parcourez les [issues accessibles aux nouveaux contributeurs](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22). - **Idées et retours** : lancez ou rejoignez une [discussion GitHub](https://github.com/langgenius/dify/discussions). -- **Traductions** : suivez le [guide d’internationalisation](https://github.com/langgenius/dify/blob/main/web/i18n-config/README.md) pour ajouter ou mettre à jour une langue. +- **Traductions** : suivez le [guide d’internationalisation](https://github.com/langgenius/dify/blob/main/web/i18n/README.md) pour ajouter ou mettre à jour une langue. - **Communauté** : partagez les applications que vous créez, aidez les autres utilisateurs et faites connaître Dify. ### Contributeurs diff --git a/docs/hi-IN/README.md b/docs/hi-IN/README.md index 747987f3a47..0a927af5ecb 100644 --- a/docs/hi-IN/README.md +++ b/docs/hi-IN/README.md @@ -146,7 +146,7 @@ Dify सभी प्रकार के योगदानों का स् - **कोड**: [Contribution Guide](./CONTRIBUTING.md) पढ़ें, फिर [नए योगदानकर्ताओं के लिए उपयुक्त issues](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22) देखें। - **विचार और प्रतिक्रिया**: [GitHub Discussion](https://github.com/langgenius/dify/discussions) शुरू करें या किसी मौजूदा चर्चा में शामिल हों। -- **अनुवाद**: किसी locale को जोड़ने या अपडेट करने के लिए [internationalization guide](https://github.com/langgenius/dify/blob/main/web/i18n-config/README.md) का पालन करें। +- **अनुवाद**: किसी locale को जोड़ने या अपडेट करने के लिए [internationalization guide](https://github.com/langgenius/dify/blob/main/web/i18n/README.md) का पालन करें। - **समुदाय**: अपने बनाए हुए ऐप साझा करें, अन्य उपयोगकर्ताओं की मदद करें और Dify के बारे में लोगों को बताएं। ### योगदानकर्ता diff --git a/docs/it-IT/README.md b/docs/it-IT/README.md index 0d258a07707..427d3a42382 100644 --- a/docs/it-IT/README.md +++ b/docs/it-IT/README.md @@ -144,7 +144,7 @@ Dify accoglie contributi di ogni tipo: - **Codice**: leggi la [Guida ai contributi](../../CONTRIBUTING.md), quindi consulta le [issue adatte ai nuovi contributori](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22). - **Idee e feedback**: avvia o partecipa a una [discussione su GitHub](https://github.com/langgenius/dify/discussions). -- **Traduzioni**: segui la [guida all’internazionalizzazione](https://github.com/langgenius/dify/blob/main/web/i18n-config/README.md) per aggiungere o aggiornare una lingua. +- **Traduzioni**: segui la [guida all’internazionalizzazione](https://github.com/langgenius/dify/blob/main/web/i18n/README.md) per aggiungere o aggiornare una lingua. - **Community**: condividi le app che crei, aiuta gli altri utenti e fai conoscere Dify. ### Collaboratori diff --git a/docs/ja-JP/README.md b/docs/ja-JP/README.md index fe1ea07c551..10403ad6d6d 100644 --- a/docs/ja-JP/README.md +++ b/docs/ja-JP/README.md @@ -141,7 +141,7 @@ Difyでは、あらゆる形の貢献を歓迎しています。 - **コード**:[コントリビューションガイド](./CONTRIBUTING.md)を読み、[good first issue](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22)を確認してください。 - **アイデアとフィードバック**:[GitHub Discussions](https://github.com/langgenius/dify/discussions)で新しい議論を始めるか、既存の議論に参加してください。 -- **翻訳**:言語の追加や更新については、[国際化ガイド](https://github.com/langgenius/dify/blob/main/web/i18n-config/README.md)に従ってください。 +- **翻訳**:言語の追加や更新については、[国際化ガイド](https://github.com/langgenius/dify/blob/main/web/i18n/README.md)に従ってください。 - **コミュニティ**:作成したアプリを共有し、他のユーザーを支援し、Difyを広めてください。 ### 貢献者 diff --git a/docs/ko-KR/README.md b/docs/ko-KR/README.md index 40638aa2449..f49404c6d7f 100644 --- a/docs/ko-KR/README.md +++ b/docs/ko-KR/README.md @@ -134,7 +134,7 @@ Dify는 모든 형태의 기여를 환영합니다. - **코드**: [기여 가이드](./CONTRIBUTING.md)를 읽고 [good first issue](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22)를 살펴보세요. - **아이디어 및 피드백**: [GitHub 토론](https://github.com/langgenius/dify/discussions)을 시작하거나 기존 토론에 참여하세요. -- **번역**: 언어를 추가하거나 업데이트하려면 [국제화 가이드](https://github.com/langgenius/dify/blob/main/web/i18n-config/README.md)를 따르세요. +- **번역**: 언어를 추가하거나 업데이트하려면 [국제화 가이드](https://github.com/langgenius/dify/blob/main/web/i18n/README.md)를 따르세요. - **커뮤니티**: 만든 앱을 공유하고, 다른 사용자를 돕고, Dify를 널리 알려주세요. ### 기여자 diff --git a/docs/pt-BR/README.md b/docs/pt-BR/README.md index 7dc7026c647..7fd3d738305 100644 --- a/docs/pt-BR/README.md +++ b/docs/pt-BR/README.md @@ -139,7 +139,7 @@ O Dify aceita contribuições de todos os tipos: - **Código**: leia o [Guia de Contribuição](./CONTRIBUTING.md) e consulte as [issues para iniciantes](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22). - **Ideias e feedback**: inicie ou participe de uma [discussão no GitHub](https://github.com/langgenius/dify/discussions). -- **Traduções**: siga o [guia de internacionalização](https://github.com/langgenius/dify/blob/main/web/i18n-config/README.md) para adicionar ou atualizar um idioma. +- **Traduções**: siga o [guia de internacionalização](https://github.com/langgenius/dify/blob/main/web/i18n/README.md) para adicionar ou atualizar um idioma. - **Comunidade**: compartilhe os aplicativos que você criar, ajude outros usuários e divulgue o Dify. ### Contribuidores diff --git a/docs/sl-SI/README.md b/docs/sl-SI/README.md index 680a858634e..38c41317ecf 100644 --- a/docs/sl-SI/README.md +++ b/docs/sl-SI/README.md @@ -141,7 +141,7 @@ Dify pozdravlja vse vrste prispevkov: - **Koda**: preberite [vodnik za prispevke](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md), nato pa preglejte [naloge za nove sodelavce](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22). - **Ideje in povratne informacije**: začnite ali se pridružite [razpravi na GitHubu](https://github.com/langgenius/dify/discussions). -- **Prevodi**: sledite [vodniku za internacionalizacijo](https://github.com/langgenius/dify/blob/main/web/i18n-config/README.md), da dodate ali posodobite jezik. +- **Prevodi**: sledite [vodniku za internacionalizacijo](https://github.com/langgenius/dify/blob/main/web/i18n/README.md), da dodate ali posodobite jezik. - **Skupnost**: delite ustvarjene aplikacije, pomagajte drugim uporabnikom in razširite glas o Difyju. ### Sodelavci diff --git a/docs/tlh/README.md b/docs/tlh/README.md index df8bfd47b8d..12d639fec38 100644 --- a/docs/tlh/README.md +++ b/docs/tlh/README.md @@ -140,7 +140,7 @@ Dify welcomes contributions of all kinds: - **Code**: Read the [Contribution Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md), then browse [good first issues](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22). - **Ideas and feedback**: Start or join a [GitHub Discussion](https://github.com/langgenius/dify/discussions). -- **Translations**: Follow the [internationalization guide](https://github.com/langgenius/dify/blob/main/web/i18n-config/README.md) to add or update a locale. +- **Translations**: Follow the [internationalization guide](https://github.com/langgenius/dify/blob/main/web/i18n/README.md) to add or update a locale. - **Community**: Share the apps you build, help other users, and spread the word about Dify. ### Contributors diff --git a/docs/tr-TR/README.md b/docs/tr-TR/README.md index 93ef7013235..d99d0f5c02a 100644 --- a/docs/tr-TR/README.md +++ b/docs/tr-TR/README.md @@ -134,7 +134,7 @@ Dify her türlü katkıyı memnuniyetle karşılar: - **Kod**: [Katkı Kılavuzu'nu](./CONTRIBUTING.md) okuyun, ardından [yeni katkıda bulunanlara uygun sorunlara](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22) göz atın. - **Fikirler ve geri bildirim**: Bir [GitHub tartışması](https://github.com/langgenius/dify/discussions) başlatın veya mevcut bir tartışmaya katılın. -- **Çeviriler**: Bir dil eklemek veya güncellemek için [uluslararasılaştırma kılavuzunu](https://github.com/langgenius/dify/blob/main/web/i18n-config/README.md) izleyin. +- **Çeviriler**: Bir dil eklemek veya güncellemek için [uluslararasılaştırma kılavuzunu](https://github.com/langgenius/dify/blob/main/web/i18n/README.md) izleyin. - **Topluluk**: Oluşturduğunuz uygulamaları paylaşın, diğer kullanıcılara yardımcı olun ve Dify'ı duyurun. ### Katkıda bulunanlar diff --git a/docs/vi-VN/README.md b/docs/vi-VN/README.md index db8d5904321..de05098833c 100644 --- a/docs/vi-VN/README.md +++ b/docs/vi-VN/README.md @@ -135,7 +135,7 @@ Dify chào đón mọi hình thức đóng góp: - **Mã nguồn**: đọc [Hướng dẫn đóng góp](./CONTRIBUTING.md), sau đó xem các [issue phù hợp cho người mới](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22). - **Ý tưởng và phản hồi**: bắt đầu hoặc tham gia một [thảo luận trên GitHub](https://github.com/langgenius/dify/discussions). -- **Bản dịch**: làm theo [hướng dẫn quốc tế hóa](https://github.com/langgenius/dify/blob/main/web/i18n-config/README.md) để thêm hoặc cập nhật một ngôn ngữ. +- **Bản dịch**: làm theo [hướng dẫn quốc tế hóa](https://github.com/langgenius/dify/blob/main/web/i18n/README.md) để thêm hoặc cập nhật một ngôn ngữ. - **Cộng đồng**: chia sẻ ứng dụng bạn xây dựng, hỗ trợ người dùng khác và giới thiệu Dify đến nhiều người hơn. ### Người đóng góp diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 5db1ed94a01..a7ef1a3102a 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -147,7 +147,7 @@ Dify 欢迎各种形式的贡献: - **代码**:阅读[贡献指南](./CONTRIBUTING.md),然后浏览[适合新手的问题](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22)。 - **想法与反馈**:发起或加入 [GitHub Discussions](https://github.com/langgenius/dify/discussions)。 -- **翻译**:按照[国际化指南](https://github.com/langgenius/dify/blob/main/web/i18n-config/README.md)添加或更新语言。 +- **翻译**:按照[国际化指南](https://github.com/langgenius/dify/blob/main/web/i18n/README.md)添加或更新语言。 - **社区**:分享您构建的应用、帮助其他用户,并向更多人介绍 Dify。 ### 贡献者 diff --git a/docs/zh-TW/README.md b/docs/zh-TW/README.md index b7682b181c6..c1418665269 100644 --- a/docs/zh-TW/README.md +++ b/docs/zh-TW/README.md @@ -145,7 +145,7 @@ Dify 歡迎各種形式的貢獻: - **程式碼**:閱讀[貢獻指南](./CONTRIBUTING.md),再瀏覽[適合新手的議題](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22)。 - **想法與意見**:發起或加入 [GitHub Discussions](https://github.com/langgenius/dify/discussions)。 -- **翻譯**:依照[國際化指南](https://github.com/langgenius/dify/blob/main/web/i18n-config/README.md)新增或更新語言。 +- **翻譯**:依照[國際化指南](https://github.com/langgenius/dify/blob/main/web/i18n/README.md)新增或更新語言。 - **社群**:分享您建立的應用程式、協助其他使用者,並向更多人介紹 Dify。 ### 貢獻者 diff --git a/eslint.config.mjs b/eslint.config.mjs index 9b72b129bd9..001db22be9a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -436,7 +436,7 @@ export default defineConfig([ }, }, { - files: ['web/i18n/**/*.json'], + files: ['web/i18n/locales/**/*.json'], plugins: { dify, }, diff --git a/knip.config.ts b/knip.config.ts index 4cf2444d674..d7bbb3a42d7 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -33,7 +33,7 @@ const config: KnipConfig = { '!vitest.setup.ts!', ], ignore: ['public/**'], - ignoreFiles: ['i18n-config/locale-resources/*.ts'], + ignoreFiles: ['i18n/locale-resources/*.ts'], ignoreDependencies: ['@iconify-json/*', '@storybook/addon-onboarding'], }, }, diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index a90e3d94156..cf881a47a03 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -769,12 +769,6 @@ } }, "web/app/components/base/chat/chat-with-history/header-in-mobile.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 4 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 4 - }, "typescript/no-explicit-any": { "count": 2 } @@ -1287,7 +1281,7 @@ }, "web/app/components/base/icons/src/vender/line/general/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 10 + "count": 9 } }, "web/app/components/base/icons/src/vender/line/images/index.ts": { @@ -5146,12 +5140,7 @@ "count": 4 } }, - "web/i18n-config/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 1 - } - }, - "web/i18n-config/lib.client.ts": { + "web/i18n/index.ts": { "no-barrel-files/no-barrel-files": { "count": 1 } diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index db32e4e26dd..d124e42c99f 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -39,7 +39,7 @@ Import `styles.css` once from the consumer's root stylesheet or entrypoint. | Actions | `./button`, `./icon-button`, `./toggle` | | Controls | `./segmented-control` | | Display | `./collapsible`, `./kbd` | -| Feedback | `./meter`, `./progress`, `./status-dot`, `./toast` | +| Feedback | `./meter`, `./progress`, `./spinner`, `./status-dot`, `./toast` | | Form | `./form`, `./field`, `./fieldset`, `./input`, `./input-group`, `./textarea`, `./checkbox`, `./checkbox-group`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | | Layout | `./scroll-area` | | Media | `./avatar` | diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index 71da5d3d9d7..8084254b75f 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -160,6 +160,10 @@ "./tooltip": { "types": "./src/tooltip/index.tsx", "import": "./src/tooltip/index.tsx" + }, + "./spinner": { + "types": "./src/spinner/index.tsx", + "import": "./src/spinner/index.tsx" } }, "scripts": { diff --git a/packages/dify-ui/src/spinner/README.md b/packages/dify-ui/src/spinner/README.md new file mode 100644 index 00000000000..f1ac8062eab --- /dev/null +++ b/packages/dify-ui/src/spinner/README.md @@ -0,0 +1,19 @@ +# Spinner + +`Spinner` is a named, indeterminate progress indicator built on Base UI Progress. Supply exactly +one accessible name through `aria-label` or `aria-labelledby`. Localization belongs to the +consumer. The component fixes `value` to `null` and omits value text rather than exposing Base UI's +English indeterminate fallback. It does not announce completion or manage the surrounding region's +busy state; those belong to the feature's status owner. + +`SpinnerIcon` is the same decorative artwork for controls that already own their loading state and +accessible name. It is always hidden from assistive technology. Do not place a second progress +indicator inside a loading button. + +Both exports occupy only their own size (12, 16, or 20 pixels; default 16). `className` styles that +size and color, not an enclosing page or panel. The four-square artwork, opacity sequence, two-second +cycle, and reduced-motion still frame preserve the former Web Loading visual. The component +retains its existing blue in both themes; a palette change is a separate design decision. + +Web owns loading placeholders, page geometry, translations, and data readiness. Do not add +`app`, `area`, fullscreen, centering, request, delay, or content-replacement props here. diff --git a/packages/dify-ui/src/spinner/index.stories.tsx b/packages/dify-ui/src/spinner/index.stories.tsx new file mode 100644 index 00000000000..3f312709da5 --- /dev/null +++ b/packages/dify-ui/src/spinner/index.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect } from 'storybook/test' +import { Spinner, SpinnerIcon } from '.' + +const meta = { + title: 'Base/UI/Spinner', + component: Spinner, + parameters: { layout: 'centered' }, + tags: ['autodocs'], + args: { 'aria-label': 'Loading agent details' }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + play: async ({ canvas }) => { + const progress = canvas.getByRole('progressbar', { name: 'Loading agent details' }) + await expect(progress).not.toHaveAttribute('aria-valuenow') + await expect(progress).not.toHaveAttribute('aria-valuetext') + }, +} + +export const VisibleLabel: Story = { + render: () => ( +
+ + Loading files +
+ ), + play: async ({ canvas }) => { + await expect(canvas.getByRole('progressbar', { name: 'Loading files' })).toBeVisible() + }, +} + +export const Sizes: Story = { + render: () => ( +
+ + + + +
+ ), +} + +export const Decorative: Story = { + render: () => ( + + ), + play: async ({ canvas }) => { + await expect(canvas.getByRole('button', { name: 'Loading audio' })).toBeDisabled() + await expect(canvas.queryByRole('progressbar')).not.toBeInTheDocument() + }, +} diff --git a/packages/dify-ui/src/spinner/index.tsx b/packages/dify-ui/src/spinner/index.tsx new file mode 100644 index 00000000000..e5abe2e7809 --- /dev/null +++ b/packages/dify-ui/src/spinner/index.tsx @@ -0,0 +1,65 @@ +'use client' + +import type { ComponentProps } from 'react' +import { Progress as BaseProgress } from '@base-ui/react/progress' +import { cva } from 'class-variance-authority' +import { cn } from '../cn' + +const spinnerVariants = cva('inline-flex shrink-0 text-[#1c64f2]', { + variants: { + size: { + small: 'size-3', + medium: 'size-4', + large: 'size-5', + }, + }, + defaultVariants: { size: 'medium' }, +}) + +type SpinnerAppearanceProps = { + size?: 'small' | 'medium' | 'large' + className?: string +} + +type SpinnerIconProps = SpinnerAppearanceProps + +type SpinnerProps = SpinnerAppearanceProps & + Pick, 'id' | 'aria-describedby'> & + ( + | { 'aria-label': string; 'aria-labelledby'?: never } + | { 'aria-label'?: never; 'aria-labelledby': string } + ) + +function SpinnerIcon({ size = 'medium', className }: SpinnerIconProps) { + return ( + + ) +} + +function Spinner({ size = 'medium', className, ...props }: SpinnerProps) { + return ( + + + + ) +} + +export { Spinner, SpinnerIcon } +export type { SpinnerIconProps, SpinnerProps } diff --git a/packages/dify-ui/src/styles/components.css b/packages/dify-ui/src/styles/components.css index 06547b66320..f889ead1fe8 100644 --- a/packages/dify-ui/src/styles/components.css +++ b/packages/dify-ui/src/styles/components.css @@ -86,3 +86,41 @@ transition: none; } } + +[data-dify-spinner] > rect { + animation: dify-spinner-opacity 2s linear infinite; +} + +[data-dify-spinner] > rect:nth-child(2) { + animation-delay: 0.5s; +} +[data-dify-spinner] > rect:nth-child(3) { + animation-delay: 1s; +} +[data-dify-spinner] > rect:nth-child(4) { + animation-delay: 1.5s; +} + +@keyframes dify-spinner-opacity { + 0% { + opacity: 0; + } + 25% { + opacity: 0.1; + } + 50% { + opacity: 0.2; + } + 75% { + opacity: 0.5; + } + 100% { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + [data-dify-spinner] > rect { + animation: none; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbfd9dcdb0a..a3d1738e31c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -185,9 +185,6 @@ catalogs: '@egoist/tailwindcss-icons': specifier: 1.9.2 version: 1.9.2 - '@emoji-mart/data': - specifier: 1.2.1 - version: 1.2.1 '@eslint-community/eslint-plugin-eslint-comments': specifier: 4.8.1 version: 4.8.1 @@ -470,9 +467,6 @@ catalogs: embla-carousel-react: specifier: 8.6.0 version: 8.6.0 - emoji-mart: - specifier: 5.6.0 - version: 5.6.0 es-toolkit: specifier: 1.52.0 version: 1.52.0 @@ -542,6 +536,9 @@ catalogs: foxact: specifier: 0.3.11 version: 0.3.11 + frimousse: + specifier: 0.3.0 + version: 0.3.0 fuse.js: specifier: 7.5.0 version: 7.5.0 @@ -1372,9 +1369,6 @@ importers: '@base-ui/react': specifier: 'catalog:' version: 1.8.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@emoji-mart/data': - specifier: 'catalog:' - version: 1.2.1 '@floating-ui/react': specifier: 'catalog:' version: 0.27.20(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -1498,9 +1492,6 @@ importers: embla-carousel-react: specifier: 'catalog:' version: 8.6.0(react@19.3.0) - emoji-mart: - specifier: 'catalog:' - version: 5.6.0 es-toolkit: specifier: 'catalog:' version: 1.52.0 @@ -1510,6 +1501,9 @@ importers: foxact: specifier: 'catalog:' version: 0.3.11(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + frimousse: + specifier: 'catalog:' + version: 0.3.0(@typescript/typescript6@6.0.2)(react@19.3.0) fuse.js: specifier: 'catalog:' version: 7.5.0 @@ -2201,9 +2195,6 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - '@emoji-mart/data@1.2.1': - resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==} - '@es-joy/jsdoccomment@0.92.0': resolution: {integrity: sha512-WivZqV5jPTYDQJgIMp0hTsyQESKYNY6yCJr4b0l84tYtQntkAMkc6zARh2vlRQyeg4aYHPUkCUDpvr2IWAiGpQ==} engines: {node: ^22.22.2 || >=24.15.0} @@ -6376,9 +6367,6 @@ packages: embla-carousel@8.6.0: resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} - emoji-mart@5.6.0: - resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==} - emoji-regex-xs@2.0.1: resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==} engines: {node: '>=10.0.0'} @@ -6840,6 +6828,15 @@ packages: react-dom: optional: true + frimousse@0.3.0: + resolution: {integrity: sha512-kO6LMoKY/cLAYEhXXtqLRaLIE6L/DagpFPrUZaLv3LsUa1/8Iza3HhwZcgN8eZ+weXnhv69eoclNUPohcCa/IQ==} + peerDependencies: + react: ^18 || ^19 + typescript: '>=5.1.0' + peerDependenciesMeta: + typescript: + optional: true + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -10227,8 +10224,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emoji-mart/data@1.2.1': {} - '@es-joy/jsdoccomment@0.92.0': dependencies: '@types/estree': 1.0.9 @@ -14002,8 +13997,6 @@ snapshots: embla-carousel@8.6.0: {} - emoji-mart@5.6.0: {} - emoji-regex-xs@2.0.1: {} empathic@2.0.1: {} @@ -14624,6 +14617,12 @@ snapshots: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) + frimousse@0.3.0(@typescript/typescript6@6.0.2)(react@19.3.0): + dependencies: + react: 19.3.0 + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' + fs-constants@1.0.0: optional: true @@ -18144,7 +18143,6 @@ time: '@chromatic-com/storybook@5.3.1': '2026-09-02T13:43:27.595Z' '@cucumber/cucumber@13.2.1': '2026-08-04T07:20:28.681Z' '@egoist/tailwindcss-icons@1.9.2': '2026-01-31T10:48:44.594Z' - '@emoji-mart/data@1.2.1': '2024-04-25T15:36:14.556Z' '@eslint-community/eslint-plugin-eslint-comments@4.8.1': '2026-09-12T22:48:47.148Z' '@eslint-react/eslint-plugin@5.19.1': '2026-09-14T17:56:33.726Z' '@eslint/markdown@8.0.3': '2026-07-01T13:28:44.339Z' @@ -18240,7 +18238,6 @@ time: embla-carousel-autoplay@8.6.0: '2025-04-04T17:37:46.303Z' embla-carousel-fade@8.6.0: '2025-04-04T17:37:50.278Z' embla-carousel-react@8.6.0: '2025-04-04T17:37:53.976Z' - emoji-mart@5.6.0: '2024-04-25T14:22:21.440Z' es-toolkit@1.52.0: '2026-08-28T07:22:17.687Z' eslint-config-flat-gitignore@2.4.0: '2026-08-27T07:24:43.063Z' eslint-markdown@0.17.0: '2026-09-16T06:20:36.564Z' @@ -18264,6 +18261,7 @@ time: eventsource-parser@4.1.1: '2026-09-15T18:30:29.195Z' fast-deep-equal@3.1.3: '2020-06-08T07:27:28.474Z' foxact@0.3.11: '2026-09-05T12:25:54.102Z' + frimousse@0.3.0: '2025-07-15T10:44:32.958Z' fuse.js@7.5.0: '2026-07-13T17:23:36.705Z' happy-dom@20.14.5: '2026-09-12T00:07:47.688Z' hast-util-to-jsx-runtime@2.3.6: '2025-03-05T11:30:29.166Z' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 06f875aeb43..0b36591bfb4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -70,7 +70,6 @@ catalog: '@chromatic-com/storybook': 5.3.1 '@cucumber/cucumber': 13.2.1 '@egoist/tailwindcss-icons': 1.9.2 - '@emoji-mart/data': 1.2.1 '@eslint-community/eslint-plugin-eslint-comments': 4.8.1 '@eslint-react/eslint-plugin': 5.19.1 '@eslint/markdown': 8.0.3 @@ -166,7 +165,6 @@ catalog: embla-carousel-autoplay: 8.6.0 embla-carousel-fade: 8.6.0 embla-carousel-react: 8.6.0 - emoji-mart: 5.6.0 es-toolkit: 1.52.0 eslint: 10.10.0 eslint-config-flat-gitignore: 2.4.0 @@ -190,6 +188,7 @@ catalog: eventsource-parser: 4.1.1 fast-deep-equal: 3.1.3 foxact: 0.3.11 + frimousse: 0.3.0 fuse.js: 7.5.0 happy-dom: 20.14.5 hast-util-to-jsx-runtime: 2.3.6 diff --git a/vite.config.ts b/vite.config.ts index 8d47f7528bb..dee3d7eccca 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -40,6 +40,8 @@ const generatedIgnores = [ 'web/public/embed.min.js', 'web/public/pdf.worker.min.mjs', 'web/public/vs/**', + // Vendored Emojibase JSON is served verbatim. + 'web/public/emoji/emojibase-*/**', ] const formatterUnstableInputs = ['web/app/components/develop/template/*.mdx'] diff --git a/web/.storybook/preview.tsx b/web/.storybook/preview.tsx index b9da6f7c6af..e1a1638437d 100644 --- a/web/.storybook/preview.tsx +++ b/web/.storybook/preview.tsx @@ -4,7 +4,7 @@ import { ToastHost } from '@langgenius/dify-ui/toast' import { withThemeByDataAttribute } from '@storybook/addon-themes' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { I18nClientProvider as I18N } from '../app/components/provider/i18n' -import commonEnUS from '../i18n/en-US/common.json' +import commonEnUS from '../i18n/locales/en-US/common.json' import '../app/styles/markdown.css' import './storybook.css' diff --git a/web/AGENTS.md b/web/AGENTS.md index 549c26553b8..a57404d1416 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -10,7 +10,7 @@ Web owns application-specific requirements and consumes shared architecture guid - For truncated text disclosure and native `title` decisions, follow [Truncated Text Disclosure]. - When adding or changing page layouts, navigation, or landmark elements, follow [Page landmarks] and inspect the composed page, including parent layouts. -- User-facing strings must use `web/i18n/en-US/` keys. When adding or renaming a key, update every supported locale with the correct localized value. +- User-facing strings must use `web/i18n/locales/en-US/` keys. When adding or renaming a key, update every supported locale with the correct localized value. - For new backend calls and migrated surfaces, use generated `consoleQuery` / `consoleClient` APIs from `@/service/console`. Do not add handwritten REST helpers or DTO mirrors, mock-backed app state, or direct edits to generated contracts. - Prefer `@langgenius/dify-ui/*` primitives, data attributes, and design tokens. Use the [Dify UI package index] to find a primitive; read the relevant contract directly when it is already known. Preserve a visible focus indicator on the final focusable element. - Reuse the Web `SearchInput` composite when its search, clear, and IME contract matches the feature; otherwise follow the canonical [Input Group contract]. diff --git a/web/__tests__/app-star-i18n.test.ts b/web/__tests__/app-star-i18n.test.ts index 31d60ce7f7a..93685b27315 100644 --- a/web/__tests__/app-star-i18n.test.ts +++ b/web/__tests__/app-star-i18n.test.ts @@ -1,7 +1,7 @@ import fs from 'node:fs' import path from 'node:path' -const I18N_DIR = path.join(__dirname, '../i18n') +const I18N_DIR = path.join(__dirname, '../i18n/locales') const REQUIRED_APP_STAR_KEYS = [ 'studio.allApps', diff --git a/web/__tests__/base/chat-flow.test.tsx b/web/__tests__/base/chat-flow.test.tsx index f82ffdad606..7f7f3b2145a 100644 --- a/web/__tests__/base/chat-flow.test.tsx +++ b/web/__tests__/base/chat-flow.test.tsx @@ -186,7 +186,7 @@ describe('Base Chat Flow', () => { const { container } = render() expect(useDocumentTitle).toHaveBeenCalledWith('Chat') - expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.getByRole('progressbar', { name: 'appApi.loading' })).toBeInTheDocument() expect(container.querySelector('.mobile-chat-shell')).toBeInTheDocument() expect(container.querySelector('.rounded-t-2xl')).toBeInTheDocument() expect(container.querySelector('.rounded-2xl')).not.toBeInTheDocument() diff --git a/web/__tests__/billing/billing-integration.test.tsx b/web/__tests__/billing/billing-integration.test.tsx index cb41580ac00..30efc0f7c86 100644 --- a/web/__tests__/billing/billing-integration.test.tsx +++ b/web/__tests__/billing/billing-integration.test.tsx @@ -67,6 +67,10 @@ vi.mock('@/context/workspace-state', async () => { vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en-US', +})) + +vi.mock('#i18n', async (importOriginal) => ({ + ...(await importOriginal()), useLocale: () => 'en-US', })) diff --git a/web/__tests__/billing/pricing-modal-flow.test.tsx b/web/__tests__/billing/pricing-modal-flow.test.tsx index 40ffa9370a4..ee154ba178a 100644 --- a/web/__tests__/billing/pricing-modal-flow.test.tsx +++ b/web/__tests__/billing/pricing-modal-flow.test.tsx @@ -60,6 +60,10 @@ vi.mock('@/context/workspace-state', async () => { }) vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en-US', +})) + +vi.mock('#i18n', async (importOriginal) => ({ + ...(await importOriginal()), useLocale: () => 'en-US', })) diff --git a/web/__tests__/i18n-upload-features.test.ts b/web/__tests__/i18n-upload-features.test.ts index 50aef054f0c..bd42227bb52 100644 --- a/web/__tests__/i18n-upload-features.test.ts +++ b/web/__tests__/i18n-upload-features.test.ts @@ -7,7 +7,7 @@ import fs from 'node:fs' import path from 'node:path' // Get all supported locales from the i18n directory -const I18N_DIR = path.join(__dirname, '../i18n') +const I18N_DIR = path.join(__dirname, '../i18n/locales') const getSupportedLocales = (): string[] => { return fs .readdirSync(I18N_DIR) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/page.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/page.tsx index 6a111ee77d1..92dcff4064a 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/page.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/page.tsx @@ -1,4 +1,4 @@ -import type { Locale } from '@/i18n-config' +import type { Locale } from '@/i18n' import AppAccessConfigPage from '@/app/components/app/access-config' export type AccessConfigPageProps = { diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index e37f124dd9a..4df033cbffe 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -9,7 +9,7 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { useStore } from '@/app/components/app/store' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { workspacePermissionKeysAtom, workspacePermissionKeysLoadingAtom, @@ -218,7 +218,7 @@ const AppDetailLayout: FC = (props) => { const content = !appDetail || shouldBlockAgentResourceAccess || shouldBlockAccessPointAccess ? (
- +
) : (
{ return (
- +
) diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index 8a304f946c7..1a181ac2511 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -7,7 +7,7 @@ import { useAtomValue } from 'jotai' import * as React from 'react' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import DatasetDetailContext from '@/context/dataset-detail' import { workspacePermissionKeysAtom, @@ -153,7 +153,7 @@ const DatasetDetailLayout: FC = (props) => { shouldRedirectUnauthorizedRoute const documentTitleOwnedByChild = isDocumentDetailPath(pathname) && !shouldShowLoading const content = shouldShowLoading ? ( - + ) : (
({})) -vi.mock('@/i18n-config/server', async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock('@/i18n/server', async (importOriginal) => ({ + ...(await importOriginal()), getLocaleOnServer: async () => 'en-US', })) diff --git a/web/app/(commonLayout)/datasets/layout.spec.tsx b/web/app/(commonLayout)/datasets/layout.spec.tsx index adb5be23009..fc9f8e9a757 100644 --- a/web/app/(commonLayout)/datasets/layout.spec.tsx +++ b/web/app/(commonLayout)/datasets/layout.spec.tsx @@ -71,7 +71,7 @@ describe('DatasetsLayout', () => { , ) - expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.getByRole('progressbar', { name: 'appApi.loading' })).toBeInTheDocument() expect(screen.queryByText('datasets')).not.toBeInTheDocument() expect(mockReplace).not.toHaveBeenCalled() }) @@ -88,7 +88,7 @@ describe('DatasetsLayout', () => { , ) - expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.getByRole('progressbar', { name: 'appApi.loading' })).toBeInTheDocument() expect(screen.queryByText('datasets')).not.toBeInTheDocument() expect(mockReplace).not.toHaveBeenCalled() }) diff --git a/web/app/(commonLayout)/datasets/layout.tsx b/web/app/(commonLayout)/datasets/layout.tsx index 8b8ae239676..b7d52ee18d9 100644 --- a/web/app/(commonLayout)/datasets/layout.tsx +++ b/web/app/(commonLayout)/datasets/layout.tsx @@ -2,7 +2,7 @@ import { useAtomValue } from 'jotai' import { useEffect } from 'react' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { workspacePermissionKeysAtom, workspacePermissionKeysLoadingAtom, @@ -48,7 +48,7 @@ export default function DatasetsLayout({ children }: { children: React.ReactNode if (shouldRedirectToDatasets) router.replace('/datasets') }, [shouldRedirectToDatasets, router]) - if (isLoadingAccess || !currentWorkspaceId) return + if (isLoadingAccess || !currentWorkspaceId) return if (shouldRedirectToDatasets) { return null diff --git a/web/app/(commonLayout)/integrations/[[...slug]]/__tests__/metadata.spec.ts b/web/app/(commonLayout)/integrations/[[...slug]]/__tests__/metadata.spec.ts index de7f7a1db59..9ae932f831b 100644 --- a/web/app/(commonLayout)/integrations/[[...slug]]/__tests__/metadata.spec.ts +++ b/web/app/(commonLayout)/integrations/[[...slug]]/__tests__/metadata.spec.ts @@ -6,8 +6,8 @@ vi.mock('@/app/components/integrations', () => ({ default: () => null, })) -vi.mock('@/i18n-config/server', async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock('@/i18n/server', async (importOriginal) => ({ + ...(await importOriginal()), getLocaleOnServer: async () => 'en-US', })) diff --git a/web/app/(commonLayout)/integrations/[[...slug]]/layout.tsx b/web/app/(commonLayout)/integrations/[[...slug]]/layout.tsx index b584c3e4a03..bf996fa6214 100644 --- a/web/app/(commonLayout)/integrations/[[...slug]]/layout.tsx +++ b/web/app/(commonLayout)/integrations/[[...slug]]/layout.tsx @@ -2,7 +2,7 @@ import type { PropsWithChildren } from 'react' import type { IntegrationSection } from '@/app/components/integrations/routes' import type { Metadata } from '@/next' import { getIntegrationRouteTargetBySlug } from '@/app/components/integrations/routes' -import { getLocaleOnServer, getTranslation } from '@/i18n-config/server' +import { getLocaleOnServer, getTranslation } from '@/i18n/server' type IntegrationsRouteLayoutProps = PropsWithChildren<{ params: Promise<{ diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx index b011b432ef8..c2eed0a2818 100644 --- a/web/app/(commonLayout)/layout.tsx +++ b/web/app/(commonLayout)/layout.tsx @@ -1,6 +1,5 @@ import * as React from 'react' import Zendesk from '@/app/components/base/zendesk' -import MaintenanceNotice from '@/app/components/header/maintenance-notice' import MainNavLayout from '@/app/components/main-nav/server' import { CommonLayoutGlobalMounts } from './global-mounts' import { ConsoleContextProviders, ConsoleRuntimeProviders } from './providers' @@ -16,7 +15,6 @@ export default async function Layout({
- {children} diff --git a/web/app/(commonLayout)/marketplace/creator/[uniqueHandle]/page.tsx b/web/app/(commonLayout)/marketplace/creator/[uniqueHandle]/page.tsx index 1c9c5615aa6..6b64be6d96a 100644 --- a/web/app/(commonLayout)/marketplace/creator/[uniqueHandle]/page.tsx +++ b/web/app/(commonLayout)/marketplace/creator/[uniqueHandle]/page.tsx @@ -1,7 +1,7 @@ import { loadCreatorProfile } from '@/app/components/plugins/marketplace/creator-profile/data.server' import DifyCreatorProfile from '@/app/components/plugins/marketplace/creator-profile/dify-profile' import MarketplaceInstallPermissionProvider from '@/app/components/plugins/marketplace/marketplace-install-permission-provider' -import { getLocaleOnServer } from '@/i18n-config/server' +import { getLocaleOnServer } from '@/i18n/server' import { notFound } from '@/next/navigation' type CreatorPageSearchParams = { diff --git a/web/app/(commonLayout)/skills/__tests__/metadata.spec.ts b/web/app/(commonLayout)/skills/__tests__/metadata.spec.ts index 92e3975587c..5a0c1f5f50d 100644 --- a/web/app/(commonLayout)/skills/__tests__/metadata.spec.ts +++ b/web/app/(commonLayout)/skills/__tests__/metadata.spec.ts @@ -3,8 +3,8 @@ import { generateMetadata } from '../page' vi.mock('server-only', () => ({})) -vi.mock('@/i18n-config/server', async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock('@/i18n/server', async (importOriginal) => ({ + ...(await importOriginal()), getLocaleOnServer: async () => 'en-US', })) diff --git a/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx b/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx index 2f1c403ebb0..242a96a7d43 100644 --- a/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx +++ b/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx @@ -63,7 +63,7 @@ vi.mock('@/app/components/plugins/marketplace/templates', () => ({ ), })) -vi.mock('@/i18n-config/server', () => ({ +vi.mock('@/i18n/server', () => ({ getLocaleOnServer: () => Promise.resolve('en-US'), })) diff --git a/web/app/(commonLayout)/templates/[[...category]]/page.tsx b/web/app/(commonLayout)/templates/[[...category]]/page.tsx index 3a4f554086c..3a2b1e1e8a3 100644 --- a/web/app/(commonLayout)/templates/[[...category]]/page.tsx +++ b/web/app/(commonLayout)/templates/[[...category]]/page.tsx @@ -2,7 +2,7 @@ import { MARKETPLACE_CONTAINER_ID } from '@/app/components/plugins/marketplace/c import { EmbeddedTemplatesMarketplace } from '@/app/components/plugins/marketplace/templates' import { TemplateDetailRouteProvider } from '@/app/components/plugins/marketplace/templates/template-detail-route' import { parseTemplatesRoute } from '@/app/components/plugins/marketplace/templates/template-links' -import { getLocaleOnServer } from '@/i18n-config/server' +import { getLocaleOnServer } from '@/i18n/server' import { redirect } from '@/next/navigation' type TemplatesPageProps = { diff --git a/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx b/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx index 8d0bdcb4534..4ea39a7a665 100644 --- a/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx +++ b/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx @@ -116,9 +116,8 @@ vi.mock('@/app/components/base/chat/chat/answer/human-input-content/expiration-t default: () =>
expiration-time
, })) -vi.mock('@/app/components/base/loading', () => ({ - __esModule: true, - default: () =>
loading
, +vi.mock('@/app/components/base/loading-placeholder', () => ({ + LoadingPlaceholder: () =>
loading
, })) vi.mock('@/app/components/base/logo/dify-logo', () => ({ diff --git a/web/app/(humanInputLayout)/form/[token]/form.tsx b/web/app/(humanInputLayout)/form/[token]/form.tsx index d320bc27ebf..7f533ae16b1 100644 --- a/web/app/(humanInputLayout)/form/[token]/form.tsx +++ b/web/app/(humanInputLayout)/form/[token]/form.tsx @@ -5,7 +5,7 @@ import type { HumanInputFormError } from '@/service/use-share' import type { HumanInputResolvedValue } from '@/types/workflow' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import useDocumentTitle from '@/hooks/use-document-title' import { useParams } from '@/next/navigation' import { useGetHumanInputForm } from '@/service/use-share' @@ -57,7 +57,7 @@ const FormContent = () => { useDocumentTitle(documentTitle) if (isLoading) { - return + return } if (success) { diff --git a/web/app/(shareLayout)/components/authenticated-layout.tsx b/web/app/(shareLayout)/components/authenticated-layout.tsx index 2f052672562..cf6736c6eb2 100644 --- a/web/app/(shareLayout)/components/authenticated-layout.tsx +++ b/web/app/(shareLayout)/components/authenticated-layout.tsx @@ -4,7 +4,7 @@ import * as React from 'react' import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' import AppUnavailable from '@/app/components/base/app-unavailable' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { useWebAppStore } from '@/context/web-app-context' import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control' @@ -116,7 +116,7 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => { ) { return (
- +
) } diff --git a/web/app/(shareLayout)/components/splash.tsx b/web/app/(shareLayout)/components/splash.tsx index eaede783da1..052d71e5829 100644 --- a/web/app/(shareLayout)/components/splash.tsx +++ b/web/app/(shareLayout)/components/splash.tsx @@ -8,7 +8,7 @@ import { resolveWebAppLoginRedirect, } from '@/app/(shareLayout)/webapp-signin/login-redirect' import AppUnavailable from '@/app/components/base/app-unavailable' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { useWebAppStore } from '@/context/web-app-context' import { AccessMode } from '@/models/access-control' import { usePathname, useRouter, useSearchParams } from '@/next/navigation' @@ -176,7 +176,7 @@ function Splash({ children }: PropsWithChildren) { if (isLoading) { return (
- +
) } diff --git a/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx index 0e378cd8e2e..9f7d89ca924 100644 --- a/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx @@ -5,8 +5,8 @@ import { toast } from '@langgenius/dify-ui/toast' import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { useLocale } from '#i18n' import Countdown from '@/app/components/signin/countdown' -import { useLocale } from '@/context/i18n' import useDocumentTitle from '@/hooks/use-document-title' import { useRouter, useSearchParams } from '@/next/navigation' import { sendWebAppResetPasswordCode, verifyWebAppResetPasswordCode } from '@/service/common' diff --git a/web/app/(shareLayout)/webapp-reset-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/page.tsx index 9c0fd0e2b34..99449d9d568 100644 --- a/web/app/(shareLayout)/webapp-reset-password/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/page.tsx @@ -6,9 +6,9 @@ import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { useLocale } from '#i18n' import { COUNT_DOWN_TIME_MS, useSetCountdownLeftTime } from '@/app/components/signin/storage' import { emailRegex } from '@/config' -import { useLocale } from '@/context/i18n' import useDocumentTitle from '@/hooks/use-document-title' import Link from '@/next/link' import { useRouter, useSearchParams } from '@/next/navigation' diff --git a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx index f586ae17452..e6a953db618 100644 --- a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx @@ -5,12 +5,12 @@ import { Input } from '@langgenius/dify-ui/input' import { toast } from '@langgenius/dify-ui/toast' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useLocale } from '#i18n' import { navigateAfterWebAppLogin, resolveWebAppLoginRedirect, } from '@/app/(shareLayout)/webapp-signin/login-redirect' import Countdown from '@/app/components/signin/countdown' -import { useLocale } from '@/context/i18n' import { useWebAppStore } from '@/context/web-app-context' import { useRouter, useSearchParams } from '@/next/navigation' import { sendWebAppEMailLoginCode, webAppEmailLoginWithCode } from '@/service/common' diff --git a/web/app/(shareLayout)/webapp-signin/components/__tests__/mail-and-code-auth.spec.tsx b/web/app/(shareLayout)/webapp-signin/components/__tests__/mail-and-code-auth.spec.tsx index e0765426bcc..68eed546fc5 100644 --- a/web/app/(shareLayout)/webapp-signin/components/__tests__/mail-and-code-auth.spec.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/__tests__/mail-and-code-auth.spec.tsx @@ -15,7 +15,8 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: () => searchParams, })) -vi.mock('@/context/i18n', () => ({ +vi.mock('#i18n', async (importOriginal) => ({ + ...(await importOriginal()), useLocale: () => 'en-US', })) diff --git a/web/app/(shareLayout)/webapp-signin/components/__tests__/mail-and-password-auth.spec.tsx b/web/app/(shareLayout)/webapp-signin/components/__tests__/mail-and-password-auth.spec.tsx index 4c39b09f9f3..7bf924ba467 100644 --- a/web/app/(shareLayout)/webapp-signin/components/__tests__/mail-and-password-auth.spec.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/__tests__/mail-and-password-auth.spec.tsx @@ -14,7 +14,8 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: () => searchParams, })) -vi.mock('@/context/i18n', () => ({ +vi.mock('#i18n', async (importOriginal) => ({ + ...(await importOriginal()), useLocale: () => 'en-US', })) diff --git a/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx index 49b2d932f0f..b72d3fb3fcb 100644 --- a/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx @@ -6,7 +6,7 @@ import * as React from 'react' import { useCallback, useEffect } from 'react' import { resolveWebAppLoginRedirect } from '@/app/(shareLayout)/webapp-signin/login-redirect' import AppUnavailable from '@/app/components/base/app-unavailable' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useRouter, useSearchParams } from '@/next/navigation' import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share' @@ -69,7 +69,7 @@ const ExternalMemberSSOAuth = () => { return (
- +
) } diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx index 3906b57fe63..bb67ca8e891 100644 --- a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx @@ -3,10 +3,10 @@ import { Input } from '@langgenius/dify-ui/input' import { toast } from '@langgenius/dify-ui/toast' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useLocale } from '#i18n' import { resolveWebAppLoginRedirect } from '@/app/(shareLayout)/webapp-signin/login-redirect' import { COUNT_DOWN_TIME_MS, useSetCountdownLeftTime } from '@/app/components/signin/storage' import { emailRegex } from '@/config' -import { useLocale } from '@/context/i18n' import { useRouter, useSearchParams } from '@/next/navigation' import { sendWebAppEMailLoginCode } from '@/service/common' import { getClientLoginFallback } from '@/utils/login-redirect' diff --git a/web/app/(shareLayout)/webapp-signin/normalForm.tsx b/web/app/(shareLayout)/webapp-signin/normalForm.tsx index bfa8906f023..a6c2cd83e8c 100644 --- a/web/app/(shareLayout)/webapp-signin/normalForm.tsx +++ b/web/app/(shareLayout)/webapp-signin/normalForm.tsx @@ -6,7 +6,7 @@ import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import Link from '@/next/link' import MailAndCodeAuth from './components/mail-and-code-auth' @@ -55,7 +55,7 @@ const NormalForm = () => {
- +
) } diff --git a/web/app/(shareLayout)/webapp-signin/page.tsx b/web/app/(shareLayout)/webapp-signin/page.tsx index e0a3dc00994..44868ffa39e 100644 --- a/web/app/(shareLayout)/webapp-signin/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/page.tsx @@ -4,7 +4,7 @@ import * as React from 'react' import { useCallback, useEffect, useSyncExternalStore } from 'react' import { useTranslation } from 'react-i18next' import AppUnavailable from '@/app/components/base/app-unavailable' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { useWebAppStore } from '@/context/web-app-context' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { AccessMode } from '@/models/access-control' @@ -54,7 +54,7 @@ function WebSSOForm() { if (!loginRedirect) { return (
- +
) } diff --git a/web/app/__tests__/home-banner.browser.spec.tsx b/web/app/__tests__/home-banner.browser.spec.tsx index 9a3595dba19..c6d4f913e70 100644 --- a/web/app/__tests__/home-banner.browser.spec.tsx +++ b/web/app/__tests__/home-banner.browser.spec.tsx @@ -17,7 +17,8 @@ vi.mock('@/features/account-profile/client', () => ({ userProfileQueryOptions: () => ({}), })) -vi.mock('@/context/i18n', () => ({ +vi.mock('#i18n', async (importOriginal) => ({ + ...(await importOriginal()), useLocale: () => 'en-US', })) diff --git a/web/app/__tests__/layout.spec.tsx b/web/app/__tests__/layout.spec.tsx index cee0bd121ed..9f93408da43 100644 --- a/web/app/__tests__/layout.spec.tsx +++ b/web/app/__tests__/layout.spec.tsx @@ -20,7 +20,7 @@ vi.mock('@/env', async (importOriginal) => { } }) -vi.mock('@/i18n-config/server', () => ({ +vi.mock('@/i18n/server', () => ({ getLocaleOnServer: async () => 'en-US', })) diff --git a/web/app/__tests__/route-metadata.spec.ts b/web/app/__tests__/route-metadata.spec.ts index b9308ee84b8..a85569332f0 100644 --- a/web/app/__tests__/route-metadata.spec.ts +++ b/web/app/__tests__/route-metadata.spec.ts @@ -18,8 +18,8 @@ import { generateMetadata as generateSignupSetPasswordMetadata } from '../signup vi.mock('server-only', () => ({})) -vi.mock('@/i18n-config/server', async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock('@/i18n/server', async (importOriginal) => ({ + ...(await importOriginal()), getLocaleOnServer: async () => 'en-US', })) diff --git a/web/app/account/(commonLayout)/layout.tsx b/web/app/account/(commonLayout)/layout.tsx index 06ec2ae4e5d..0e323e98798 100644 --- a/web/app/account/(commonLayout)/layout.tsx +++ b/web/app/account/(commonLayout)/layout.tsx @@ -1,7 +1,6 @@ import * as React from 'react' import { ConsoleContextProviders, ConsoleRuntimeProviders } from '@/app/(commonLayout)/providers' import HeaderWrapper from '@/app/components/header/header-wrapper' -import MaintenanceNotice from '@/app/components/header/maintenance-notice' import Header from './header' export default async function Layout({ children }: { children: React.ReactNode }) { @@ -9,7 +8,6 @@ export default async function Layout({ children }: { children: React.ReactNode }
-
diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index bbdf7f76188..410aff077c2 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -14,7 +14,7 @@ import { import { skipToken, useMutation, useQuery } from '@tanstack/react-query' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client' import useDocumentTitle from '@/hooks/use-document-title' @@ -161,7 +161,7 @@ export default function OAuthAuthorize() { if (isProfileLoading || isOAuthLoading || isAutoAuthorizing) { return (
- +
) } diff --git a/web/app/activate/activateForm.tsx b/web/app/activate/activateForm.tsx index 188bc2c5a8a..f60dcf727c9 100644 --- a/web/app/activate/activateForm.tsx +++ b/web/app/activate/activateForm.tsx @@ -3,7 +3,7 @@ import { buttonVariants } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import useDocumentTitle from '@/hooks/use-document-title' import { useRouter, useSearchParams } from '@/next/navigation' import { useInvitationCheck } from '@/service/use-common' @@ -52,7 +52,7 @@ const ActivateForm = () => {
- {!checkRes && } + {!checkRes && } {checkRes && !checkRes.is_valid && (
diff --git a/web/app/components/access-rules-editor/__tests__/index.spec.tsx b/web/app/components/access-rules-editor/__tests__/index.spec.tsx index 4a6b498ece5..eed342ae46d 100644 --- a/web/app/components/access-rules-editor/__tests__/index.spec.tsx +++ b/web/app/components/access-rules-editor/__tests__/index.spec.tsx @@ -109,7 +109,7 @@ describe('AccessRulesEditor', () => { />, ) - expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument() + expect(screen.getByRole('progressbar', { name: 'appApi.loading' })).toBeInTheDocument() expect(screen.queryByText('permission.accessRule.noUserAccessSettings')).not.toBeInTheDocument() }) diff --git a/web/app/components/access-rules-editor/add-access-subject-popover.tsx b/web/app/components/access-rules-editor/add-access-subject-popover.tsx index 3175f1fd500..57a541a9bec 100644 --- a/web/app/components/access-rules-editor/add-access-subject-popover.tsx +++ b/web/app/components/access-rules-editor/add-access-subject-popover.tsx @@ -8,7 +8,7 @@ import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgeni import { useQuery } from '@tanstack/react-query' import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { consoleQuery } from '@/service/console' import { DEFAULT_ACCESS_POLICY_ID } from './constants' @@ -93,9 +93,7 @@ function AddAccessSubjectPopoverContent({
) : isLoading ? ( -
- -
+ ) : availableMembers.length === 0 ? (
{t(($) => $['accessRule.noAvailableMembers'], { ns: 'permission' })} diff --git a/web/app/components/access-rules-editor/index.tsx b/web/app/components/access-rules-editor/index.tsx index 1810c04da43..d37efe99048 100644 --- a/web/app/components/access-rules-editor/index.tsx +++ b/web/app/components/access-rules-editor/index.tsx @@ -10,7 +10,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { Pagination } from '@langgenius/dify-ui/pagination' import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { RESOURCE_ACCESS_SETTINGS_PAGE_SIZE_OPTIONS } from '@/service/access-control/constants' import AddAccessSubjectPopover from './add-access-subject-popover' import AutomaticIncludeWorkspaceMembersSection from './automatic-include-workspace-members-section' @@ -303,7 +303,7 @@ function AccessRulesEditor({ colSpan={3} className="flex flex-1 items-center justify-center px-4 py-8 text-center" > - + ) : userAccessSettings.length === 0 ? ( diff --git a/web/app/components/api-key/api-key-modal.tsx b/web/app/components/api-key/api-key-modal.tsx index ec3e566f9e9..fc826d818dd 100644 --- a/web/app/components/api-key/api-key-modal.tsx +++ b/web/app/components/api-key/api-key-modal.tsx @@ -23,7 +23,7 @@ import { skipToken, useMutation, useQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { currentWorkspaceAtom } from '@/context/workspace-state' import { consoleQuery } from '@/service/console' import { ApiKeyTable } from './api-key-table' @@ -216,7 +216,7 @@ export function ApiKeyModal({ open, canManage, scope, onOpenChange }: ApiKeyModa /> {isLoading && (
- +
)} {!!apiKeys?.length && ( diff --git a/web/app/components/app/access-config/index.tsx b/web/app/components/app/access-config/index.tsx index 79acb7e7b3c..570b7d850f2 100644 --- a/web/app/components/app/access-config/index.tsx +++ b/web/app/components/app/access-config/index.tsx @@ -5,13 +5,13 @@ import { useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useLocale } from '#i18n' import AccessRulesEditor from '@/app/components/access-rules-editor' import { useStore } from '@/app/components/app/store' -import { useLocale } from '@/context/i18n' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client' -import { getAccessControlTemplateLanguage } from '@/i18n-config/language' +import { getAccessControlTemplateLanguage } from '@/i18n/language' import { RESOURCE_ACCESS_SETTINGS_PAGE_SIZE } from '@/service/access-control/constants' import { useAppAccessRules, diff --git a/web/app/components/app/access-point/built-in-access-points/index.tsx b/web/app/components/app/access-point/built-in-access-points/index.tsx index 1713f41cdbd..cc48a5af3f0 100644 --- a/web/app/components/app/access-point/built-in-access-points/index.tsx +++ b/web/app/components/app/access-point/built-in-access-points/index.tsx @@ -6,7 +6,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { useSuspenseQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { useDocLink } from '@/context/i18n' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import Link from '@/next/link' @@ -51,7 +51,7 @@ export function BuiltInAccessPoints({ }) const actions = useAccessPointActions(appId, canManageAccessPoint) - if (!appInfo) return + if (!appInfo) return const workflowState = getPublishedWorkflowState(appInfo, workflow) const builtInLoading = workflowState.isWorkflowApp && workflowLoading diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-downloader.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-downloader.spec.tsx index d43e14c0fe1..9e77cfc47b2 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-downloader.spec.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-downloader.spec.tsx @@ -1,12 +1,13 @@ import type { Mock } from 'vite-plus/test' -import type { Locale } from '@/i18n-config' +import type { Locale } from '@/i18n' import { render, screen } from '@testing-library/react' import * as React from 'react' -import { useLocale } from '@/context/i18n' -import { LanguagesSupported } from '@/i18n-config/language' +import { useLocale } from '#i18n' +import { LanguagesSupported } from '@/i18n/language' import CSVDownload from '../csv-downloader' -vi.mock('@/context/i18n', () => ({ +vi.mock('#i18n', async (importOriginal) => ({ + ...(await importOriginal()), useLocale: vi.fn(() => 'en-US'), })) diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.tsx index 69fa82bbbdb..6648a6472f7 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.tsx @@ -3,9 +3,9 @@ import type { FC } from 'react' import * as React from 'react' import { useTranslation } from 'react-i18next' import { useCSVDownloader } from 'react-papaparse' +import { useLocale } from '#i18n' import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general' -import { useLocale } from '@/context/i18n' -import { LanguagesSupported } from '@/i18n-config/language' +import { LanguagesSupported } from '@/i18n/language' const CSV_TEMPLATE_QA_EN = [ ['question', 'answer'], diff --git a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx index 374affd754e..95a731cd7d0 100644 --- a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx @@ -1,11 +1,11 @@ import type { ComponentProps } from 'react' import type { Mock } from 'vite-plus/test' import type { AnnotationItemBasic } from '../../type' -import type { Locale } from '@/i18n-config' +import type { Locale } from '@/i18n' import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useLocale } from '@/context/i18n' -import { LanguagesSupported } from '@/i18n-config/language' +import { useLocale } from '#i18n' +import { LanguagesSupported } from '@/i18n/language' import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation' import { renderWithConsoleQuery as render } from '@/test/console/query-data' import HeaderOptions from '../index' @@ -30,7 +30,8 @@ vi.mock('@/app/components/billing/annotation-full', () => ({ default: () =>
, })) -vi.mock('@/context/i18n', () => ({ +vi.mock('#i18n', async (importOriginal) => ({ + ...(await importOriginal()), useLocale: vi.fn(() => LanguagesSupported[0]), })) diff --git a/web/app/components/app/annotation/header-opts/index.tsx b/web/app/components/app/annotation/header-opts/index.tsx index c4de0662292..d725b92c4c7 100644 --- a/web/app/components/app/annotation/header-opts/index.tsx +++ b/web/app/components/app/annotation/header-opts/index.tsx @@ -15,8 +15,8 @@ import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { jsonToCSV } from 'react-papaparse' -import { useLocale } from '@/context/i18n' -import { LanguagesSupported } from '@/i18n-config/language' +import { useLocale } from '#i18n' +import { LanguagesSupported } from '@/i18n/language' import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation' import { downloadBlob } from '@/utils/download' import AddAnnotationModal from '../add-annotation-modal' diff --git a/web/app/components/app/annotation/index.tsx b/web/app/components/app/annotation/index.tsx index 6a3297f7b2a..1d41e9cec7e 100644 --- a/web/app/components/app/annotation/index.tsx +++ b/web/app/components/app/annotation/index.tsx @@ -18,7 +18,7 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import ConfigParamModal from '@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal' import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import AnnotationFullModal from '@/app/components/billing/annotation-full/modal' import { APP_PAGE_LIMIT } from '@/config' import { useDocLink } from '@/context/i18n' @@ -248,7 +248,7 @@ const Annotation: FC = (props) => {
{isLoading ? ( - + ) : total > 0 ? ( { ) }) + it('keeps member-search loading text in its existing live region', async () => { + const query = { + isLoading: true, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + data: { pages: [] }, + } + mockUseSearchForWhiteListCandidates.mockReturnValue(query) + const user = userEvent.setup() + const view = render() + await user.click(screen.getByText('common.operation.add')) + const status = screen.getByRole('status') + expect(status).toHaveTextContent('common.loading') + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + + mockUseSearchForWhiteListCandidates.mockReturnValue({ ...query, isLoading: false }) + view.rerender() + expect(screen.getByRole('status')).toBe(status) + expect(status).toHaveTextContent('app.accessControlDialog.operateGroupAndMember.noResult') + }) + it('should show the empty state when no candidates are returned', async () => { mockUseSearchForWhiteListCandidates.mockReturnValue({ isLoading: false, diff --git a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx index 3f15ca67169..f3a8889e552 100644 --- a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx @@ -1,5 +1,5 @@ import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control' -import { render, screen } from '@testing-library/react' +import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { AccessMode } from '@/models/access-control' import SpecificGroupsOrMembers from '../specific-groups-or-members' @@ -67,7 +67,7 @@ describe('SpecificGroupsOrMembers', () => { />, ) - expect(container.querySelector('.spin-animation')).toBeInTheDocument() + expect(within(container).queryByRole('progressbar')).toBeInTheDocument() expect(mockAddMemberOrGroupDialog).not.toHaveBeenCalled() }) diff --git a/web/app/components/app/app-access-control/add-member-or-group-pop/index.tsx b/web/app/components/app/app-access-control/add-member-or-group-pop/index.tsx index ebd3b76327a..e80cc9ce1d5 100644 --- a/web/app/components/app/app-access-control/add-member-or-group-pop/index.tsx +++ b/web/app/components/app/app-access-control/add-member-or-group-pop/index.tsx @@ -16,10 +16,10 @@ import { ScrollAreaThumb, ScrollAreaViewport, } from '@langgenius/dify-ui/scroll-area' +import { SpinnerIcon } from '@langgenius/dify-ui/spinner' import { useDebounce } from 'ahooks' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import Loading from '@/app/components/base/loading' import { SearchInput } from '@/app/components/base/search-input' import { SubjectType } from '@/models/access-control' import { useSearchForWhiteListCandidates } from '@/service/access-control' @@ -188,14 +188,24 @@ export default function AddMemberOrGroupDialog({ } > {isLoading ? ( - + <> + + {t(($) => $.loading, { ns: 'common' })} + ) : ( - statusText + <> + {statusText} + {isFetchingNextPage && ( + {t(($) => $.loading, { ns: 'common' })} + )} + )}
- {isFetchingNextPage && } + {isFetchingNextPage && ( +
+ +
+ )}
diff --git a/web/app/components/app/app-access-control/specific-groups-or-members.tsx b/web/app/components/app/app-access-control/specific-groups-or-members.tsx index 8259a83d2f0..cb8a70aaf74 100644 --- a/web/app/components/app/app-access-control/specific-groups-or-members.tsx +++ b/web/app/components/app/app-access-control/specific-groups-or-members.tsx @@ -4,9 +4,9 @@ import { Avatar } from '@langgenius/dify-ui/avatar' import { Button } from '@langgenius/dify-ui/button' import { IconButton } from '@langgenius/dify-ui/icon-button' import { useTranslation } from 'react-i18next' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { AccessMode } from '@/models/access-control' import { Infotip } from '../../base/infotip' -import Loading from '../../base/loading' import AddMemberOrGroupDialog from './add-member-or-group-pop' export type AccessControlSubjects = { @@ -63,7 +63,7 @@ export default function SpecificGroupsOrMembers({
- {subjectsStatus === 'loading' && } + {subjectsStatus === 'loading' && } {subjectsStatus === 'error' && (

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 96642b8c1fe..56ba2f05db8 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 @@ -489,7 +489,7 @@ describe('PublisherEnvironmentFlow', () => { ) expect(screen.getByText('Environment tabs')).toBeInTheDocument() - expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument() + expect(screen.getByRole('progressbar', { name: 'appApi.loading' })).toBeInTheDocument() }) it('formats deployed_at as a Unix timestamp in seconds', () => { diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index 8b1ec64b24a..3e9bbb4781f 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -632,7 +632,7 @@ describe('AppPublisher', () => { expect(environmentDeploymentListRequests.length).toBeGreaterThan(0) }) expect(environmentDeploymentDetailRequests).toHaveLength(0) - expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.getByRole('progressbar', { name: 'appApi.loading' })).toBeInTheDocument() expect(screen.queryByText(/studio\.accessPoint\.noPublishedTitle/)).not.toBeInTheDocument() expect(screen.queryByText(/studio\.publisher\.notDeployedYet/)).not.toBeInTheDocument() 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 4916eb37320..9a0328e297a 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 @@ -6,7 +6,7 @@ import type { DeploymentVersion } from '@/app/components/app/deploy/utils/versio import { useAtomValue, useSetAtom } from 'jotai' import { useTranslation } from 'react-i18next' import { EnvironmentDeploymentFlow } from '@/app/components/app/deploy/shared/environment-deployment-flow' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { publisherEnvironmentDeploymentPollingAtom, startPublisherEnvironmentDeploymentPollingAtom, @@ -52,7 +52,7 @@ export function PublisherEnvironmentFlow({

{environmentTabs} {isDeploymentLoading ? ( - + ) : (
{ it('should render a loading state before configuration data is ready', () => { render() - expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument() + expect(screen.getByRole('progressbar', { name: 'appApi.loading' })).toBeInTheDocument() expect(screen.queryByTestId('app-publisher')).not.toBeInTheDocument() }) diff --git a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx index 1d5cd5c7dde..7538e1b68a6 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx @@ -17,7 +17,8 @@ import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import Loading from '@/app/components/base/loading' +import { useLocale } from '#i18n' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import TabSlider from '@/app/components/base/tab-slider-plain' import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form' import Icon from '@/app/components/plugins/card/base/card-icon' @@ -27,8 +28,7 @@ import { AuthCategory, PluginAuthInAgent } from '@/app/components/plugins/plugin import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' import { CollectionType } from '@/app/components/tools/types' import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' -import { useLocale } from '@/context/i18n' -import { getLanguage } from '@/i18n-config/language' +import { getPluginLanguage } from '@/i18n/metadata' import { fetchBuiltInToolList, fetchCustomToolList, @@ -66,7 +66,7 @@ const SettingBuiltInTool: FC = ({ onAuthorizationItemClick, }) => { const locale = useLocale() - const language = getLanguage(locale) + const language = getPluginLanguage(locale) const { t } = useTranslation() const passedTools = (collection as ToolWithProvider).tools const hasPassedTools = passedTools?.length > 0 @@ -183,7 +183,7 @@ const SettingBuiltInTool: FC = ({ )} > - {isLoading && } + {isLoading && } {!isLoading && ( <> {/* header */} diff --git a/web/app/components/app/configuration/config/automatic/__tests__/generator-reflow.browser.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/generator-reflow.browser.spec.tsx index 9227956b34b..24eaca6b69d 100644 --- a/web/app/components/app/configuration/config/automatic/__tests__/generator-reflow.browser.spec.tsx +++ b/web/app/components/app/configuration/config/automatic/__tests__/generator-reflow.browser.spec.tsx @@ -6,8 +6,8 @@ import GetAutomaticRes from '../get-automatic-res' vi.mock('react-i18next', async () => { const { createReactI18nextMock } = await import('@/test/i18n-mock') - const { default: appDebug } = await import('@/i18n/en-US/app-debug.json') - const { default: common } = await import('@/i18n/en-US/common.json') + const { default: appDebug } = await import('@/i18n/locales/en-US/app-debug.json') + const { default: common } = await import('@/i18n/locales/en-US/common.json') return createReactI18nextMock({ ...appDebug, ...common }) }) diff --git a/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx b/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx index 901733e32e0..2f2eb685346 100644 --- a/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx +++ b/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx @@ -21,7 +21,7 @@ import * as React from 'react' import { useCallback, useId, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/components/app/configuration/toast' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' @@ -190,7 +190,7 @@ const GetAutomaticRes: FC = ({ const renderLoading = (
- +
{t(($) => $['generate.loading'], { ns: 'appDebug' })}
diff --git a/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx b/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx index ce0a57b76d9..06d905d85fd 100644 --- a/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx +++ b/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx @@ -20,7 +20,7 @@ import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/components/app/configuration/toast' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' @@ -189,7 +189,7 @@ export const GetCodeGeneratorResModal: FC = ({ const renderLoading = (
- +
{t(($) => $['codegen.loading'], { ns: 'appDebug' })}
diff --git a/web/app/components/app/configuration/configuration-view/index.tsx b/web/app/components/app/configuration/configuration-view/index.tsx index c94e9a01181..5ffa311e8c2 100644 --- a/web/app/components/app/configuration/configuration-view/index.tsx +++ b/web/app/components/app/configuration/configuration-view/index.tsx @@ -34,7 +34,7 @@ import Debug from '@/app/components/app/configuration/debug' import Divider from '@/app/components/base/divider' import { FeaturesProvider } from '@/app/components/base/features' import NewFeaturePanel from '@/app/components/base/features/new-feature-panel' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' import PluginDependency from '@/app/components/workflow/plugin-dependency' import ConfigContext from '@/context/debug-configuration' @@ -101,7 +101,7 @@ const ConfigurationView: FC = ({ if (showLoading) { return (
- +
) } diff --git a/web/app/components/app/configuration/dataset-config/card-item/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/card-item/__tests__/index.spec.tsx index acb21be3f6a..48424a0322c 100644 --- a/web/app/components/app/configuration/dataset-config/card-item/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/card-item/__tests__/index.spec.tsx @@ -218,7 +218,7 @@ describe('dataset-config/card-item', () => { const iconElement = nameElement.parentElement?.firstElementChild as HTMLElement expect(iconElement)!.toHaveStyle({ background: '#FFF4ED' }) - expect(iconElement.querySelector('em-emoji'))!.toHaveAttribute('id', '📙') + expect(iconElement).toHaveTextContent('📙') }) it('should apply mask overlay on mobile when drawer is open', async () => { diff --git a/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx index e0278e5dd19..2fcc3bf14fe 100644 --- a/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx @@ -8,16 +8,6 @@ import { render } from '@/test/console/render' import { RETRIEVE_METHOD } from '@/types/app' import SelectDataSet from '../index' -vi.mock('@/i18n-config/i18next-config', () => ({ - default: { - changeLanguage: vi.fn(), - addResourceBundle: vi.fn(), - use: vi.fn().mockReturnThis(), - init: vi.fn(), - addResource: vi.fn(), - hasResourceBundle: vi.fn().mockReturnValue(true), - }, -})) const mockUseInfiniteScroll = vi.fn() vi.mock('ahooks', async (importOriginal) => { const actual = await importOriginal() diff --git a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx index 1e9b0037371..48e7a67e705 100644 --- a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx +++ b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx @@ -12,7 +12,7 @@ import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import Badge from '@/app/components/base/badge' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import FeatureIcon from '@/app/components/header/account-setting/model-provider-page/model-selector/feature-icon' import { workspacePermissionKeysAtom } from '@/context/permission-state' @@ -118,7 +118,7 @@ const SelectDataSet: FC = ({ /> {isLoading && datasets.length === 0 && (
- +
)} @@ -201,7 +201,7 @@ const SelectDataSet: FC = ({ )} ))} - {isFetchingNextPage && } + {isFetchingNextPage && }
)} diff --git a/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal-utils.spec.ts b/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal-utils.spec.ts index e832efacf6e..b020d8cadcb 100644 --- a/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal-utils.spec.ts +++ b/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal-utils.spec.ts @@ -1,6 +1,6 @@ -import type { I18nText } from '@/i18n-config/language' +import type { I18nText } from '@/i18n/language' import type { CodeBasedExtensionItem } from '@/models/common' -import { LanguagesSupported } from '@/i18n-config/language' +import { LanguagesSupported } from '@/i18n/language' import { withSelectorKey } from '@/test/i18n-mock' import { buildProviders, diff --git a/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx b/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx index 4b5ca839428..099c7305771 100644 --- a/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx +++ b/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { mockEmojiData } from '@/test/emoji-picker' import ExternalDataToolModal from '../external-data-tool-modal' const mockToastError = vi.fn() @@ -12,6 +13,10 @@ vi.mock('@/app/components/app/configuration/toast', () => ({ vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => `https://docs.example.com${path}`, +})) + +vi.mock('#i18n', async (importOriginal) => ({ + ...(await importOriginal()), useLocale: () => mockLocale, })) @@ -131,9 +136,9 @@ describe('ExternalDataToolModal', () => { fireEvent.click(screen.getByText('pick-extension')) fireEvent.click(screen.getByText('open-emoji-picker')) await waitFor(() => { - expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument() + expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument() }) - const emojiButton = document.querySelector('em-emoji')?.closest('button') + const emojiButton = await screen.findByRole('gridcell', { name: 'Grinning face' }) expect(emojiButton).toBeTruthy() fireEvent.click(emojiButton!) fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' })) @@ -228,3 +233,5 @@ describe('ExternalDataToolModal', () => { expect(mockOnCancel).toHaveBeenCalled() }) }) + +mockEmojiData() diff --git a/web/app/components/app/configuration/tools/external-data-tool-modal-utils.ts b/web/app/components/app/configuration/tools/external-data-tool-modal-utils.ts index 27557168d13..165554ac3bd 100644 --- a/web/app/components/app/configuration/tools/external-data-tool-modal-utils.ts +++ b/web/app/components/app/configuration/tools/external-data-tool-modal-utils.ts @@ -1,7 +1,7 @@ import type { SelectorTranslate } from '@/app/components/app/configuration/utils' import type { CodeBasedExtensionItem, ExternalDataTool } from '@/models/common' import { getStringSelectorTranslate } from '@/app/components/app/configuration/utils' -import { LanguagesSupported } from '@/i18n-config/language' +import { LanguagesSupported } from '@/i18n/language' const systemTypes = ['api'] as const diff --git a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx index 3cbff54a4f4..86b64daadb0 100644 --- a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx +++ b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx @@ -14,13 +14,14 @@ import { import { noop } from 'es-toolkit/function' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { useLocale } from '#i18n' import { toast } from '@/app/components/app/configuration/toast' import AppIcon from '@/app/components/base/app-icon' import EmojiPicker from '@/app/components/base/emoji-picker' import FormGeneration from '@/app/components/base/features/new-feature-panel/moderation/form-generation' import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education' import { ApiBasedExtensionSelector } from '@/app/components/header/account-setting/api-based-extension-page/selector' -import { useDocLink, useLocale } from '@/context/i18n' +import { useDocLink } from '@/context/i18n' import { useCodeBasedExtensions } from '@/service/use-common' import { buildProviders, diff --git a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx index 4c36c5d6990..773b5fad799 100644 --- a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx @@ -415,7 +415,7 @@ describe('Apps', () => { render() - expect(screen.getByRole('status'))!.toBeInTheDocument() + expect(screen.getByRole('progressbar'))!.toBeInTheDocument() }) it('should handle an undefined template payload by falling back to the empty state', () => { diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index 691a97eac43..a3915247047 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -14,7 +14,7 @@ import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import AppTypeSelector from '@/app/components/app/type-selector' import Divider from '@/app/components/base/divider' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import CreateAppModal from '@/app/components/explore/create-app-modal' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { workspacePermissionKeysAtom } from '@/context/permission-state' @@ -171,7 +171,7 @@ const Apps = ({ onClose, onCreateFromBlank }: AppsProps) => { if (isLoading) { return (
- +
) } diff --git a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx index 3bbe0d7c8a1..68af8670fe4 100644 --- a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx @@ -4,6 +4,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { useRouter } from '@/next/navigation' import { renderWithConsoleQuery } from '@/test/console/query-data' +import { mockEmojiData } from '@/test/emoji-picker' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' import { trackCreateApp } from '@/utils/create-app-tracking' @@ -265,12 +266,12 @@ describe('CreateAppModal', () => { fireEvent.click(screen.getByText('app.types.chatbot')) fireEvent.click(screen.getByText('open-icon-picker')) await waitFor(() => { - expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument() + expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument() }) fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' })) fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ })) await waitFor(() => { - expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument() + expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument() }) fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), { target: { value: 'Keyboard App' }, @@ -309,14 +310,14 @@ describe('CreateAppModal', () => { fireEvent.click(screen.getByText('open-icon-picker')) await waitFor(() => { - expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument() + expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument() }) fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ })) await waitFor(() => { - expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument() + expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument() }) - expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument() + expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument() ahooksMocks.keyPressHandlers.at(-1)?.() @@ -379,3 +380,5 @@ describe('CreateAppModal', () => { }) }) }) + +mockEmojiData() diff --git a/web/app/components/app/deploy/__tests__/index.spec.tsx b/web/app/components/app/deploy/__tests__/index.spec.tsx index 956e7dc3592..4df3f221db9 100644 --- a/web/app/components/app/deploy/__tests__/index.spec.tsx +++ b/web/app/components/app/deploy/__tests__/index.spec.tsx @@ -771,7 +771,6 @@ vi.mock('react-i18next', async () => { 'The app will stop running in this environment, and all of its access points will become unavailable.', 'deployments.studio.undeployConfirmTitle': 'Undeploy {{versionName}} from {{envName}}', 'deployments.status.RUNTIME_INSTANCE_STATUS_DEPLOYING': 'Deploying', - 'deployments.status.RUNTIME_INSTANCE_STATUS_FAILED': 'Deploy failed', 'deployments.status.RUNTIME_INSTANCE_STATUS_INVALID': 'Invalid', 'deployments.status.RUNTIME_INSTANCE_STATUS_READY': 'Running', 'deployments.status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED': 'Not deployed', @@ -846,6 +845,10 @@ vi.mock('@/context/permission-state', async () => { vi.mock('@/context/i18n', () => ({ getEnterpriseDocUrl: mockGetEnterpriseDocUrl, +})) + +vi.mock('#i18n', async (importOriginal) => ({ + ...(await importOriginal()), useLocale: () => 'en-US', })) @@ -1109,7 +1112,7 @@ describe('AppDeploy', () => { render() - expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument() + expect(screen.getByRole('progressbar', { name: 'appApi.loading' })).toBeInTheDocument() expect( screen.queryByRole('heading', { name: 'common.appMenus.deploy' }), ).not.toBeInTheDocument() @@ -2120,7 +2123,7 @@ describe('AppDeploy', () => { { queryClient }, ) - expect(screen.getByRole('status', { name: /loading/ })).toBeInTheDocument() + expect(screen.getByRole('progressbar', { name: /loading/ })).toBeInTheDocument() expect(screen.queryByRole('table')).not.toBeInTheDocument() await waitFor(() => { expect(deploymentRequests).toHaveLength(1) diff --git a/web/app/components/app/deploy/environment-table/deploy-menu/index.tsx b/web/app/components/app/deploy/environment-table/deploy-menu/index.tsx index 7c9c17e6cf9..74af3c9af76 100644 --- a/web/app/components/app/deploy/environment-table/deploy-menu/index.tsx +++ b/web/app/components/app/deploy/environment-table/deploy-menu/index.tsx @@ -12,7 +12,7 @@ import { } from '@langgenius/dify-ui/dropdown-menu' import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { appEnvironmentsIsErrorAtom, appEnvironmentsIsLoadingAtom, @@ -62,7 +62,7 @@ export function EnvironmentDeployMenu({ {t(($) => $['card.notDeployed'])} {isLoading ? ( - + ) : isError ? (

diff --git a/web/app/components/app/deploy/environment-table/index.tsx b/web/app/components/app/deploy/environment-table/index.tsx index 28230e6b857..79a12a105dd 100644 --- a/web/app/components/app/deploy/environment-table/index.tsx +++ b/web/app/components/app/deploy/environment-table/index.tsx @@ -11,7 +11,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { useAtomValue } from 'jotai' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { appEnvironmentDeploymentsAtom, appEnvironmentDeploymentsIsErrorAtom, @@ -167,7 +167,7 @@ export const EnvironmentTable = memo( )} > {showLoadingState ? ( - + ) : showErrorState ? ( + if (!appDetail) return const { canDeploy, canViewAccessPoint } = getAppACLCapabilities(appDetail.permission_keys, { currentUserId, diff --git a/web/app/components/app/deploy/shared/deployment-configuration/content.tsx b/web/app/components/app/deploy/shared/deployment-configuration/content.tsx index 1a725c9ce15..adc3d5cf10f 100644 --- a/web/app/components/app/deploy/shared/deployment-configuration/content.tsx +++ b/web/app/components/app/deploy/shared/deployment-configuration/content.tsx @@ -6,7 +6,7 @@ import type { DeploymentConfigurationQueryState } from './use-deployment-configu import type { DeploymentConfigurationValuesController } from './use-deployment-configuration-values' import { cn } from '@langgenius/dify-ui/cn' import { useTranslation } from 'react-i18next' -import Loading from '@/app/components/base/loading' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { CredentialsSection } from './credentials-section' import { DeploymentPrecheckAlert } from './deployment-precheck-alert' import { EnvironmentVariablesSection } from './environment-variables-section' @@ -99,7 +99,7 @@ export function DeploymentConfigurationContent({ aria-busy={isPrechecking || isLoadingDeploymentOptions} className="min-h-0 flex-1 overflow-y-auto" > - {isPrechecking && } + {isPrechecking && } {!isPrechecking && precheckError && (

)} - {isLoadingDeploymentOptions && } + {isLoadingDeploymentOptions && } {!isLoadingDeploymentOptions && deploymentOptionsError && (
- {isLoading && } + {isLoading && } {!isLoading && versionsError && versions.length === 0 && (

@@ -203,7 +203,7 @@ export function VersionList({ )}

)} - {isFetchingNextPage && versions.length > 0 && } + {isFetchingNextPage && versions.length > 0 && }
diff --git a/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx b/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx index 1f5adc98622..f36ee285ac1 100644 --- a/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx @@ -41,7 +41,7 @@ vi.mock('@/app/components/base/app-icon-picker', () => ({
, +})) + +it('keeps desktop keyboard focus visible across sidebar states and conversation actions', async () => { + // Real Chromium is needed to prove native inert Tab skipping, focus restoration + // during collapse, and focus-visible opacity on a non-hovered conversation row. + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const screen = await render( + +
+ + +
+
, + ) + const before = screen.getByRole('button', { name: 'Before chat' }) + const collapse = screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }) + const chat = screen.getByRole('button', { name: 'Chat input boundary' }) + await before.click() + await userEvent.tab() + await expect.element(collapse).toHaveFocus() + await userEvent.tab() + await expect.element(screen.getByRole('button', { name: 'share.chat.newChat' })).toHaveFocus() + await userEvent.tab() + await expect.element(screen.getByRole('button', { name: 'Saved conversation' })).toHaveFocus() + await userEvent.tab() + const more = screen.getByRole('button', { name: 'common.operation.more' }).first() + await expect.element(more).toHaveFocus() + expect(getComputedStyle(more.element()).opacity).toBe('1') + await userEvent.keyboard('{Enter}') + await expect + .element(screen.getByRole('menuitem', { name: 'explore.sidebar.action.pin' })) + .toBeVisible() + await userEvent.keyboard('{Escape}') + await userEvent.tab() + await userEvent.tab() + await expect.element(chat).toHaveFocus() + + // Collapsing must put focus on the visible replacement toggle, and the next + // native Tab must skip both hidden copies of the sidebar's actions. + await collapse.click() + const expand = screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' }).last() + await expect.element(expand).toHaveFocus() + await userEvent.tab() + await expect.element(chat).toHaveFocus() + await userEvent.tab({ shift: true }) + await expect.element(expand).toHaveFocus() + await userEvent.keyboard('{Enter}') + await expect.element(collapse).toHaveFocus() + expect(collapse.element().checkVisibility({ checkOpacity: true })).toBe(true) +}) + +it('keeps the floating sidebar available through its portalled menu and closes after focus leaves', async () => { + // Pointer leave and Escape cross the real portal boundary; focus must return + // to a visible, interactive trigger instead of a newly inert offscreen panel. + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const screen = await render( + +
+ + +
+
, + ) + await screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }).click() + await screen.getByRole('region', { name: 'Chat preview' }).hover({ position: { x: 2, y: 150 } }) + const conversation = screen.getByRole('button', { name: 'Saved conversation' }).last() + await expect.element(conversation).toBeVisible() + await conversation.click() + await userEvent.tab() + const more = screen.getByRole('button', { name: 'common.operation.more' }).nth(2) + await expect.element(more).toHaveFocus() + await userEvent.keyboard('{Enter}') + await expect + .element(screen.getByRole('menuitem', { name: 'explore.sidebar.action.pin' })) + .toBeVisible() + await screen.getByRole('button', { name: 'Outside sidebar' }).hover() + await userEvent.keyboard('{Escape}') + await expect.element(more).toHaveFocus() + expect(more.element().checkVisibility({ checkOpacity: true })).toBe(true) + expect(more.element().closest('[inert]')).toBeNull() + await userEvent.keyboard('{Enter}') + await expect + .element(screen.getByRole('menuitem', { name: 'explore.sidebar.action.pin' })) + .toBeVisible() + await userEvent.keyboard('{Escape}') + await expect.element(more).toHaveFocus() + await screen.getByRole('button', { name: 'Outside sidebar' }).click() + await expect.poll(() => conversation.element().closest('[inert]')).not.toBeNull() +}) + +it('closes the floating sidebar when both the pointer and keyboard focus leave it', async () => { + // Native pointer leave precedes blur here. Tab between sidebar controls must + // keep the panel usable, but moving focus outside must make its actions inert. + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const screen = await render( + +
+ + +
+
, + ) + await screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }).click() + await screen.getByRole('region', { name: 'Chat preview' }).hover({ position: { x: 2, y: 150 } }) + const conversation = screen.getByRole('button', { name: 'Saved conversation' }).last() + await conversation.click() + await screen.getByRole('button', { name: 'Outside sidebar' }).hover() + await expect.element(conversation).toHaveFocus() + + await userEvent.tab() + const more = screen.getByRole('button', { name: 'common.operation.more' }).nth(2) + await expect.element(more).toHaveFocus() + await userEvent.tab({ shift: true }) + await expect.element(conversation).toHaveFocus() + + const outside = screen.getByRole('button', { name: 'Outside sidebar' }) + await outside.click() + await expect.element(outside).toHaveFocus() + await expect.poll(() => conversation.element().closest('[inert]')).not.toBeNull() +}) diff --git a/web/app/components/base/chat/chat-with-history/header-in-mobile.tsx b/web/app/components/base/chat/chat-with-history/header-in-mobile.tsx index 11c0f04a649..39bff5e5008 100644 --- a/web/app/components/base/chat/chat-with-history/header-in-mobile.tsx +++ b/web/app/components/base/chat/chat-with-history/header-in-mobile.tsx @@ -8,6 +8,16 @@ import { AlertDialogDescription, AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' +import { + Dialog, + DialogBackdrop, + DialogClose, + DialogPopup, + DialogPortal, + DialogTitle, + DialogTrigger, + DialogViewport, +} from '@langgenius/dify-ui/dialog' import { IconButton } from '@langgenius/dify-ui/icon-button' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -77,86 +87,105 @@ const HeaderInMobile = () => { return ( <> -
- $['sidebar.expandSidebar'], { ns: 'layout' })} - size="lg" - className="shrink-0" - onClick={() => setShowSidebar(true)} - > -