Compare commits

...
Author SHA1 Message Date
yyhandGitHub b95d313128 refactor(web): improve workflow block selector (#39377) 2026-07-21 14:18:38 +00:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>yyh
45ac70132c chore(i18n): sync translations with en-US (#39362)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-07-21 11:54:20 +00:00
JoelandGitHub 6e772259bb fix: update the agent roster documentation link (#39368) 2026-07-21 09:56:21 +00:00
QuantumGhostandGitHub af465fb527 fix(api): scope HITL snapshot message lookup to utilize db index (#39351) 2026-07-21 09:47:17 +00:00
Xiyuan ChenGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
62bbc0dbeb feat(webapp-access): add WEBAPP_PUBLIC_ACCESS_ENABLED toggle (#38567)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-21 08:46:28 +00:00
zyssyz123andGitHub ed1289281d fix(agent): include workflow runs in monitoring stats (#39354) 2026-07-21 08:33:23 +00:00
Asuka MinatoandGitHub e37f32e4a0 test: add SQLite session fixture for providers (#38982) 2026-07-21 08:29:44 +00:00
Asuka MinatoandGitHub 150613cc05 test: use sqlite3 session in test_build_from_mapping (#38769) 2026-07-21 08:21:36 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
a5194ff17b test: move human input delivery coverage to unit tests (#38926)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-21 08:18:32 +00:00
Asuka MinatoandGitHub cdaf5d4562 test: use sqlite3 session in test_tool_file_manager (#38771) 2026-07-21 08:10:23 +00:00
Asuka MinatoandGitHub 145b0e622e test: use sqlite3 session in test_workspace_account (#38757) 2026-07-21 08:07:57 +00:00
Asuka MinatoandGitHub 4bab62d458 test: use sqlite3 session in test_agent_app_parameters (#38759) 2026-07-21 07:59:00 +00:00
Asuka MinatoandGitHub b99f8941bb test: use sqlite3 session in test_database_retrieval (#38743) 2026-07-21 07:56:34 +00:00
Asuka MinatoandGitHub 32d2594877 test: move app import API coverage to unit tests (#38920) 2026-07-21 07:54:32 +00:00
Asuka MinatoandGitHub 7b188287da test: use sqlite3 session in test_accounts (#38752) 2026-07-21 07:50:05 +00:00
Asuka MinatoandGitHub f6db23444d test: move API token service coverage to unit tests (#38918) 2026-07-21 07:48:10 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
a5c989d82b test: use sqlite3 session in test_conversation_service (#38774)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-21 07:38:53 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2622b1ce0a test: use sqlite3 session in test_based_generate_task_pipeline (#38738)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-21 07:37:20 +00:00
Asuka MinatoandGitHub 1e672530c4 test: use sqlite3 session in test_built_in_retrieval (#38735) 2026-07-21 07:36:42 +00:00
Asuka MinatoandGitHub 852169d99c test: use SQLite sessions in unit misc (#39118) 2026-07-21 07:27:13 +00:00
Asuka MinatoandGitHub d7860571aa test: use SQLite sessions in services workflow (#39115) 2026-07-21 07:24:52 +00:00
Asuka MinatoandGitHub 3683831738 test: use sqlite3 session in test_account_service (#38751) 2026-07-21 07:22:05 +00:00
非法操作andGitHub f5024f5440 fix(workflow): avoid false draft save error during Strict Mode initia… (#39353) 2026-07-21 07:11:19 +00:00
JoelGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
4f8477427e fix: not support jump to detail in marketplace (#39347)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-21 07:01:00 +00:00
非法操作andGitHub 8d406aec31 fix(dev): use gevent WebSocket server for local API (#39349) 2026-07-21 06:42:55 +00:00
落尘andGitHub f8754286ec fix(watercrawl): handle non-json error responses (#37514) 2026-07-21 06:41:06 +00:00
yyhandGitHub 7da6b2ce36 refactor(web): remove permission catalog service hooks (#39342) 2026-07-21 06:01:00 +00:00
aa718add24 fix(api): return 400 instead of 500 when 'file' is missing from trial-app audio-to-text (#39305)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
2026-07-21 05:59:48 +00:00
yyhandGitHub fcc1d39f5e fix(web): restore billing access boundary (#39343) 2026-07-21 05:35:36 +00:00
yyhandGitHub 861831f176 fix(api): keep workflow log archives owner-admin only (#39338) 2026-07-21 04:31:14 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
d333aa31ea test: use SQLite sessions in services workflow (#39116)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-21 03:40:02 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
97e6a14c13 test: use SQLite sessions in unit misc (#39119)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-21 03:20:59 +00:00
shaktiandGitHub 6a338654fb fix: return 400 instead of 500 when file field is missing in audio-to-text endpoints (#39303) (#39322) 2026-07-21 02:19:24 +00:00
ChecoandGitHub 8f075c82bc fix(configs): reject host:port-shaped PLUGIN_REMOTE_INSTALL_PORT with actionable hint (#39329)
Signed-off-by: sergioperezcheco <checo520@outlook.com>
2026-07-21 02:18:29 +00:00
林玮 (Jade Lin)andGitHub 28d17da3b1 fix(api): use resource tenant for draft variable files (#39307) 2026-07-21 02:07:47 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
891b2dc537 test: use SQLite sessions in unit misc (#39120)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-21 01:51:24 +00:00
WH-2099andGitHub 75f7069541 ci: simplify test workflow execution (#39318) 2026-07-21 00:18:59 +00:00
yyhandGitHub b7193d1cba refactor(web): remove basePath from hydration boundary (#39308) 2026-07-20 14:46:37 +00:00
wangxiaoleiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
ef0115d340 chore: when dataset permission is not all team, update rbac config to… (#39227)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-20 12:59:29 +00:00
3afb9b3230 fix(web): stop doubling basePath in auth refresh redirects (#39273)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-07-20 12:22:40 +00:00
JoelandGitHub 0862641533 feat: support export agent dsl in sidebar (#39299) 2026-07-20 10:41:26 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
cbd7872520 test: use SQLite sessions in unit misc (#39121)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-20 10:38:52 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
23882a704e test: use SQLite sessions in unit misc (#39122)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-20 10:38:49 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1bd5254ea7 test: use SQLite sessions in unit misc (#39123)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-20 10:38:46 +00:00
yyhandGitHub e13069dbe3 revert(ci): restore default Depot runners (#39297) 2026-07-20 10:18:52 +00:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>yyh
edd3d4a218 chore(i18n): sync translations with en-US (#39296)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-07-20 10:15:37 +00:00
JoelandGitHub 79751f7622 chore: display non-LLM settings in integration tool details (#39295) 2026-07-20 09:52:54 +00:00
JingyiGitHubhjlarryautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>yyhyyh
cb64446fa3 feat(web): add step-by-step tour shell (#38785)
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-07-20 09:23:25 +00:00
JoelandGitHub e77ee82526 fix: show persistent error when workflow draft save fails (#39293) 2026-07-20 08:48:38 +00:00
yyhandGitHub b3f163fb0f chore(deps): update pnpm and workspace dependencies (#39292) 2026-07-20 08:43:21 +00:00
513 changed files with 21179 additions and 7725 deletions
+3 -6
View File
@@ -16,7 +16,7 @@ concurrency:
jobs:
api-unit:
name: API Unit Tests
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
env:
COVERAGE_FILE: coverage-unit
defaults:
@@ -47,9 +47,6 @@ jobs:
- name: Install dependencies
run: uv sync --project api --dev
- name: Run dify config tests
run: uv run --project api pytest api/tests/unit_tests/configs/test_env_consistency.py
- name: Run Unit Tests
run: |
uv run --project api pytest \
@@ -75,7 +72,7 @@ jobs:
api-integration:
name: API Integration Tests
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
env:
COVERAGE_FILE: coverage-integration
STORAGE_TYPE: opendal
@@ -129,7 +126,7 @@ jobs:
api-coverage:
name: API Coverage
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
needs:
- api-unit
- api-integration
+1 -1
View File
@@ -173,7 +173,7 @@ jobs:
create-manifest:
needs: build
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
if: github.repository == 'langgenius/dify'
strategy:
matrix:
+2 -2
View File
@@ -23,7 +23,7 @@ concurrency:
jobs:
validate:
name: validate manifest + resolve target Dify release
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
if: github.repository == 'langgenius/dify'
permissions:
contents: read
@@ -87,7 +87,7 @@ jobs:
release:
name: build + attach standalone binaries (all targets)
needs: validate
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
permissions:
contents: write
defaults:
+2 -2
View File
@@ -9,7 +9,7 @@ concurrency:
jobs:
db-migration-test-postgres:
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- name: Checkout code
@@ -59,7 +59,7 @@ jobs:
run: uv run --directory api flask upgrade-db
db-migration-test-mysql:
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- name: Checkout code
+1 -1
View File
@@ -13,7 +13,7 @@ on:
jobs:
deploy:
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'deploy/agent'
+1 -1
View File
@@ -10,7 +10,7 @@ on:
jobs:
deploy:
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'deploy/dev'
+1 -1
View File
@@ -13,7 +13,7 @@ on:
jobs:
deploy:
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'deploy/enterprise'
+1 -1
View File
@@ -13,7 +13,7 @@ on:
jobs:
deploy:
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'deploy/saas'
+1 -1
View File
@@ -22,7 +22,7 @@ concurrency:
jobs:
check-cherry-pick-provenance:
name: Require cherry-pick provenance
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
+1 -1
View File
@@ -7,7 +7,7 @@ jobs:
permissions:
contents: read
pull-requests: write
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
with:
+16 -17
View File
@@ -21,7 +21,7 @@ concurrency:
jobs:
pre_job:
name: Skip Duplicate Checks
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip || 'false' }}
steps:
@@ -37,7 +37,7 @@ jobs:
name: Check Changed Files
needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true'
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
outputs:
api-changed: ${{ steps.changes.outputs.api }}
cli-changed: ${{ steps.changes.outputs.cli }}
@@ -81,7 +81,6 @@ jobs:
- '.npmrc'
- '.nvmrc'
- '.github/workflows/cli-tests.yml'
- '.github/workflows/cli-docker-build.yml'
- '.github/actions/setup-web/**'
web:
- 'web/**'
@@ -164,7 +163,7 @@ jobs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.api-changed != 'true'
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped API tests
run: echo "No API-related changes detected; skipping API tests."
@@ -177,7 +176,7 @@ jobs:
- check-changes
- api-tests-run
- api-tests-skip
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- name: Finalize API Tests status
env:
@@ -224,7 +223,7 @@ jobs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.cli-changed != 'true'
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped CLI tests
run: echo "No CLI-related changes detected; skipping CLI tests."
@@ -237,7 +236,7 @@ jobs:
- check-changes
- cli-tests-run
- cli-tests-skip
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- name: Finalize CLI Tests status
env:
@@ -284,7 +283,7 @@ jobs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.web-changed != 'true'
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped web tests
run: echo "No web-related changes detected; skipping web tests."
@@ -297,7 +296,7 @@ jobs:
- check-changes
- web-tests-run
- web-tests-skip
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- name: Finalize Web Tests status
env:
@@ -344,7 +343,7 @@ jobs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped web full-stack e2e
run: echo "No E2E-related changes detected; skipping web full-stack E2E."
@@ -357,7 +356,7 @@ jobs:
- check-changes
- web-e2e-run
- web-e2e-skip
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- name: Finalize Web Full-Stack E2E status
env:
@@ -411,7 +410,7 @@ jobs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed != 'true'
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped VDB tests
run: echo "No VDB-related changes detected; skipping VDB tests."
@@ -424,7 +423,7 @@ jobs:
- check-changes
- vdb-tests-run
- vdb-tests-skip
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- name: Finalize VDB Tests status
env:
@@ -470,7 +469,7 @@ jobs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed != 'true'
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped DB migration tests
run: echo "No migration-related changes detected; skipping DB migration tests."
@@ -483,7 +482,7 @@ jobs:
- check-changes
- db-migration-test-run
- db-migration-test-skip
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- name: Finalize DB Migration Test status
env:
@@ -530,7 +529,7 @@ jobs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.sandbox-runtime-changed != 'true'
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped sandbox runtime tests
run: echo "No sandbox-runtime-related changes detected; skipping sandbox runtime tests."
@@ -543,7 +542,7 @@ jobs:
- check-changes
- sandbox-runtime-tests-run
- sandbox-runtime-tests-skip
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- name: Finalize Sandbox Runtime Tests status
env:
+1 -1
View File
@@ -14,7 +14,7 @@ concurrency:
jobs:
check-changes:
name: Check Changed Files
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
outputs:
external-e2e-changed: ${{ steps.changes.outputs.external_e2e }}
steps:
+1 -1
View File
@@ -12,7 +12,7 @@ permissions: {}
jobs:
comment:
name: Comment PR with pyrefly diff
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
permissions:
actions: read
contents: read
+1 -1
View File
@@ -10,7 +10,7 @@ permissions:
jobs:
pyrefly-diff:
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
permissions:
contents: read
issues: write
@@ -12,7 +12,7 @@ permissions: {}
jobs:
comment:
name: Comment PR with type coverage
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
permissions:
actions: read
contents: read
+1 -1
View File
@@ -10,7 +10,7 @@ permissions:
jobs:
pyrefly-type-coverage:
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
permissions:
contents: read
issues: write
+3 -3
View File
@@ -13,7 +13,7 @@ concurrency:
jobs:
sandbox-runtime-unit:
name: Sandbox Runtime Unit Tests
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
defaults:
run:
shell: bash
@@ -37,7 +37,7 @@ jobs:
sandbox-runtime-lint:
name: Sandbox Runtime Lint
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
defaults:
run:
shell: bash
@@ -64,7 +64,7 @@ jobs:
sandbox-runtime-integration:
name: Sandbox Runtime Integration Tests
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
defaults:
run:
shell: bash
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
name: Validate PR title
permissions:
pull-requests: read
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- name: Complete merge group check
if: github.event_name == 'merge_group'
+1 -1
View File
@@ -12,7 +12,7 @@ on:
jobs:
stale:
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
permissions:
issues: write
pull-requests: write
+3 -3
View File
@@ -19,7 +19,7 @@ permissions:
jobs:
python-style:
name: Python Style
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- name: Checkout code
@@ -83,7 +83,7 @@ jobs:
web-style:
name: Web Style
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
defaults:
run:
working-directory: ./web
@@ -182,7 +182,7 @@ jobs:
superlinter:
name: SuperLinter
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
steps:
- name: Checkout code
+1 -1
View File
@@ -17,7 +17,7 @@ concurrency:
jobs:
build:
name: unit test for Node.js SDK
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
defaults:
run:
+1 -1
View File
@@ -35,7 +35,7 @@ concurrency:
jobs:
translate:
if: github.repository == 'langgenius/dify'
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
timeout-minutes: 120
steps:
+1 -1
View File
@@ -16,7 +16,7 @@ concurrency:
jobs:
trigger:
if: github.repository == 'langgenius/dify'
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
timeout-minutes: 5
steps:
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
test:
name: Full VDB Tests
if: github.repository == 'langgenius/dify'
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
strategy:
matrix:
python-version:
+1 -1
View File
@@ -13,7 +13,7 @@ concurrency:
jobs:
test:
name: VDB Smoke Tests
runs-on: depot-ubuntu-24.04-4
runs-on: depot-ubuntu-24.04
strategy:
matrix:
python-version:
-10
View File
@@ -135,16 +135,6 @@ jobs:
exit 1
fi
if [[ -d cucumber-report ]]; then
rm -rf cucumber-report-non-external
mv cucumber-report cucumber-report-non-external
fi
if [[ -d .logs ]]; then
rm -rf .logs-non-external
mv .logs .logs-non-external
fi
teardown_external_runtime() {
local run_status=$?
trap - EXIT
-2
View File
@@ -17,8 +17,6 @@ jobs:
test:
name: Web Tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
runs-on: depot-ubuntu-24.04-4
env:
VITEST_COVERAGE_SCOPE: app-components
strategy:
fail-fast: false
matrix:
+2
View File
@@ -107,6 +107,7 @@ test:
echo "Target: $(TARGET_TESTS)"; \
uv run --project api --dev pytest $(TARGET_TESTS); \
else \
set -e; \
echo "Running backend unit tests"; \
uv run --project api --dev pytest -p no:benchmark --timeout "$${PYTEST_TIMEOUT:-20}" -n auto \
api/tests/unit_tests \
@@ -124,6 +125,7 @@ test-all:
echo "Target: $(TARGET_TESTS)"; \
uv run --project api --dev pytest $(TARGET_TESTS); \
else \
set -e; \
echo "Running backend unit tests"; \
uv run --project api --dev pytest -p no:benchmark --timeout "$${PYTEST_TIMEOUT:-20}" -n auto \
api/tests/unit_tests \
+6
View File
@@ -14,6 +14,12 @@ class EnterpriseFeatureConfig(BaseSettings):
default=False,
)
WEBAPP_PUBLIC_ACCESS_ENABLED: bool = Field(
description="Whether admins are allowed to set a webapp's access mode to public (anyone with the link, "
"no auth). Disable in security-sensitive on-prem deployments.",
default=True,
)
CAN_REPLACE_LOGO: bool = Field(
description="Allow customization of the enterprise logo.",
default=False,
+33 -1
View File
@@ -1,4 +1,4 @@
from datetime import timedelta
from datetime import datetime, timedelta
from enum import StrEnum
from typing import Literal
@@ -11,6 +11,7 @@ from pydantic import (
PositiveFloat,
PositiveInt,
computed_field,
field_validator,
)
from pydantic_settings import BaseSettings
@@ -280,6 +281,27 @@ class PluginConfig(BaseSettings):
default="",
)
@field_validator("PLUGIN_REMOTE_INSTALL_PORT", mode="before")
@classmethod
def _reject_host_port_shaped_plugin_remote_install_port(cls, v):
"""Reject ``host:port``-shaped values with an actionable hint.
``EXPOSE_PLUGIN_DEBUGGING_PORT`` is overloaded: it feeds both the
plugin_daemon ``ports:`` mapping (where ``127.0.0.1:5003`` is valid
compose syntax) and this integer app setting advertised in the console.
Without this guard a loopback bind spec crashloops the api container
with an opaque ``int_parsing`` traceback. See issue #39323.
"""
if isinstance(v, str) and ":" in v.strip():
raise ValueError(
"PLUGIN_REMOTE_INSTALL_PORT must be a bare port number, got "
f"{v!r}. A 'host:port' value usually means "
"EXPOSE_PLUGIN_DEBUGGING_PORT was set to a compose publish spec "
"like '127.0.0.1:5003'; bind loopback via a "
"docker-compose.override.yaml instead of overloading this var."
)
return v
@property
def NEW_USER_DEFAULT_PLUGIN_ID_LIST(self) -> list[str]:
return [item.strip() for item in self.NEW_USER_DEFAULT_PLUGIN_IDS.split(",") if item.strip()]
@@ -1138,6 +1160,16 @@ class HomepageConfig(BaseSettings):
default=True,
)
ENABLE_STEP_BY_STEP_TOUR: bool = Field(
description="Enable account-level Step-by-step Tour eligibility checks",
default=False,
)
STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT: datetime | None = Field(
description="UTC timestamp after which newly initialized accounts are eligible for Step-by-step Tour",
default=None,
)
class RagEtlConfig(BaseSettings):
"""
+2
View File
@@ -40,6 +40,7 @@ from . import (
init_validate,
knowledge_fs_proxy,
notification,
onboarding,
ping,
setup,
spec,
@@ -209,6 +210,7 @@ __all__ = [
"notification",
"oauth",
"oauth_server",
"onboarding",
"ops_trace",
"parameter",
"ping",
+8 -1
View File
@@ -607,6 +607,13 @@ class DatasetListApi(Resource):
ReplaceMemberBindings(scope=RBACResourceWhitelistScope.ALL),
)
initialize_created_app_rbac_access_task.delay(current_tenant_id, current_user.id, dataset_id=dataset.id)
else:
enterprise_rbac_service.RBACService.DatasetAccess.replace_whitelist(
current_tenant_id,
current_user.id,
dataset.id,
ReplaceMemberBindings(scope=RBACResourceWhitelistScope.SPECIFIC),
)
permission_keys_map = enterprise_rbac_service.RBACService.DatasetPermissions.batch_get(
current_tenant_id,
@@ -875,7 +882,7 @@ class DatasetIndexingEstimateApi(Resource):
file_details = session.scalars(
select(UploadFile).where(UploadFile.tenant_id == current_tenant_id, UploadFile.id.in_(file_ids))
).all()
if file_details is None:
if not file_details:
raise NotFound("File not found.")
if file_details:
+1 -1
View File
@@ -645,7 +645,7 @@ class TrialChatAudioApi(TrialAppResource):
def post(self, current_user: Account, trial_app):
app_model = trial_app
file = request.files["file"]
file = request.files.get("file")
try:
# Get IDs before they might be detached from session
+106
View File
@@ -0,0 +1,106 @@
"""Console onboarding APIs.
This module keeps Step-by-step Tour persistence account-scoped. Workspace IDs
are accepted only as presentation overrides; UI-only state such as minimized
panels or the currently active task stays on the frontend. PATCH requests are
action-based so callers do not replace server-side arrays with stale snapshots.
"""
from datetime import datetime
from typing import Literal, cast
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field, model_validator
from controllers.common.schema import register_response_schema_models, register_schema_models
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import dump_response
from libs.login import login_required
from models import Account
from services.step_by_step_tour_service import StepByStepTourPatch, StepByStepTourService
from . import console_ns
from .wraps import account_initialization_required, setup_required, with_current_tenant_id, with_current_user
StepByStepTourAction = Literal[
"skip",
"complete_task",
"uncomplete_task",
"enable_current_workspace",
"disable_current_workspace",
]
StepByStepTourTaskId = Literal["home", "studio", "knowledge", "integration"]
class StepByStepTourStatePatchPayload(BaseModel):
action: StepByStepTourAction = Field(description="State update action")
task_id: StepByStepTourTaskId | None = Field(default=None, description="Task ID for task actions")
model_config = ConfigDict(extra="forbid")
@model_validator(mode="after")
def validate_patch_shape(self) -> "StepByStepTourStatePatchPayload":
task_actions = {"complete_task", "uncomplete_task"}
if self.action in task_actions and self.task_id is None:
raise ValueError("task_id is required for task actions")
if self.action not in task_actions and self.task_id is not None:
raise ValueError("task_id is only supported for task actions")
return self
class StepByStepTourStateResponse(ResponseModel):
first_workspace_id: str | None = None
skipped: bool = False
completed_task_ids: list[StepByStepTourTaskId] = Field(default_factory=list)
manually_enabled_workspace_ids: list[str] = Field(default_factory=list)
manually_disabled_workspace_ids: list[str] = Field(default_factory=list)
updated_at: datetime | None = None
register_schema_models(console_ns, StepByStepTourStatePatchPayload)
register_response_schema_models(console_ns, StepByStepTourStateResponse)
@console_ns.route("/onboarding/step-by-step-tour/state")
class StepByStepTourStateApi(Resource):
@console_ns.doc("get_step_by_step_tour_state")
@console_ns.doc(description="Get account-level Step-by-step Tour state")
@console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
return dump_response(
StepByStepTourStateResponse,
StepByStepTourService.get_state(
account=current_user,
current_tenant_id=current_tenant_id,
session=db.session,
),
)
@console_ns.doc("patch_step_by_step_tour_state")
@console_ns.doc(description="Update account-level Step-by-step Tour state")
@console_ns.expect(console_ns.models[StepByStepTourStatePatchPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def patch(self, current_tenant_id: str, current_user: Account):
payload = StepByStepTourStatePatchPayload.model_validate(console_ns.payload or {})
patch = cast(StepByStepTourPatch, payload.model_dump(exclude_unset=True, exclude_none=True))
return dump_response(
StepByStepTourStateResponse,
StepByStepTourService.patch_state(
account=current_user,
current_tenant_id=current_tenant_id,
patch=patch,
session=db.session,
),
)
+10 -27
View File
@@ -4,20 +4,16 @@ from http import HTTPStatus
from flask import redirect
from flask_restx import Resource
from pydantic import BaseModel, Field
from werkzeug.exceptions import Conflict, NotFound
from werkzeug.exceptions import Conflict, Forbidden, NotFound
from controllers.common.fields import RedirectResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
cloud_edition_billing_enabled,
cloud_edition_billing_paid_plan_required,
is_admin_or_owner_required,
only_edition_cloud,
rbac_permission_required,
setup_required,
)
from extensions.ext_database import db
@@ -25,6 +21,7 @@ from fields.base import ResponseModel
from libs.archive_storage import get_export_storage
from libs.helper import dump_response
from libs.login import current_account_with_tenant, login_required
from models import TenantAccountRole
from services.retention.workflow_run.archive_download_preparation import ARCHIVE_DOWNLOAD_MIME_TYPE
from services.retention.workflow_run.archive_download_task_cache import (
WorkflowRunArchiveDownloadStatus,
@@ -98,11 +95,13 @@ register_response_schema_models(
)
def _current_ids() -> tuple[str, str]:
"""Return current `(tenant_id, account_id)` or raise when no workspace is selected."""
def _current_owner_or_admin_ids() -> tuple[str, str]:
"""Return current Cloud workspace IDs for an owner or admin, independently of enterprise RBAC."""
current_user, current_tenant_id = current_account_with_tenant()
if not current_tenant_id:
raise NotFound("Current workspace not found")
if not TenantAccountRole.is_privileged_role(current_user.current_role):
raise Forbidden()
return current_tenant_id, current_user.id
@@ -124,12 +123,8 @@ class WorkflowRunArchivesApi(Resource):
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
@is_admin_or_owner_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False
)
def get(self):
tenant_id, _ = _current_ids()
tenant_id, _ = _current_owner_or_admin_ids()
return dump_response(WorkflowRunArchiveListResponse, list_workflow_run_archives(db.session(), tenant_id))
@@ -149,12 +144,8 @@ class WorkflowRunArchiveDownloadsApi(Resource):
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
@is_admin_or_owner_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False
)
def post(self):
tenant_id, account_id = _current_ids()
tenant_id, account_id = _current_owner_or_admin_ids()
payload = WorkflowRunArchiveDownloadPayload.model_validate(console_ns.payload or {})
try:
task = create_workflow_run_archive_download_task(
@@ -180,12 +171,8 @@ class WorkflowRunArchiveDownloadApi(Resource):
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
@is_admin_or_owner_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False
)
def get(self, download_id: str):
tenant_id, _ = _current_ids()
tenant_id, _ = _current_owner_or_admin_ids()
try:
task = get_workflow_run_archive_download_task(tenant_id=tenant_id, download_id=download_id)
except WorkflowRunArchiveDownloadTaskNotFoundError as exc:
@@ -209,12 +196,8 @@ class WorkflowRunArchiveDownloadFileApi(Resource):
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
@is_admin_or_owner_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False
)
def get(self, download_id: str):
tenant_id, _ = _current_ids()
tenant_id, _ = _current_owner_or_admin_ids()
try:
task = get_ready_workflow_run_archive_download_task(tenant_id=tenant_id, download_id=download_id)
except WorkflowRunArchiveDownloadTaskNotFoundError as exc:
+1 -1
View File
@@ -101,7 +101,7 @@ class AudioApi(Resource):
Accepts an audio file upload and returns the transcribed text.
"""
file = request.files["file"]
file = request.files.get("file")
try:
response = AudioService.transcript_asr(
+16 -8
View File
@@ -531,14 +531,22 @@ class DatasetListApi(DatasetApiResource):
except services.errors.dataset.DatasetNameDuplicateError:
raise DatasetNameDuplicateError()
if payload.permission == DatasetPermissionEnum.ALL_TEAM and dify_config.RBAC_ENABLED:
RBACService.DatasetAccess.replace_whitelist(
tenant_id,
current_user.id,
dataset.id,
ReplaceMemberBindings(scope=RBACResourceWhitelistScope.ALL),
)
initialize_created_app_rbac_access_task.delay(tenant_id, current_user.id, dataset_id=dataset.id)
if dify_config.RBAC_ENABLED:
if payload.permission == DatasetPermissionEnum.ALL_TEAM:
RBACService.DatasetAccess.replace_whitelist(
tenant_id,
current_user.id,
dataset.id,
ReplaceMemberBindings(scope=RBACResourceWhitelistScope.ALL),
)
initialize_created_app_rbac_access_task.delay(tenant_id, current_user.id, dataset_id=dataset.id)
else:
RBACService.DatasetAccess.replace_whitelist(
tenant_id,
current_user.id,
dataset.id,
ReplaceMemberBindings(scope=RBACResourceWhitelistScope.SPECIFIC),
)
return _dump_service_dataset_detail(dataset, session=session), 200
+1 -1
View File
@@ -76,7 +76,7 @@ class AudioApi(WebApiResource):
@web_ns.response(200, "Success", web_ns.models[AudioToTextResponse.__name__])
def post(self, app_model: App, end_user: EndUser):
"""Convert audio to text"""
file = request.files["file"]
file = request.files.get("file")
try:
response = AudioService.transcript_asr(
@@ -625,7 +625,11 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
message=message_snapshot,
user=user,
stream=stream,
draft_var_saver_factory=self._get_draft_var_saver_factory(invoke_from, account=user),
draft_var_saver_factory=self._get_draft_var_saver_factory(
invoke_from,
account=user,
tenant_id=application_generate_entity.app_config.tenant_id,
),
)
return AdvancedChatAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
+10 -1
View File
@@ -32,6 +32,7 @@ class _DebuggerDraftVariableSaver:
self,
*,
account: Account,
tenant_id: str,
app_id: str,
node_id: str,
node_type: NodeType,
@@ -39,6 +40,7 @@ class _DebuggerDraftVariableSaver:
enclosing_node_id: str | None = None,
) -> None:
self._account = account
self._tenant_id = tenant_id
self._app_id = app_id
self._node_id = node_id
self._node_type = node_type
@@ -49,6 +51,7 @@ class _DebuggerDraftVariableSaver:
with Session(db.engine) as session, session.begin():
DraftVariableSaverImpl(
session=session,
tenant_id=self._tenant_id,
app_id=self._app_id,
node_id=self._node_id,
node_type=self._node_type,
@@ -287,7 +290,12 @@ class BaseAppGenerator:
@final
@staticmethod
def _get_draft_var_saver_factory(invoke_from: InvokeFrom, account: Account | EndUser) -> DraftVariableSaverFactory:
def _get_draft_var_saver_factory(
invoke_from: InvokeFrom,
account: Account | EndUser,
*,
tenant_id: str,
) -> DraftVariableSaverFactory:
if invoke_from == InvokeFrom.DEBUGGER:
assert isinstance(account, Account)
@@ -300,6 +308,7 @@ class BaseAppGenerator:
) -> DraftVariableSaver:
return _DebuggerDraftVariableSaver(
account=account,
tenant_id=tenant_id,
app_id=app_id,
node_id=node_id,
node_type=node_type,
@@ -349,6 +349,7 @@ class PipelineGenerator(BaseAppGenerator):
draft_var_saver_factory = self._get_draft_var_saver_factory(
invoke_from,
user,
tenant_id=pipeline.tenant_id,
)
# return response or stream generator
response = self._handle_response(
+5 -1
View File
@@ -399,7 +399,11 @@ class WorkflowAppGenerator(BaseAppGenerator):
worker_thread.start()
draft_var_saver_factory = self._get_draft_var_saver_factory(invoke_from, user)
draft_var_saver_factory = self._get_draft_var_saver_factory(
invoke_from,
user,
tenant_id=app_model.tenant_id,
)
# return response or stream generator
response = self._handle_response(
+4 -1
View File
@@ -131,7 +131,10 @@ class WaterCrawlAPIClient(BaseAPIClient):
content_type = response.headers.get("Content-Type", "")
media_type = content_type.split(";", 1)[0].strip().lower()
if media_type == "application/json":
return response.json() or {}
try:
return response.json() or {}
except ValueError as exc:
raise ValueError("Invalid JSON response from WaterCrawl") from exc
if media_type == "application/octet-stream":
return response.content
@@ -1,5 +1,15 @@
"""WaterCrawl domain exceptions.
These exceptions are constructed from upstream HTTP responses, which may be
JSON API errors or plain text/HTML proxy errors. Keep the exception type stable
even when the body is not JSON so callers can handle WaterCrawl failures by
domain type instead of low-level parser errors.
"""
import json
from typing import override
from typing import Any, override
from httpx import Response
class WaterCrawlError(Exception):
@@ -7,11 +17,16 @@ class WaterCrawlError(Exception):
class WaterCrawlBadRequestError(WaterCrawlError):
def __init__(self, response):
def __init__(self, response: Response):
self.status_code = response.status_code
self.response = response
data = response.json()
self.message = data.get("message", "Unknown error occurred")
try:
data: Any = response.json()
except ValueError:
data = {}
if not isinstance(data, dict):
data = {}
self.message = data.get("message") or response.text or "Unknown error occurred"
self.errors = data.get("errors", {})
super().__init__(self.message)
@@ -0,0 +1,39 @@
"""add step by step tour state
Revision ID: b8c9d0e1f2a3
Revises: 3c9f8e2a1d7b
Create Date: 2026-06-29 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models
# revision identifiers, used by Alembic.
revision = "b8c9d0e1f2a3"
down_revision = "3c9f8e2a1d7b"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"account_step_by_step_tour_states",
sa.Column("id", models.types.StringUUID(), nullable=False),
sa.Column("account_id", models.types.StringUUID(), nullable=False),
sa.Column("first_workspace_id", models.types.StringUUID(), nullable=True),
sa.Column("skipped", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("completed_task_ids", models.types.AdjustedJSON(), nullable=False),
sa.Column("manually_enabled_workspace_ids", models.types.AdjustedJSON(), nullable=False),
sa.Column("manually_disabled_workspace_ids", models.types.AdjustedJSON(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id", name="account_step_by_step_tour_state_pkey"),
sa.UniqueConstraint("account_id", name="account_step_by_step_tour_state_account_id_key"),
)
def downgrade():
op.drop_table("account_step_by_step_tour_states")
+2
View File
@@ -101,6 +101,7 @@ from .model import (
UploadFile,
)
from .oauth import DatasourceOauthParamConfig, DatasourceProvider, OAuthAccessToken
from .onboarding import AccountStepByStepTourState
from .provider import (
LoadBalancingModelConfig,
Provider,
@@ -155,6 +156,7 @@ __all__ = [
"Account",
"AccountIntegrate",
"AccountStatus",
"AccountStepByStepTourState",
"AccountTrialAppRecord",
"Agent",
"AgentConfigDraft",
+59
View File
@@ -0,0 +1,59 @@
"""Account-level onboarding state models."""
from datetime import datetime
import sqlalchemy as sa
from sqlalchemy import DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import TypeBase, gen_uuidv7_string
from .types import AdjustedJSON, StringUUID
class AccountStepByStepTourState(TypeBase):
"""Persistent account-level Step-by-step Tour state.
The tour is account-owned, with workspace IDs stored only as presentation
overrides. The first workspace is the workspace context where an eligible
account first asks for tour state; subsequent workspaces are opt-in only.
"""
__tablename__ = "account_step_by_step_tour_states"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="account_step_by_step_tour_state_pkey"),
sa.UniqueConstraint("account_id", name="account_step_by_step_tour_state_account_id_key"),
)
id: Mapped[str] = mapped_column(
StringUUID,
insert_default=gen_uuidv7_string,
default_factory=gen_uuidv7_string,
init=False,
)
account_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
first_workspace_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
skipped: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"), default=False)
completed_task_ids: Mapped[list[str]] = mapped_column(AdjustedJSON, nullable=False, default_factory=list)
manually_enabled_workspace_ids: Mapped[list[str]] = mapped_column(
AdjustedJSON,
nullable=False,
default_factory=list,
)
manually_disabled_workspace_ids: Mapped[list[str]] = mapped_column(
AdjustedJSON,
nullable=False,
default_factory=list,
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
server_default=func.current_timestamp(),
nullable=False,
init=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
server_default=func.current_timestamp(),
nullable=False,
init=False,
onupdate=func.current_timestamp(),
)
+44
View File
@@ -7787,6 +7787,30 @@ Initiate OAuth login process
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [OAuthProviderTokenResponse](#oauthprovidertokenresponse)<br> |
### [GET] /onboarding/step-by-step-tour/state
Get account-level Step-by-step Tour state
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [StepByStepTourStateResponse](#stepbysteptourstateresponse)<br> |
### [PATCH] /onboarding/step-by-step-tour/state
Update account-level Step-by-step Tour state
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [StepByStepTourStatePatchPayload](#stepbysteptourstatepatchpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [StepByStepTourStateResponse](#stepbysteptourstateresponse)<br> |
### [DELETE] /rag/pipeline/customized/templates/{template_id}
#### Parameters
@@ -21788,6 +21812,24 @@ Query parameters for listing snippet published workflows.
| paused | integer | | Yes |
| success | integer | | Yes |
#### StepByStepTourStatePatchPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| action | string, <br>**Available values:** "complete_task", "disable_current_workspace", "enable_current_workspace", "skip", "uncomplete_task" | State update action<br>*Enum:* `"complete_task"`, `"disable_current_workspace"`, `"enable_current_workspace"`, `"skip"`, `"uncomplete_task"` | Yes |
| task_id | string | Task ID for task actions | No |
#### StepByStepTourStateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| completed_task_ids | [ string, <br>**Available values:** "home", "integration", "knowledge", "studio" ] | | No |
| first_workspace_id | string | | No |
| manually_disabled_workspace_ids | [ string ] | | No |
| manually_enabled_workspace_ids | [ string ] | | No |
| skipped | boolean | | No |
| updated_at | string | | No |
#### Storage
| Name | Type | Description | Required |
@@ -21941,6 +21983,7 @@ Model class for provider system configuration response.
| enable_learn_app | boolean, <br>**Default:** true | | Yes |
| enable_marketplace | boolean | | Yes |
| enable_social_oauth_login | boolean | | Yes |
| enable_step_by_step_tour | boolean | | Yes |
| enable_trial_app | boolean | | Yes |
| is_allow_create_workspace | boolean | | Yes |
| is_allow_register | boolean | | Yes |
@@ -22897,6 +22940,7 @@ in form definiton, or a variable while the workflow is running.
| ---- | ---- | ----------- | -------- |
| allow_email_code_login | boolean | | Yes |
| allow_email_password_login | boolean | | Yes |
| allow_public_access | boolean, <br>**Default:** true | | Yes |
| allow_sso | boolean | | Yes |
| enabled | boolean | | Yes |
| sso_config | [WebAppAuthSSOModel](#webappauthssomodel) | | Yes |
+2
View File
@@ -1577,6 +1577,7 @@ Default configuration for form inputs.
| enable_learn_app | boolean, <br>**Default:** true | | Yes |
| enable_marketplace | boolean | | Yes |
| enable_social_oauth_login | boolean | | Yes |
| enable_step_by_step_tour | boolean | | Yes |
| enable_trial_app | boolean | | Yes |
| is_allow_create_workspace | boolean | | Yes |
| is_allow_register | boolean | | Yes |
@@ -1642,6 +1643,7 @@ in form definiton, or a variable while the workflow is running.
| ---- | ---- | ----------- | -------- |
| allow_email_code_login | boolean | | Yes |
| allow_email_password_login | boolean | | Yes |
| allow_public_access | boolean, <br>**Default:** true | | Yes |
| allow_sso | boolean | | Yes |
| enabled | boolean | | Yes |
| sso_config | [WebAppAuthSSOModel](#webappauthssomodel) | | Yes |
+35
View File
@@ -0,0 +1,35 @@
"""Shared database fixtures for provider tests.
Provider tests live outside ``tests/unit_tests`` and therefore cannot use that
suite's SQLite fixtures. Keep this fixture scoped to ``providers`` so each
provider package can exercise real SQLAlchemy queries without a service
database or mocked sessions.
"""
from collections.abc import Iterator
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from models.base import TypeBase
@pytest.fixture
def sqlite3_session(request: pytest.FixtureRequest) -> Iterator[Session]:
"""Yield an isolated SQLite session with the parametrized model tables.
Pass the required ORM classes through indirect parametrization. The engine
is per-test so committed rows and identity-map state cannot leak between
provider packages.
"""
models: tuple[type[TypeBase], ...] = request.param
engine = create_engine("sqlite:///:memory:")
tables = [model.metadata.tables[model.__tablename__] for model in models]
TypeBase.metadata.create_all(engine, tables=tables)
try:
with Session(engine, expire_on_commit=False) as session:
yield session
finally:
engine.dispose()
+227 -18
View File
@@ -9,12 +9,13 @@ import sqlalchemy as sa
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import aliased
from configs import dify_config
from core.app.entities.app_invoke_entities import InvokeFrom
from libs.helper import convert_datetime_to_date, escape_like_pattern, to_timestamp
from models.agent import WorkflowAgentNodeBinding
from models.enums import MessageStatus
from models.enums import CreatorUserRole, MessageStatus
from models.model import App, Conversation, Message
from models.workflow import WorkflowNodeExecutionModel, WorkflowRun
from models.workflow import WorkflowNodeExecutionModel, WorkflowRun, WorkflowType
@dataclass(frozen=True)
@@ -580,9 +581,26 @@ class AgentObservabilityService:
def _load_daily_statistics(
self, *, app: App, agent_id: str, params: AgentStatisticsQueryParams, source_filter: AgentSourceFilter
) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
if source_filter.kind in {"all", "webapp"}:
rows.extend(self._load_webapp_daily_statistics(app=app, params=params, source_filter=source_filter))
if source_filter.kind in {"all", "workflow"}:
rows.extend(
self._load_workflow_daily_statistics(
app=app,
agent_id=agent_id,
params=params,
source_filter=source_filter,
)
)
return self._merge_daily_statistics(rows)
def _load_webapp_daily_statistics(
self, *, app: App, params: AgentStatisticsQueryParams, source_filter: AgentSourceFilter
) -> list[dict[str, Any]]:
converted_created_at = convert_datetime_to_date("m.created_at")
message_scope = self._statistics_message_scope_sql(source_filter)
message_scope = self._statistics_webapp_message_scope_sql(source_filter)
sql_query = f"""SELECT
{converted_created_at} AS date,
COUNT(m.id) AS message_count,
@@ -602,12 +620,154 @@ WHERE
args: dict[str, Any] = {
"tz": params.timezone,
"app_id": app.id,
"tenant_id": app.tenant_id,
"agent_id": agent_id,
"debugger": InvokeFrom.DEBUGGER,
}
if source_filter.invoke_from is not None:
args["source"] = source_filter.invoke_from
if params.start:
sql_query += " AND m.created_at >= :start"
args["start"] = params.start
if params.end:
sql_query += " AND m.created_at < :end"
args["end"] = params.end
sql_query += " GROUP BY date ORDER BY date"
return [dict(row._mapping) for row in self._session.execute(sa.text(sql_query), args).all()]
@staticmethod
def _statistics_webapp_message_scope_sql(source_filter: AgentSourceFilter) -> str:
app_scope = "m.app_id = :app_id"
if source_filter.invoke_from is not None:
app_scope += " AND m.invoke_from = :source"
return app_scope
def _load_workflow_daily_statistics(
self,
*,
app: App,
agent_id: str,
params: AgentStatisticsQueryParams,
source_filter: AgentSourceFilter,
) -> list[dict[str, Any]]:
converted_run_created_at = convert_datetime_to_date("aru.created_at")
total_tokens = self._workflow_execution_metadata_numeric_sql(("total_tokens",), "BIGINT")
nested_total_tokens = self._workflow_execution_metadata_numeric_sql(
("agent_log", "agent_backend", "usage", "total_tokens"), "BIGINT"
)
total_price = self._workflow_execution_metadata_numeric_sql(("total_price",), "DECIMAL(65, 30)")
nested_total_price = self._workflow_execution_metadata_numeric_sql(
("agent_log", "agent_backend", "usage", "total_price"), "DECIMAL(65, 30)"
)
completion_tokens = self._workflow_execution_metadata_numeric_sql(
("agent_log", "agent_backend", "usage", "completion_tokens"), "BIGINT"
)
binding_filters = self._statistics_workflow_binding_filters_sql(source_filter)
run_date_filters = ""
args: dict[str, Any] = {
"tz": params.timezone,
"tenant_id": app.tenant_id,
"agent_id": agent_id,
"chat_workflow_type": WorkflowType.CHAT,
"end_user_role": CreatorUserRole.END_USER,
}
if source_filter.app_id:
args["source_app_id"] = source_filter.app_id
if source_filter.workflow_id:
args["workflow_id"] = source_filter.workflow_id
if source_filter.workflow_version:
args["workflow_version"] = source_filter.workflow_version
if source_filter.node_id:
args["node_id"] = source_filter.node_id
if params.start:
run_date_filters += " AND wr.created_at >= :start"
args["start"] = params.start
if params.end:
run_date_filters += " AND wr.created_at < :end"
args["end"] = params.end
run_query = f"""WITH agent_run_usage AS (
SELECT
wr.id,
wr.created_by_role,
wr.created_by,
wr.created_at,
COALESCE(SUM(COALESCE({total_tokens}, {nested_total_tokens}, 0)), 0) AS token_count,
COALESCE(SUM(COALESCE({total_price}, {nested_total_price}, 0)), 0) AS total_price,
COALESCE(SUM(COALESCE(wne.elapsed_time, 0)), 0) AS latency,
COALESCE(SUM(COALESCE({completion_tokens}, 0)), 0) AS answer_tokens
FROM workflow_runs wr
JOIN workflow_agent_node_bindings wanb
ON wanb.tenant_id = :tenant_id
AND wanb.agent_id = :agent_id
AND wanb.app_id = wr.app_id
AND wanb.workflow_id = wr.workflow_id
AND wanb.workflow_version = wr.version
{binding_filters}
JOIN workflow_node_executions wne
ON wne.workflow_run_id = wr.id
AND wne.node_id = wanb.node_id
WHERE wr.type != :chat_workflow_type{run_date_filters}
GROUP BY wr.id, wr.created_by_role, wr.created_by, wr.created_at
)
SELECT
{converted_run_created_at} AS date,
COUNT(aru.id) AS message_count,
COUNT(aru.id) AS conversation_count,
COUNT(DISTINCT CASE
WHEN aru.created_by_role = :end_user_role THEN aru.created_by
ELSE NULL
END) AS end_user_count,
COALESCE(SUM(aru.token_count), 0) AS token_count,
COALESCE(SUM(aru.total_price), 0) AS total_price,
COALESCE(AVG(aru.latency), 0) AS avg_latency,
COALESCE(SUM(aru.latency), 0) AS latency_sum,
COALESCE(SUM(aru.answer_tokens), 0) AS answer_tokens,
0 AS like_count
FROM agent_run_usage aru
GROUP BY date
ORDER BY date"""
rows = [dict(row._mapping) for row in self._session.execute(sa.text(run_query), args).all()]
rows.extend(
self._load_workflow_chat_daily_context(
app=app,
agent_id=agent_id,
params=params,
source_filter=source_filter,
)
)
return self._merge_daily_statistics(rows)
def _load_workflow_chat_daily_context(
self,
*,
app: App,
agent_id: str,
params: AgentStatisticsQueryParams,
source_filter: AgentSourceFilter,
) -> list[dict[str, Any]]:
converted_created_at = convert_datetime_to_date("m.created_at")
workflow_scope = self._statistics_workflow_message_scope_sql(source_filter)
sql_query = f"""SELECT
{converted_created_at} AS date,
COUNT(m.id) AS message_count,
COUNT(DISTINCT m.conversation_id) AS conversation_count,
COUNT(DISTINCT m.from_end_user_id) AS end_user_count,
COALESCE(SUM(COALESCE(m.message_tokens, 0) + COALESCE(m.answer_tokens, 0)), 0) AS token_count,
COALESCE(SUM(COALESCE(m.total_price, 0)), 0) AS total_price,
COALESCE(AVG(m.provider_response_latency), 0) AS avg_latency,
COALESCE(SUM(m.provider_response_latency), 0) AS latency_sum,
COALESCE(SUM(m.answer_tokens), 0) AS answer_tokens,
COUNT(mf.id) AS like_count
FROM messages m
LEFT JOIN message_feedbacks mf
ON mf.message_id = m.id AND mf.rating = 'like'
WHERE
{workflow_scope}"""
args: dict[str, Any] = {
"tz": params.timezone,
"tenant_id": app.tenant_id,
"agent_id": agent_id,
"chat_workflow_type": WorkflowType.CHAT,
}
if source_filter.app_id:
args["source_app_id"] = source_filter.app_id
if source_filter.workflow_id:
@@ -627,10 +787,7 @@ WHERE
return [dict(row._mapping) for row in self._session.execute(sa.text(sql_query), args).all()]
@staticmethod
def _statistics_message_scope_sql(source_filter: AgentSourceFilter) -> str:
app_scope = "m.app_id = :app_id"
if source_filter.invoke_from is not None:
app_scope += " AND m.invoke_from = :source"
def _statistics_workflow_binding_filters_sql(source_filter: AgentSourceFilter) -> str:
workflow_binding_filters = []
if source_filter.app_id:
workflow_binding_filters.append("wanb.app_id = :source_app_id")
@@ -640,8 +797,12 @@ WHERE
workflow_binding_filters.append("wanb.workflow_version = :workflow_version")
if source_filter.node_id:
workflow_binding_filters.append("wanb.node_id = :node_id")
extra_workflow_filters = f"AND {' AND '.join(workflow_binding_filters)}" if workflow_binding_filters else ""
workflow_scope = f"""m.workflow_run_id IS NOT NULL
return f"AND {' AND '.join(workflow_binding_filters)}" if workflow_binding_filters else ""
@classmethod
def _statistics_workflow_message_scope_sql(cls, source_filter: AgentSourceFilter) -> str:
binding_filters = cls._statistics_workflow_binding_filters_sql(source_filter)
return f"""m.workflow_run_id IS NOT NULL
AND EXISTS (
SELECT 1
FROM workflow_runs wr
@@ -651,17 +812,65 @@ WHERE
AND wanb.app_id = wr.app_id
AND wanb.workflow_id = wr.workflow_id
AND wanb.workflow_version = wr.version
{extra_workflow_filters}
{binding_filters}
JOIN workflow_node_executions wne
ON wne.workflow_run_id = wr.id
AND wne.node_id = wanb.node_id
WHERE wr.id = m.workflow_run_id
AND wr.type = :chat_workflow_type
)"""
if source_filter.kind == "webapp":
return app_scope
if source_filter.kind == "workflow":
return workflow_scope
return f"(({app_scope}) OR ({workflow_scope}))"
@staticmethod
def _workflow_execution_metadata_numeric_sql(path: tuple[str, ...], numeric_type: str) -> str:
if dify_config.DB_TYPE == "postgresql":
json_path = ",".join(path)
value = f"CAST(wne.execution_metadata AS JSONB) #>> '{{{json_path}}}'"
return f"CAST(NULLIF({value}, '') AS {numeric_type})"
if dify_config.DB_TYPE in {"mysql", "oceanbase", "seekdb"}:
json_path = "$." + ".".join(path)
mysql_numeric_type = "UNSIGNED" if numeric_type == "BIGINT" else numeric_type
value = f"JSON_UNQUOTE(JSON_EXTRACT(wne.execution_metadata, '{json_path}'))"
return f"CAST(NULLIF(NULLIF({value}, ''), 'null') AS {mysql_numeric_type})"
raise NotImplementedError(f"Unsupported database type: {dify_config.DB_TYPE}")
@staticmethod
def _merge_daily_statistics(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
merged: dict[Any, dict[str, Any]] = {}
weighted_latency: dict[Any, float] = {}
for row in rows:
date = row["date"]
target = merged.setdefault(
date,
{
"date": date,
"message_count": 0,
"conversation_count": 0,
"end_user_count": 0,
"token_count": 0,
"total_price": Decimal(0),
"avg_latency": 0.0,
"latency_sum": 0.0,
"answer_tokens": 0,
"like_count": 0,
},
)
message_count = int(row.get("message_count") or 0)
target["message_count"] += message_count
target["conversation_count"] += int(row.get("conversation_count") or 0)
target["end_user_count"] += int(row.get("end_user_count") or 0)
target["token_count"] += int(row.get("token_count") or 0)
target["total_price"] += Decimal(str(row.get("total_price") or 0))
target["latency_sum"] += float(row.get("latency_sum") or 0)
target["answer_tokens"] += int(row.get("answer_tokens") or 0)
target["like_count"] += int(row.get("like_count") or 0)
weighted_latency[date] = (
weighted_latency.get(date, 0.0) + float(row.get("avg_latency") or 0) * message_count
)
for date, row in merged.items():
message_count = int(row["message_count"])
row["avg_latency"] = weighted_latency[date] / message_count if message_count else 0.0
return sorted(merged.values(), key=lambda row: str(row["date"]))
@staticmethod
def _build_charts(rows: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
-13
View File
@@ -317,9 +317,6 @@ _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [
"credential.use",
"credential.create",
"credential.manage",
"billing.view",
"billing.subscription.manage",
"billing.manage",
"app.acl.preview",
"app_library.access",
"app.create_and_management",
@@ -349,9 +346,6 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
"credential.use",
"credential.create",
"credential.manage",
"billing.view",
"billing.subscription.manage",
"billing.manage",
"app_library.access",
"app.create_and_management",
"app.tag.manage",
@@ -377,9 +371,6 @@ _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
"dataset.external.connect",
"snippets.create_and_modify",
"tool.manage",
"billing.view",
"billing.subscription.manage",
"billing.manage",
]
_LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
@@ -387,9 +378,6 @@ _LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
"plugin.install",
"credential.use",
"app_library.access",
"billing.view",
"billing.subscription.manage",
"billing.manage",
]
_LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [
@@ -805,7 +793,6 @@ class RBACService:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/role-permissions/catalog",
params={"billing_enabled": dify_config.BILLING_ENABLED},
tenant_id=tenant_id,
account_id=account_id,
)
+4
View File
@@ -100,6 +100,7 @@ class WebAppAuthModel(FeatureResponseModel):
sso_config: WebAppAuthSSOModel = WebAppAuthSSOModel()
allow_email_code_login: bool = False
allow_email_password_login: bool = False
allow_public_access: bool = True
class KnowledgePipeline(FeatureResponseModel):
@@ -183,6 +184,7 @@ class SystemFeatureModel(FeatureResponseModel):
enable_trial_app: bool = False
enable_explore_banner: bool = False
enable_learn_app: bool = True
enable_step_by_step_tour: bool = False
rbac_enabled: bool = False
@@ -285,6 +287,8 @@ class FeatureService:
system_features.enable_trial_app = dify_config.ENABLE_TRIAL_APP
system_features.enable_explore_banner = dify_config.ENABLE_EXPLORE_BANNER
system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP
system_features.webapp_auth.allow_public_access = dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED
system_features.enable_step_by_step_tour = dify_config.ENABLE_STEP_BY_STEP_TOUR
@classmethod
def _fulfill_trial_models_from_env(cls) -> list[str]:
@@ -601,6 +601,7 @@ class RagPipelineService:
with sessionmaker(bind=db.engine).begin() as session:
draft_var_saver = DraftVariableSaver(
session=session,
tenant_id=pipeline.tenant_id,
app_id=pipeline.id,
node_id=workflow_node_execution.node_id,
node_type=workflow_node_execution.node_type,
@@ -1391,6 +1392,7 @@ class RagPipelineService:
with sessionmaker(bind=db.engine).begin() as session:
draft_var_saver = DraftVariableSaver(
session=session,
tenant_id=pipeline.tenant_id,
app_id=pipeline.id,
node_id=workflow_node_execution_db_model.node_id,
node_type=workflow_node_execution_db_model.node_type,
+221
View File
@@ -0,0 +1,221 @@
"""Account-level Step-by-step Tour persistence."""
from datetime import datetime
from typing import NotRequired, TypedDict
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, scoped_session
from configs import dify_config
from libs.datetime_utils import ensure_naive_utc
from models.account import Account
from models.onboarding import AccountStepByStepTourState
STEP_BY_STEP_TOUR_TASK_IDS = frozenset(("home", "studio", "knowledge", "integration"))
class StepByStepTourStateResponse(TypedDict):
first_workspace_id: str | None
skipped: bool
completed_task_ids: list[str]
manually_enabled_workspace_ids: list[str]
manually_disabled_workspace_ids: list[str]
updated_at: datetime | None
class StepByStepTourPatch(TypedDict):
action: str
task_id: NotRequired[str | None]
class StepByStepTourService:
"""Coordinate persisted tour state with account eligibility rules."""
@classmethod
def get_state(
cls,
*,
account: Account,
current_tenant_id: str,
session: Session | scoped_session,
) -> StepByStepTourStateResponse:
eligible = cls.is_eligible(account)
state = cls._get_state(account.id, session=session)
if eligible:
state = cls._ensure_state(account.id, session=session, state=state)
if state.first_workspace_id is None:
state.first_workspace_id = current_tenant_id
session.commit()
session.refresh(state)
return cls._build_response(state=state)
@classmethod
def patch_state(
cls,
*,
account: Account,
current_tenant_id: str,
patch: StepByStepTourPatch,
session: Session | scoped_session,
) -> StepByStepTourStateResponse:
state = cls._ensure_state(account.id, session=session, state=None)
cls._apply_action(
state=state,
action=patch["action"],
task_id=patch.get("task_id"),
current_tenant_id=current_tenant_id,
)
session.commit()
session.refresh(state)
return cls._build_response(state=state)
@classmethod
def is_eligible(cls, account: Account) -> bool:
if not dify_config.ENABLE_STEP_BY_STEP_TOUR:
return False
rollout_started_at = dify_config.STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT
if rollout_started_at is None:
return False
account_started_at = account.initialized_at or account.created_at
if account_started_at is None:
return False
return ensure_naive_utc(account_started_at) >= ensure_naive_utc(rollout_started_at)
@classmethod
def _get_state(
cls,
account_id: str,
*,
session: Session | scoped_session,
) -> AccountStepByStepTourState | None:
stmt = select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == account_id).limit(1)
return session.execute(stmt).scalar_one_or_none()
@classmethod
def _ensure_state(
cls,
account_id: str,
*,
session: Session | scoped_session,
state: AccountStepByStepTourState | None,
) -> AccountStepByStepTourState:
if state is None:
state = cls._get_state(account_id, session=session)
if state is not None:
return state
state = AccountStepByStepTourState(account_id=account_id)
session.add(state)
try:
session.flush()
except IntegrityError:
# Another tab/device can create the account row between our read and insert.
session.rollback()
state = cls._get_state(account_id, session=session)
if state is None:
raise
return state
@classmethod
def _apply_action(
cls,
*,
state: AccountStepByStepTourState,
action: str,
task_id: str | None,
current_tenant_id: str,
) -> None:
match action:
case "skip":
state.skipped = True
state.manually_enabled_workspace_ids = cls._remove_id(
state.manually_enabled_workspace_ids,
current_tenant_id,
)
case "complete_task":
if task_id is None:
raise ValueError("task_id is required")
cls._validate_task_id(task_id)
state.completed_task_ids = cls._add_id(state.completed_task_ids, task_id)
case "uncomplete_task":
if task_id is None:
raise ValueError("task_id is required")
cls._validate_task_id(task_id)
state.completed_task_ids = cls._remove_id(state.completed_task_ids, task_id)
case "enable_current_workspace":
state.skipped = False
state.manually_enabled_workspace_ids = cls._add_id(
state.manually_enabled_workspace_ids,
current_tenant_id,
)
state.manually_disabled_workspace_ids = cls._remove_id(
state.manually_disabled_workspace_ids,
current_tenant_id,
)
case "disable_current_workspace":
state.manually_enabled_workspace_ids = cls._remove_id(
state.manually_enabled_workspace_ids,
current_tenant_id,
)
state.manually_disabled_workspace_ids = cls._add_id(
state.manually_disabled_workspace_ids,
current_tenant_id,
)
case _:
raise ValueError(f"Unsupported action: {action}")
@classmethod
def _build_response(
cls,
*,
state: AccountStepByStepTourState | None,
) -> StepByStepTourStateResponse:
if state is None:
return {
"first_workspace_id": None,
"skipped": False,
"completed_task_ids": [],
"manually_enabled_workspace_ids": [],
"manually_disabled_workspace_ids": [],
"updated_at": None,
}
return {
"first_workspace_id": state.first_workspace_id,
"skipped": state.skipped,
"completed_task_ids": cls._normalize_ids(state.completed_task_ids),
"manually_enabled_workspace_ids": cls._normalize_ids(state.manually_enabled_workspace_ids),
"manually_disabled_workspace_ids": cls._normalize_ids(state.manually_disabled_workspace_ids),
"updated_at": state.updated_at,
}
@staticmethod
def _validate_task_id(task_id: str) -> None:
if task_id not in STEP_BY_STEP_TOUR_TASK_IDS:
raise ValueError(f"Unsupported task_id: {task_id}")
@classmethod
def _add_id(cls, values: list[str], value: str) -> list[str]:
normalized = cls._normalize_ids(values)
if value in normalized:
return normalized
return [*normalized, value]
@classmethod
def _remove_id(cls, values: list[str], value: str) -> list[str]:
return [item for item in cls._normalize_ids(values) if item != value]
@staticmethod
def _normalize_ids(values: list[str]) -> list[str]:
normalized: list[str] = []
for value in values:
if value not in normalized:
normalized.append(value)
return normalized
+11 -10
View File
@@ -823,6 +823,8 @@ _FILENAME_TRANS_TABLE = _make_filename_trans_table()
class DraftVariableSaver:
"""Persist draft outputs under the tenant that owns the app or pipeline."""
# _DUMMY_OUTPUT_IDENTITY is a placeholder output for workflow nodes.
# Its sole possible value is `None`.
#
@@ -842,6 +844,10 @@ class DraftVariableSaver:
# Database session used for persisting draft variables.
_session: Session
# Resource owner tenant. An account's current tenant may be unset or point elsewhere
# when draft variables are persisted by an asynchronous workflow execution.
_tenant_id: str
# The application ID associated with the draft variables.
# This should match the `Workflow.app_id` of the workflow to which the current node belongs.
_app_id: str
@@ -867,6 +873,7 @@ class DraftVariableSaver:
def __init__(
self,
session: Session,
tenant_id: str,
app_id: str,
node_id: str,
node_type: NodeType,
@@ -878,6 +885,7 @@ class DraftVariableSaver:
# WorkflowNodeExecutionModel/WorkflowNodeExecution, not their `node_execution_id`
# field. These are distinct database fields with different purposes.
self._session = session
self._tenant_id = tenant_id
self._app_id = app_id
self._node_id = node_id
self._node_type = node_type
@@ -885,12 +893,6 @@ class DraftVariableSaver:
self._user = user
self._enclosing_node_id = enclosing_node_id
def _resolve_app_tenant_id(self) -> str:
tenant_id = self._session.scalar(select(App.tenant_id).where(App.id == self._app_id))
if not tenant_id:
raise ValueError(f"Unable to resolve tenant_id for app {self._app_id}")
return tenant_id
def _create_dummy_output_variable(self):
return WorkflowDraftVariable.new_node_variable(
app_id=self._app_id,
@@ -949,11 +951,10 @@ class DraftVariableSaver:
if name == SystemVariableKey.FILES:
# Here we know the type of variable must be `array[file]`, we
# just rebuild files from the serialized payload.
tenant_id = self._resolve_app_tenant_id()
files = [
build_file_from_stored_mapping(
file_mapping=v,
tenant_id=tenant_id,
tenant_id=self._tenant_id,
)
for v in value
]
@@ -1096,8 +1097,8 @@ class DraftVariableSaver:
content=original_content_serialized.encode(),
mimetype=content_type,
user=self._user,
tenant_id=self._tenant_id,
)
assert self._user.current_tenant_id
# Create WorkflowDraftVariableFile record
variable_file = WorkflowDraftVariableFile(
upload_file_id=upload_file.id,
@@ -1105,7 +1106,7 @@ class DraftVariableSaver:
length=original_length,
value_type=value_seg.value_type,
app_id=self._app_id,
tenant_id=self._user.current_tenant_id,
tenant_id=self._tenant_id,
user_id=self._user.id,
)
variable_file.id = str(uuidv7())
+90 -11
View File
@@ -13,6 +13,7 @@ from sqlalchemy import desc, select
from sqlalchemy.orm import Session, sessionmaker
from core.app.apps.message_generator import MessageGenerator
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity
from core.app.entities.task_entities import (
HumanInputRequiredResponse,
MessageReplaceStreamResponse,
@@ -84,9 +85,6 @@ def build_workflow_event_stream(
topic = MessageGenerator.get_response_topic(app_mode, workflow_run.id)
workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker)
node_execution_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(session_maker)
message_context = (
_get_message_context(session_maker, workflow_run.id) if app_mode == AppMode.ADVANCED_CHAT else None
)
pause_entity: WorkflowPauseEntity | None = None
if workflow_run.status == WorkflowExecutionStatus.PAUSED:
@@ -97,6 +95,38 @@ def build_workflow_event_stream(
pause_entity = None
resumption_context = _load_resumption_context(pause_entity)
message_context: MessageContext | None = None
if app_mode == AppMode.ADVANCED_CHAT:
if workflow_run.status == WorkflowExecutionStatus.PAUSED:
if resumption_context is None:
raise AssertionError(
"WorkflowResumptionContext is required for advanced-chat snapshot replay, "
f"workflow_run_id={workflow_run.id}"
)
generate_entity = resumption_context.get_generate_entity()
if not isinstance(generate_entity, AdvancedChatAppGenerateEntity):
raise AssertionError(
"AdvancedChatAppGenerateEntity is required for advanced-chat snapshot replay, "
f"workflow_run_id={workflow_run.id}, generate_entity_type={type(generate_entity).__name__}"
)
if not generate_entity.conversation_id:
raise AssertionError(
f"conversation_id is required for advanced-chat snapshot replay, workflow_run_id={workflow_run.id}"
)
message_context = _get_message_context_by_conversation(
session_maker,
conversation_id=generate_entity.conversation_id,
workflow_run_id=workflow_run.id,
)
else:
# Compatibility fallback for non-suspended snapshot requests. This app-scoped lookup is not optimal;
# a dedicated index or stronger lookup key would be preferable.
message_context = _get_message_context_by_app(
session_maker,
app_id=app_id,
workflow_run_id=workflow_run.id,
)
node_snapshots = node_execution_repo.get_execution_snapshots_by_workflow_run(
tenant_id=tenant_id,
app_id=app_id,
@@ -175,19 +205,68 @@ def build_workflow_event_stream(
return _generate()
def _get_message_context(session_maker: sessionmaker[Session], workflow_run_id: str) -> MessageContext | None:
def _get_message_context_by_conversation(
session_maker: sessionmaker[Session],
*,
conversation_id: str,
workflow_run_id: str,
) -> MessageContext | None:
"""Look up a paused or suspended Advanced Chat snapshot message by conversation and workflow run.
Use this exact lookup after recovering ``conversation_id`` from persisted resumption context. Its predicates match
``message_workflow_run_id_idx``.
"""
with session_maker() as session:
stmt = select(Message).where(Message.workflow_run_id == workflow_run_id).order_by(desc(Message.created_at))
stmt = (
select(Message)
.where(
Message.conversation_id == conversation_id,
Message.workflow_run_id == workflow_run_id,
)
.order_by(desc(Message.created_at))
.limit(1)
)
message = session.scalar(stmt)
if message is None:
return None
created_at = int(message.created_at.timestamp()) if message.created_at else 0
return MessageContext(
conversation_id=message.conversation_id,
message_id=message.id,
created_at=created_at,
answer=message.answer,
return _to_message_context(message)
def _get_message_context_by_app(
session_maker: sessionmaker[Session],
*,
app_id: str,
workflow_run_id: str,
) -> MessageContext | None:
"""Look up a non-suspended or running Advanced Chat reconnect snapshot by app and workflow run.
This compatibility path applies only when no resumption context is expected. The app-scoped query is not optimal;
a dedicated index or stronger lookup key would be preferable.
"""
with session_maker() as session:
stmt = (
select(Message)
.where(
Message.app_id == app_id,
Message.workflow_run_id == workflow_run_id,
)
.order_by(desc(Message.created_at))
.limit(1)
)
message = session.scalar(stmt)
if message is None:
return None
return _to_message_context(message)
def _to_message_context(message: Message) -> MessageContext:
created_at = int(message.created_at.timestamp()) if message.created_at else 0
return MessageContext(
conversation_id=message.conversation_id,
message_id=message.id,
created_at=created_at,
answer=message.answer,
)
def _load_resumption_context(pause_entity: WorkflowPauseEntity | None) -> WorkflowResumptionContext | None:
+2
View File
@@ -1060,6 +1060,7 @@ class WorkflowService:
with sessionmaker(bind=db.engine).begin() as session:
draft_var_saver = DraftVariableSaver(
session=session,
tenant_id=app_model.tenant_id,
app_id=app_model.id,
node_id=workflow_node_execution.node_id,
node_type=workflow_node_execution.node_type,
@@ -1210,6 +1211,7 @@ class WorkflowService:
with sessionmaker(bind=db.engine).begin() as session:
draft_var_saver = DraftVariableSaver(
session=session,
tenant_id=app_model.tenant_id,
app_id=app_model.id,
node_id=node_id,
node_type=BuiltinNodeTypes.HUMAN_INPUT,
@@ -311,6 +311,7 @@ class TestDraftVariableLoader(unittest.TestCase):
# Use DraftVariableSaver to create offloaded variable (this mimics production)
saver = DraftVariableSaver(
session=session,
tenant_id=self._test_tenant_id,
app_id=self._test_app_id,
node_id="test_offload_node",
node_type=BuiltinNodeTypes.LLM, # Use a real node type
@@ -1,179 +0,0 @@
"""Testcontainers integration tests for controllers.console.app.app_import endpoints."""
from __future__ import annotations
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from flask import Flask
from controllers.console.app import app_import as app_import_module
from services.app_dsl_service import ImportStatus
class _Result:
def __init__(self, status: ImportStatus, app_id: str | None = "app-1"):
self.status = status
self.app_id = app_id
def model_dump(self, mode: str = "json"):
return {"status": self.status, "app_id": self.app_id}
def _install_features(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None:
features = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=enabled))
monkeypatch.setattr(app_import_module.FeatureService, "get_system_features", lambda: features)
class TestAppImportApi:
@pytest.fixture
def app(self, flask_app_with_containers: Flask):
return flask_app_with_containers
def test_import_post_returns_failed_status(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = app_import_module.AppImportApi()
method = unwrap(api.post)
_install_features(monkeypatch, enabled=False)
monkeypatch.setattr(
app_import_module.AppDslService,
"import_app",
lambda *_args, **_kwargs: _Result(ImportStatus.FAILED, app_id=None),
)
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
response, status = method(api, SimpleNamespace(id="u1"))
assert status == 400
assert response["status"] == ImportStatus.FAILED
def test_import_post_returns_pending_status(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = app_import_module.AppImportApi()
method = unwrap(api.post)
_install_features(monkeypatch, enabled=False)
monkeypatch.setattr(
app_import_module.AppDslService,
"import_app",
lambda *_args, **_kwargs: _Result(ImportStatus.PENDING),
)
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
response, status = method(api, SimpleNamespace(id="u1"))
assert status == 202
assert response["status"] == ImportStatus.PENDING
def test_import_post_updates_webapp_auth_when_enabled(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = app_import_module.AppImportApi()
method = unwrap(api.post)
_install_features(monkeypatch, enabled=True)
monkeypatch.setattr(
app_import_module.AppDslService,
"import_app",
lambda *_args, **_kwargs: _Result(ImportStatus.COMPLETED, app_id="app-123"),
)
update_access = MagicMock()
monkeypatch.setattr(app_import_module.EnterpriseService.WebAppAuth, "update_app_access_mode", update_access)
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
response, status = method(api, SimpleNamespace(id="u1"))
update_access.assert_called_once_with("app-123", "private")
assert status == 200
assert response["status"] == ImportStatus.COMPLETED
def test_import_post_commits_session_on_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = app_import_module.AppImportApi()
method = unwrap(api.post)
_install_features(monkeypatch, enabled=False)
monkeypatch.setattr(
app_import_module.AppDslService,
"import_app",
lambda *_args, **_kwargs: _Result(ImportStatus.COMPLETED, app_id="app-123"),
)
fake_session = MagicMock()
fake_session.__enter__.return_value = fake_session
fake_session.__exit__.return_value = None
monkeypatch.setattr(app_import_module, "Session", lambda *_args, **_kwargs: fake_session)
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
response, status = method(api, SimpleNamespace(id="u1"))
fake_session.commit.assert_called_once_with()
fake_session.rollback.assert_not_called()
assert status == 200
assert response["status"] == ImportStatus.COMPLETED
def test_import_post_rolls_back_session_on_failure(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = app_import_module.AppImportApi()
method = unwrap(api.post)
_install_features(monkeypatch, enabled=False)
monkeypatch.setattr(
app_import_module.AppDslService,
"import_app",
lambda *_args, **_kwargs: _Result(ImportStatus.FAILED, app_id=None),
)
fake_session = MagicMock()
fake_session.__enter__.return_value = fake_session
fake_session.__exit__.return_value = None
monkeypatch.setattr(app_import_module, "Session", lambda *_args, **_kwargs: fake_session)
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
response, status = method(api, SimpleNamespace(id="u1"))
fake_session.rollback.assert_called_once_with()
fake_session.commit.assert_not_called()
assert status == 400
assert response["status"] == ImportStatus.FAILED
class TestAppImportConfirmApi:
@pytest.fixture
def app(self, flask_app_with_containers: Flask):
return flask_app_with_containers
def test_import_confirm_returns_failed_status(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = app_import_module.AppImportConfirmApi()
method = unwrap(api.post)
monkeypatch.setattr(
app_import_module.AppDslService,
"confirm_import",
lambda *_args, **_kwargs: _Result(ImportStatus.FAILED),
)
with app.test_request_context("/console/api/apps/imports/import-1/confirm", method="POST"):
response, status = method(api, SimpleNamespace(id="u1"), import_id="import-1")
assert status == 400
assert response["status"] == ImportStatus.FAILED
class TestAppImportCheckDependenciesApi:
@pytest.fixture
def app(self, flask_app_with_containers: Flask):
return flask_app_with_containers
def test_import_check_dependencies_returns_result(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = app_import_module.AppImportCheckDependenciesApi()
method = unwrap(api.get)
monkeypatch.setattr(
app_import_module.AppDslService,
"check_dependencies",
lambda *_args, **_kwargs: SimpleNamespace(model_dump=lambda mode="json": {"leaked_dependencies": []}),
)
with app.test_request_context("/console/api/apps/imports/app-1/check-dependencies", method="GET"):
response, status = method(api, app_model=SimpleNamespace(id="app-1"))
assert status == 200
assert response["leaked_dependencies"] == []
@@ -8,8 +8,13 @@ from yarl import URL
from configs.app_config import DifyConfig
def _clear_environment(monkeypatch: pytest.MonkeyPatch) -> None:
for name in tuple(os.environ):
monkeypatch.delenv(name)
def _set_basic_config_env(monkeypatch: pytest.MonkeyPatch) -> None:
os.environ.clear()
_clear_environment(monkeypatch)
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
monkeypatch.setenv("DB_TYPE", "postgresql")
@@ -51,7 +56,7 @@ def test_dify_config_preserves_explicit_secret_key(
def test_dify_config(monkeypatch: pytest.MonkeyPatch):
# clear system environment variables
os.environ.clear()
_clear_environment(monkeypatch)
# Set environment variables using monkeypatch
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
@@ -104,6 +109,24 @@ def test_new_user_default_plugin_ids_are_parsed_from_env(monkeypatch: pytest.Mon
]
def test_plugin_remote_install_port_rejects_host_port_spec(monkeypatch: pytest.MonkeyPatch) -> None:
"""A 'host:port' compose publish spec must produce an actionable error, not an opaque int_parsing traceback."""
_set_basic_config_env(monkeypatch)
monkeypatch.setenv("PLUGIN_REMOTE_INSTALL_PORT", "127.0.0.1:5003")
with pytest.raises(ValueError, match="must be a bare port number"):
DifyConfig(_env_file=None)
def test_plugin_remote_install_port_accepts_bare_port(monkeypatch: pytest.MonkeyPatch) -> None:
_set_basic_config_env(monkeypatch)
monkeypatch.setenv("PLUGIN_REMOTE_INSTALL_PORT", "5003")
config = DifyConfig(_env_file=None)
assert config.PLUGIN_REMOTE_INSTALL_PORT == 5003
def test_new_user_default_models_are_parsed_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
_set_basic_config_env(monkeypatch)
monkeypatch.setenv(
@@ -140,7 +163,7 @@ def test_new_user_default_models_reject_duplicate_model_types(monkeypatch: pytes
def test_http_timeout_defaults(monkeypatch: pytest.MonkeyPatch):
"""Test that HTTP timeout defaults are correctly set"""
# clear system environment variables
os.environ.clear()
_clear_environment(monkeypatch)
# Set minimal required env vars
monkeypatch.setenv("DB_TYPE", "postgresql")
@@ -160,7 +183,7 @@ def test_http_timeout_defaults(monkeypatch: pytest.MonkeyPatch):
def test_internal_files_url_falls_back_to_server_console_api_url(monkeypatch: pytest.MonkeyPatch):
os.environ.clear()
_clear_environment(monkeypatch)
monkeypatch.setenv("SERVER_CONSOLE_API_URL", "http://api:5001")
config = DifyConfig(_env_file=None)
@@ -169,7 +192,7 @@ def test_internal_files_url_falls_back_to_server_console_api_url(monkeypatch: py
def test_internal_files_url_prefers_explicit_value(monkeypatch: pytest.MonkeyPatch):
os.environ.clear()
_clear_environment(monkeypatch)
monkeypatch.setenv("INTERNAL_FILES_URL", "http://files-internal:5001")
monkeypatch.setenv("SERVER_CONSOLE_API_URL", "http://api:5001")
@@ -183,7 +206,7 @@ def test_internal_files_url_prefers_explicit_value(monkeypatch: pytest.MonkeyPat
def test_flask_configs(monkeypatch: pytest.MonkeyPatch):
flask_app = Flask("app")
# clear system environment variables
os.environ.clear()
_clear_environment(monkeypatch)
# Set environment variables using monkeypatch
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
@@ -290,7 +313,7 @@ def test_db_session_timezone_override_can_disable_app_level_timezone_injection(m
def test_pubsub_redis_url_default(monkeypatch: pytest.MonkeyPatch):
os.environ.clear()
_clear_environment(monkeypatch)
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
@@ -313,7 +336,7 @@ def test_pubsub_redis_url_default(monkeypatch: pytest.MonkeyPatch):
def test_pubsub_redis_url_override(monkeypatch: pytest.MonkeyPatch):
os.environ.clear()
_clear_environment(monkeypatch)
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
@@ -330,7 +353,7 @@ def test_pubsub_redis_url_override(monkeypatch: pytest.MonkeyPatch):
def test_pubsub_redis_url_required_when_default_unavailable(monkeypatch: pytest.MonkeyPatch):
os.environ.clear()
_clear_environment(monkeypatch)
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
@@ -346,7 +369,7 @@ def test_pubsub_redis_url_required_when_default_unavailable(monkeypatch: pytest.
def test_dify_config_exposes_redis_key_prefix_default(monkeypatch: pytest.MonkeyPatch):
os.environ.clear()
_clear_environment(monkeypatch)
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
@@ -363,7 +386,7 @@ def test_dify_config_exposes_redis_key_prefix_default(monkeypatch: pytest.Monkey
def test_dify_config_reads_redis_key_prefix_from_env(monkeypatch: pytest.MonkeyPatch):
os.environ.clear()
_clear_environment(monkeypatch)
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
@@ -415,7 +438,7 @@ def test_celery_broker_url_with_special_chars_password(
from kombu.utils.url import parse_url
# clear system environment variables
os.environ.clear()
_clear_environment(monkeypatch)
# Set up basic required environment variables (following existing pattern)
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
@@ -1,23 +1,82 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
from typing import Any
from uuid import NAMESPACE_URL, uuid5
import pytest
from sqlalchemy.orm import Session
from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
from models.model import AppAnnotationSetting
def _app_model(*, bound_agent_id: str | None, app_model_config=None):
def _stable_uuid(value: str) -> str:
return str(uuid5(NAMESPACE_URL, value))
def _app_model(*, tenant_id: str, bound_agent_id: str | None, app_model_config: object | None = None):
return SimpleNamespace(
id="app-1",
tenant_id="tenant-1",
id=_stable_uuid(f"app:{tenant_id}"),
tenant_id=tenant_id,
bound_agent_id=bound_agent_id,
app_model_config_with_session=lambda *, session: app_model_config,
)
def test_published_agent_app_parameters_use_soul_file_upload():
def _persist_agent(
session: Session,
*,
tenant_id: str,
agent_id: str,
active_config_snapshot_id: str | None,
active_config_is_published: bool,
) -> Agent:
agent = Agent(
id=agent_id,
tenant_id=tenant_id,
name="Agent",
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
active_config_snapshot_id=active_config_snapshot_id,
active_config_is_published=active_config_is_published,
)
session.add(agent)
session.commit()
return agent
def _persist_snapshot(
session: Session,
*,
snapshot_id: str,
tenant_id: str,
agent_id: str,
config_snapshot: dict[str, Any],
) -> AgentConfigSnapshot:
snapshot = AgentConfigSnapshot(
id=snapshot_id,
tenant_id=tenant_id,
agent_id=agent_id,
version=1,
config_snapshot=config_snapshot,
)
session.add(snapshot)
session.commit()
return snapshot
@pytest.mark.parametrize(
"sqlite_session",
[(Agent, AgentConfigSnapshot, AppAnnotationSetting)],
indirect=True,
)
def test_published_agent_app_parameters_use_soul_file_upload(sqlite_session: Session):
tenant_id = _stable_uuid("tenant:one")
agent_id = _stable_uuid("agent:one")
snapshot_id = _stable_uuid("snapshot:one")
app_model_config = SimpleNamespace(
to_dict=lambda **_kwargs: {
"opening_statement": "Hi from legacy presentation config",
@@ -27,14 +86,24 @@ def test_published_agent_app_parameters_use_soul_file_upload():
},
}
)
app_model = _app_model(bound_agent_id="agent-1", app_model_config=app_model_config)
agent = SimpleNamespace(
id="agent-1",
active_config_snapshot_id="snapshot-1",
app_model = _app_model(
tenant_id=tenant_id,
bound_agent_id=agent_id,
app_model_config=app_model_config,
)
_persist_agent(
sqlite_session,
tenant_id=tenant_id,
agent_id=agent_id,
active_config_snapshot_id=snapshot_id,
active_config_is_published=True,
)
snapshot = SimpleNamespace(
config_snapshot_dict={
_persist_snapshot(
sqlite_session,
snapshot_id=snapshot_id,
tenant_id=tenant_id,
agent_id=agent_id,
config_snapshot={
"app_features": {
"file_upload": {
"enabled": True,
@@ -46,14 +115,12 @@ def test_published_agent_app_parameters_use_soul_file_upload():
}
},
"app_variables": [{"name": "topic", "type": "string", "required": True}],
}
},
)
session = MagicMock()
session.scalar.side_effect = [agent, snapshot, None]
features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(
app_model,
session=session,
session=sqlite_session,
)
parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
@@ -69,20 +136,30 @@ def test_published_agent_app_parameters_use_soul_file_upload():
assert parameters["user_input_form"] == [{"text-input": {"label": "topic", "variable": "topic", "required": True}}]
def test_published_agent_app_parameters_requires_bound_agent():
app_model = _app_model(bound_agent_id=None)
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True)
def test_published_agent_app_parameters_requires_bound_agent(sqlite_session: Session):
tenant_id = _stable_uuid("tenant:unbound")
app_model = _app_model(tenant_id=tenant_id, bound_agent_id=None)
with pytest.raises(AgentAppGeneratorError, match="no bound Agent"):
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=MagicMock())
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session)
def test_published_agent_app_parameters_requires_existing_active_agent():
app_model = _app_model(bound_agent_id="agent-1")
session = MagicMock()
session.scalar.return_value = None
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True)
def test_published_agent_app_parameters_requires_existing_active_agent(sqlite_session: Session):
requested_tenant_id = _stable_uuid("tenant:requested")
agent_id = _stable_uuid("agent:cross-tenant")
app_model = _app_model(tenant_id=requested_tenant_id, bound_agent_id=agent_id)
_persist_agent(
sqlite_session,
tenant_id=_stable_uuid("tenant:other"),
agent_id=agent_id,
active_config_snapshot_id=None,
active_config_is_published=False,
)
with pytest.raises(AgentAppGeneratorError, match="no bound Agent"):
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=session)
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session)
@pytest.mark.parametrize(
@@ -92,68 +169,96 @@ def test_published_agent_app_parameters_requires_existing_active_agent():
False,
],
)
def test_published_agent_app_parameters_requires_published_agent(active_config_is_published):
app_model = _app_model(bound_agent_id="agent-1")
agent = SimpleNamespace(
id="agent-1",
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True)
def test_published_agent_app_parameters_requires_published_agent(
active_config_is_published: bool, sqlite_session: Session
):
tenant_id = _stable_uuid(f"tenant:published:{active_config_is_published}")
agent_id = _stable_uuid(f"agent:published:{active_config_is_published}")
app_model = _app_model(tenant_id=tenant_id, bound_agent_id=agent_id)
_persist_agent(
sqlite_session,
tenant_id=tenant_id,
agent_id=agent_id,
active_config_snapshot_id=None,
active_config_is_published=active_config_is_published,
)
session = MagicMock()
session.scalar.return_value = agent
with pytest.raises(AgentAppNotPublishedError, match="not been published"):
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=session)
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session)
def test_published_agent_app_parameters_allows_unpublished_draft_with_active_snapshot():
app_model = _app_model(bound_agent_id="agent-1")
agent = SimpleNamespace(
id="agent-1",
active_config_snapshot_id="snapshot-1",
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True)
def test_published_agent_app_parameters_allows_unpublished_draft_with_active_snapshot(sqlite_session: Session):
tenant_id = _stable_uuid("tenant:unpublished-draft")
agent_id = _stable_uuid("agent:unpublished-draft")
snapshot_id = _stable_uuid("snapshot:unpublished-draft")
app_model = _app_model(tenant_id=tenant_id, bound_agent_id=agent_id)
_persist_agent(
sqlite_session,
tenant_id=tenant_id,
agent_id=agent_id,
active_config_snapshot_id=snapshot_id,
active_config_is_published=False,
)
snapshot = SimpleNamespace(config_snapshot_dict={})
session = MagicMock()
session.scalar.side_effect = [agent, snapshot]
_persist_snapshot(
sqlite_session,
snapshot_id=snapshot_id,
tenant_id=tenant_id,
agent_id=agent_id,
config_snapshot={},
)
features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(
app_model,
session=session,
session=sqlite_session,
)
assert features_dict["file_upload"]["enabled"] is True
assert user_input_form == []
def test_published_agent_app_parameters_requires_published_snapshot():
app_model = _app_model(bound_agent_id="agent-1")
agent = SimpleNamespace(
id="agent-1",
active_config_snapshot_id="snapshot-1",
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True)
def test_published_agent_app_parameters_requires_published_snapshot(sqlite_session: Session):
tenant_id = _stable_uuid("tenant:missing-snapshot")
agent_id = _stable_uuid("agent:missing-snapshot")
app_model = _app_model(tenant_id=tenant_id, bound_agent_id=agent_id)
_persist_agent(
sqlite_session,
tenant_id=tenant_id,
agent_id=agent_id,
active_config_snapshot_id=_stable_uuid("snapshot:missing"),
active_config_is_published=True,
)
session = MagicMock()
session.scalar.side_effect = [agent, None]
with pytest.raises(AgentAppGeneratorError, match="published version not found"):
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=session)
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session)
def test_published_agent_app_parameters_allows_missing_legacy_app_model_config():
app_model = _app_model(bound_agent_id="agent-1")
agent = SimpleNamespace(
id="agent-1",
active_config_snapshot_id="snapshot-1",
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True)
def test_published_agent_app_parameters_allows_missing_legacy_app_model_config(sqlite_session: Session):
tenant_id = _stable_uuid("tenant:no-legacy-config")
agent_id = _stable_uuid("agent:no-legacy-config")
snapshot_id = _stable_uuid("snapshot:no-legacy-config")
app_model = _app_model(tenant_id=tenant_id, bound_agent_id=agent_id)
_persist_agent(
sqlite_session,
tenant_id=tenant_id,
agent_id=agent_id,
active_config_snapshot_id=snapshot_id,
active_config_is_published=True,
)
snapshot = SimpleNamespace(config_snapshot_dict={})
session = MagicMock()
session.scalar.side_effect = [agent, snapshot]
_persist_snapshot(
sqlite_session,
snapshot_id=snapshot_id,
tenant_id=tenant_id,
agent_id=agent_id,
config_snapshot={},
)
features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(
app_model,
session=session,
session=sqlite_session,
)
assert features_dict["file_upload"] == {
@@ -2,15 +2,23 @@
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from flask import Flask
from sqlalchemy import event
from sqlalchemy.orm import Session
from controllers.console.app import app_import as app_import_module
from models.account import Account
from models.engine import db
from models.model import App
from services.app_dsl_service import ImportStatus
from services.entities.dsl_entities import CheckDependenciesResult
from services.feature_service import SystemFeatureModel, WebAppAuthModel
def _unwrap(func):
@@ -38,17 +46,58 @@ class _Result:
def _install_features(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None:
features = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=enabled))
features = SystemFeatureModel(webapp_auth=WebAppAuthModel(enabled=enabled))
monkeypatch.setattr(app_import_module.FeatureService, "get_system_features", lambda: features)
def _mock_session(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
fake_session = MagicMock()
fake_session.__enter__.return_value = fake_session
fake_session.__exit__.return_value = None
monkeypatch.setattr(app_import_module, "db", SimpleNamespace(engine=object()))
monkeypatch.setattr(app_import_module, "Session", lambda *_args, **_kwargs: fake_session)
return fake_session
def _make_account(account_id: str = "u1") -> Account:
account = Account(name="Test User", email="test@example.com")
account.id = account_id
return account
@pytest.fixture
def app() -> Iterator[Flask]:
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
db.init_app(app)
with app.app_context():
yield app
@dataclass
class TransactionEvents:
commits: int = 0
rollbacks: int = 0
@pytest.fixture
def transaction_events() -> TransactionEvents:
"""Observe transaction decisions while keeping the controller on a real SQLAlchemy session."""
observed = TransactionEvents()
def record_commit(_session: Session) -> None:
observed.commits += 1
def record_rollback(_session: Session) -> None:
observed.rollbacks += 1
event.listen(Session, "after_commit", record_commit)
event.listen(Session, "after_rollback", record_rollback)
try:
yield observed
finally:
event.remove(Session, "after_commit", record_commit)
event.remove(Session, "after_rollback", record_rollback)
def _failed_result_after_starting_transaction(
service: app_import_module.AppDslService, *, app_id: str | None = None
) -> _Result:
service._session.begin()
return _Result(ImportStatus.FAILED, app_id=app_id)
class TestAppImportApi:
@@ -57,33 +106,39 @@ class TestAppImportApi:
return app_import_module.AppImportApi()
def test_import_post_returns_failed_status_and_rolls_back(
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
self,
api,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
transaction_events: TransactionEvents,
) -> None:
method = unwrap(api.post)
_install_features(monkeypatch, enabled=False)
session = _mock_session(monkeypatch)
monkeypatch.setattr(
app_import_module.AppDslService,
"import_app",
lambda *_args, **_kwargs: _Result(ImportStatus.FAILED, app_id=None),
lambda service, *_args, **_kwargs: _failed_result_after_starting_transaction(service, app_id=None),
)
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
response, status = method(api, SimpleNamespace(id="u1"))
response, status = method(api, _make_account())
session.rollback.assert_called_once_with()
session.commit.assert_not_called()
assert transaction_events.rollbacks == 1
assert transaction_events.commits == 0
assert status == 400
assert response["status"] == ImportStatus.FAILED
def test_import_post_returns_pending_status_and_commits(
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
self,
api,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
transaction_events: TransactionEvents,
) -> None:
method = unwrap(api.post)
_install_features(monkeypatch, enabled=False)
session = _mock_session(monkeypatch)
monkeypatch.setattr(
app_import_module.AppDslService,
"import_app",
@@ -91,20 +146,23 @@ class TestAppImportApi:
)
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
response, status = method(api, SimpleNamespace(id="u1"))
response, status = method(api, _make_account())
session.commit.assert_called_once_with()
session.rollback.assert_not_called()
assert transaction_events.commits == 1
assert transaction_events.rollbacks == 0
assert status == 202
assert response["status"] == ImportStatus.PENDING
def test_import_post_updates_webapp_auth_when_enabled(
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
self,
api,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
transaction_events: TransactionEvents,
) -> None:
method = unwrap(api.post)
_install_features(monkeypatch, enabled=True)
session = _mock_session(monkeypatch)
monkeypatch.setattr(
app_import_module.AppDslService,
"import_app",
@@ -114,25 +172,28 @@ class TestAppImportApi:
monkeypatch.setattr(app_import_module.EnterpriseService.WebAppAuth, "update_app_access_mode", update_access)
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
response, status = method(api, SimpleNamespace(id="u1"))
response, status = method(api, _make_account())
session.commit.assert_called_once_with()
session.rollback.assert_not_called()
assert transaction_events.commits == 1
assert transaction_events.rollbacks == 0
update_access.assert_called_once_with("app-123", "private")
assert status == 200
assert response["status"] == ImportStatus.COMPLETED
def test_import_post_attaches_permission_keys_when_creating_new_app_and_rbac_enabled(
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
self,
api,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
transaction_events: TransactionEvents,
) -> None:
method = _unwrap(api.post)
_install_features(monkeypatch, enabled=False)
session = _mock_session(monkeypatch)
monkeypatch.setattr(
app_import_module,
"current_account_with_tenant",
lambda: (SimpleNamespace(id="u1"), "tenant-1"),
lambda: (_make_account(), "tenant-1"),
)
monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True)
monkeypatch.setattr(
@@ -149,21 +210,24 @@ class TestAppImportApi:
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
response, status = method()
session.commit.assert_called_once_with()
assert transaction_events.commits == 1
assert status == 200
assert response["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"]
def test_import_post_does_not_attach_permission_keys_when_overwriting_existing_app(
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
self,
api,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
transaction_events: TransactionEvents,
) -> None:
method = _unwrap(api.post)
_install_features(monkeypatch, enabled=False)
session = _mock_session(monkeypatch)
monkeypatch.setattr(
app_import_module,
"current_account_with_tenant",
lambda: (SimpleNamespace(id="u1"), "tenant-1"),
lambda: (_make_account(), "tenant-1"),
)
monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True)
monkeypatch.setattr(
@@ -184,7 +248,7 @@ class TestAppImportApi:
):
response, status = method()
session.commit.assert_called_once_with()
assert transaction_events.commits == 1
assert status == 200
assert response["permission_keys"] == []
@@ -195,35 +259,41 @@ class TestAppImportConfirmApi:
return app_import_module.AppImportConfirmApi()
def test_import_confirm_returns_failed_status_and_rolls_back(
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
self,
api,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
transaction_events: TransactionEvents,
) -> None:
method = unwrap(api.post)
session = _mock_session(monkeypatch)
monkeypatch.setattr(
app_import_module.AppDslService,
"confirm_import",
lambda *_args, **_kwargs: _Result(ImportStatus.FAILED),
lambda service, *_args, **_kwargs: _failed_result_after_starting_transaction(service),
)
with app.test_request_context("/console/api/apps/imports/import-1/confirm", method="POST"):
response, status = method(api, SimpleNamespace(id="u1"), import_id="import-1")
response, status = method(api, _make_account(), import_id="import-1")
session.rollback.assert_called_once_with()
session.commit.assert_not_called()
assert transaction_events.rollbacks == 1
assert transaction_events.commits == 0
assert status == 400
assert response["status"] == ImportStatus.FAILED
def test_import_confirm_attaches_permission_keys_when_creating_new_app_and_rbac_enabled(
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
self,
api,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
transaction_events: TransactionEvents,
) -> None:
method = _unwrap(api.post)
session = _mock_session(monkeypatch)
monkeypatch.setattr(
app_import_module,
"current_account_with_tenant",
lambda: (SimpleNamespace(id="u1"), "tenant-1"),
lambda: (_make_account(), "tenant-1"),
)
monkeypatch.setattr(
app_import_module.redis_client,
@@ -248,20 +318,23 @@ class TestAppImportConfirmApi:
with app.test_request_context("/console/api/apps/imports/import-1/confirm", method="POST"):
response, status = method(import_id="import-1")
session.commit.assert_called_once_with()
assert transaction_events.commits == 1
assert status == 200
assert response["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"]
def test_import_confirm_does_not_attach_permission_keys_when_overwriting_existing_app(
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
self,
api,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
transaction_events: TransactionEvents,
) -> None:
method = _unwrap(api.post)
session = _mock_session(monkeypatch)
monkeypatch.setattr(
app_import_module,
"current_account_with_tenant",
lambda: (SimpleNamespace(id="u1"), "tenant-1"),
lambda: (_make_account(), "tenant-1"),
)
monkeypatch.setattr(
app_import_module.redis_client,
@@ -286,6 +359,27 @@ class TestAppImportConfirmApi:
with app.test_request_context("/console/api/apps/imports/import-1/confirm", method="POST"):
response, status = method(import_id="import-1")
session.commit.assert_called_once_with()
assert transaction_events.commits == 1
assert status == 200
assert response["permission_keys"] == []
class TestAppImportCheckDependenciesApi:
def test_import_check_dependencies_returns_result(
self,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
) -> None:
api = app_import_module.AppImportCheckDependenciesApi()
method = unwrap(api.get)
monkeypatch.setattr(
app_import_module.AppDslService,
"check_dependencies",
lambda *_args, **_kwargs: CheckDependenciesResult(leaked_dependencies=[]),
)
with app.test_request_context("/console/api/apps/imports/app-1/check-dependencies", method="GET"):
response, status = method(api, app_model=App(id="app-1"))
assert status == 200
assert response["leaked_dependencies"] == []
@@ -7,10 +7,28 @@ from unittest.mock import MagicMock
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from controllers.console.app import generator as generator_module
from controllers.console.app.error import ProviderNotInitializeError
from core.errors.error import ProviderTokenNotInitError
from models.model import App, AppMode
def _persist_app(session: Session, *, tenant_id: str = "t1") -> App:
app_model = App(
id="app-1",
tenant_id=tenant_id,
name="Workflow App",
description="",
mode=AppMode.WORKFLOW,
enable_site=False,
enable_api=False,
max_active_requests=None,
)
session.add(app_model)
session.commit()
return app_model
def _model_config_payload():
@@ -66,12 +84,11 @@ def test_rule_code_generate_maps_token_error(app: Flask, monkeypatch: pytest.Mon
method(api, "t1")
def test_instruction_generate_app_not_found(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_instruction_generate_app_not_found(app: Flask, sqlite_session: Session) -> None:
api = generator_module.InstructionGenerateApi()
method = unwrap(api.post)
session = MagicMock()
session.scalar.return_value = None
_persist_app(sqlite_session, tenant_id="other-tenant")
with app.test_request_context(
"/console/api/instruction-generate",
@@ -83,25 +100,21 @@ def test_instruction_generate_app_not_found(app: Flask, monkeypatch: pytest.Monk
"model_config": _model_config_payload(),
},
):
response, status = method(api, session, "t1")
response, status = method(api, sqlite_session, "t1")
assert status == 400
assert response["error"] == "app app-1 not found"
stmt = session.scalar.call_args.args[0]
compiled = stmt.compile()
statement = str(compiled)
assert "apps.id" in statement
assert "apps.tenant_id" in statement
assert "app-1" in compiled.params.values()
assert "t1" in compiled.params.values()
assert sqlite_session.get(App, "app-1") is not None
def test_instruction_generate_workflow_not_found(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_instruction_generate_workflow_not_found(
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
api = generator_module.InstructionGenerateApi()
method = unwrap(api.post)
app_model = SimpleNamespace(id="app-1")
session = SimpleNamespace(scalar=lambda *_args, **_kwargs: app_model)
app_model = _persist_app(sqlite_session)
_install_workflow_service(monkeypatch, workflow=None)
with app.test_request_context(
@@ -114,18 +127,20 @@ def test_instruction_generate_workflow_not_found(app: Flask, monkeypatch: pytest
"model_config": _model_config_payload(),
},
):
response, status = method(api, session, "t1")
response, status = method(api, sqlite_session, "t1")
assert status == 400
assert response["error"] == "workflow app-1 not found"
def test_instruction_generate_node_missing(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_instruction_generate_node_missing(
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
api = generator_module.InstructionGenerateApi()
method = unwrap(api.post)
app_model = SimpleNamespace(id="app-1")
session = SimpleNamespace(scalar=lambda *_args, **_kwargs: app_model)
app_model = _persist_app(sqlite_session)
workflow = SimpleNamespace(graph_dict={"nodes": []})
_install_workflow_service(monkeypatch, workflow=workflow)
@@ -140,18 +155,18 @@ def test_instruction_generate_node_missing(app: Flask, monkeypatch: pytest.Monke
"model_config": _model_config_payload(),
},
):
response, status = method(api, session, "t1")
response, status = method(api, sqlite_session, "t1")
assert status == 400
assert response["error"] == "node node-1 not found"
def test_instruction_generate_code_node(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_instruction_generate_code_node(app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
api = generator_module.InstructionGenerateApi()
method = unwrap(api.post)
app_model = SimpleNamespace(id="app-1")
session = SimpleNamespace(scalar=lambda *_args, **_kwargs: app_model)
app_model = _persist_app(sqlite_session)
workflow = SimpleNamespace(
graph_dict={
@@ -173,18 +188,19 @@ def test_instruction_generate_code_node(app: Flask, monkeypatch: pytest.MonkeyPa
"model_config": _model_config_payload(),
},
):
response = method(api, session, "t1")
response = method(api, sqlite_session, "t1")
assert response == {"code": "x"}
assert workflow_service.app_model is app_model
assert workflow_service.session is session
assert workflow_service.session is sqlite_session
def test_instruction_generate_legacy_modify(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_instruction_generate_legacy_modify(
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
api = generator_module.InstructionGenerateApi()
method = unwrap(api.post)
session = SimpleNamespace()
monkeypatch.setattr(
generator_module.LLMGenerator,
"instruction_modify_legacy",
@@ -202,16 +218,15 @@ def test_instruction_generate_legacy_modify(app: Flask, monkeypatch: pytest.Monk
"model_config": _model_config_payload(),
},
):
response = method(api, session, "t1")
response = method(api, sqlite_session, "t1")
assert response == {"instruction": "ok"}
def test_instruction_generate_incompatible_params(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_instruction_generate_incompatible_params(app: Flask, sqlite_session: Session) -> None:
api = generator_module.InstructionGenerateApi()
method = unwrap(api.post)
session = SimpleNamespace()
with app.test_request_context(
"/console/api/instruction-generate",
method="POST",
@@ -223,7 +238,7 @@ def test_instruction_generate_incompatible_params(app: Flask, monkeypatch: pytes
"model_config": _model_config_payload(),
},
):
response, status = method(api, session, "t1")
response, status = method(api, sqlite_session, "t1")
assert status == 400
assert response["error"] == "incompatible parameters"
@@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Iterator
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import PropertyMock, patch
import pytest
@@ -275,9 +276,9 @@ class TestCustomizedPipelineTemplateApi:
assert (response, status) == ("", 204)
assert deleted_templates == [("template-1", tenant_id)]
@pytest.mark.parametrize("sqlite_session", [(PipelineCustomizedTemplate,)], indirect=True)
def test_post_exports_yaml_from_orm_template(
self,
database_app: Flask,
self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, sqlite_session: Session
) -> None:
api = CustomizedPipelineTemplateApi()
method = unwrap(api.post)
@@ -293,26 +294,29 @@ class TestCustomizedPipelineTemplateApi:
language="en-US",
created_by="00000000-0000-0000-0000-000000000002",
)
db.session.add(template)
db.session.commit()
template.id = "template-1"
sqlite_session.add(template)
sqlite_session.commit()
monkeypatch.setattr(module, "db", SimpleNamespace(engine=sqlite_engine))
with database_app.test_request_context("/rag/pipeline/customized/templates/template-1", method="POST"):
response, status = method(api, template.id)
with app.test_request_context("/rag/pipeline/customized/templates/template-1", method="POST"):
response, status = method(api, "template-1")
assert status == 200
assert response == {"data": "dsl: value"}
@pytest.mark.parametrize("sqlite_session", [(PipelineCustomizedTemplate,)], indirect=True)
def test_post_raises_when_template_is_missing(
self,
database_app: Flask,
self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, sqlite_session: Session
) -> None:
api = CustomizedPipelineTemplateApi()
method = unwrap(api.post)
with (
database_app.test_request_context("/rag/pipeline/customized/templates/missing", method="POST"),
pytest.raises(ValueError, match="Customized pipeline template not found"),
):
method(api, "44444444-4444-4444-4444-444444444444")
assert sqlite_session.get(PipelineCustomizedTemplate, "missing") is None
monkeypatch.setattr(module, "db", SimpleNamespace(engine=sqlite_engine))
with app.test_request_context("/rag/pipeline/customized/templates/missing", method="POST"):
with pytest.raises(ValueError, match="Customized pipeline template not found"):
method(api, "missing")
class TestPublishCustomizedPipelineTemplateApi:
@@ -838,6 +838,29 @@ class TestTrialChatAudioApi:
with pytest.raises(module.NoAudioUploadedError):
method(api, account, trial_app_chat)
def test_missing_file_field_returns_400(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
"""A multipart POST with no `file` field must surface as 400, not 500.
Verifies the controller passes file=None to AudioService.transcript_asr
instead of raising a KeyError that would yield HTTP 500.
"""
def fake_asr(*args, **kwargs):
assert kwargs["file"] is None
raise module.services.errors.audio.NoAudioUploadedServiceError()
api = module.TrialChatAudioApi()
method = unwrap(api.post)
with (
app.test_request_context("/", method="POST", data={}, content_type="multipart/form-data"),
patch.object(module.AudioService, "transcript_asr", side_effect=fake_asr),
):
with pytest.raises(module.NoAudioUploadedError) as exc_info:
method(api, account, trial_app_chat)
assert exc_info.value.code == 400
def test_audio_too_large(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatAudioApi()
method = unwrap(api.post)
@@ -0,0 +1,99 @@
from __future__ import annotations
from datetime import UTC, datetime
from inspect import unwrap
from unittest.mock import Mock, PropertyMock, patch
import pytest
from flask import Flask
from pydantic import ValidationError
from controllers.console import console_ns
from controllers.console.onboarding import (
StepByStepTourStateApi,
StepByStepTourStatePatchPayload,
)
from extensions.ext_database import db
from models.account import Account, AccountStatus
from services.step_by_step_tour_service import StepByStepTourService
def _account() -> Account:
account = Account(name="User", email="user@example.com", status=AccountStatus.ACTIVE)
account.id = "account-1"
return account
def _state_response() -> dict[str, object]:
return {
"first_workspace_id": "workspace-1",
"skipped": False,
"completed_task_ids": ["home"],
"manually_enabled_workspace_ids": [],
"manually_disabled_workspace_ids": [],
"updated_at": datetime(2026, 6, 28, tzinfo=UTC),
}
def test_get_step_by_step_tour_state(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
get_state = Mock(return_value=_state_response())
monkeypatch.setattr(StepByStepTourService, "get_state", get_state)
api = StepByStepTourStateApi()
method = unwrap(api.get)
with app.test_request_context("/console/api/onboarding/step-by-step-tour/state", method="GET"):
result = method(api, "workspace-1", _account())
assert result == {
"first_workspace_id": "workspace-1",
"skipped": False,
"completed_task_ids": ["home"],
"manually_enabled_workspace_ids": [],
"manually_disabled_workspace_ids": [],
"updated_at": "2026-06-28T00:00:00Z",
}
get_state.assert_called_once()
assert get_state.call_args.kwargs["current_tenant_id"] == "workspace-1"
assert get_state.call_args.kwargs["session"] is db.session
def test_patch_step_by_step_tour_state_passes_action_payload(
app: Flask,
monkeypatch: pytest.MonkeyPatch,
) -> None:
patch_state = Mock(return_value=_state_response())
monkeypatch.setattr(StepByStepTourService, "patch_state", patch_state)
api = StepByStepTourStateApi()
method = unwrap(api.patch)
payload = {"action": "complete_task", "task_id": "studio"}
with app.test_request_context(
"/console/api/onboarding/step-by-step-tour/state",
method="PATCH",
json=payload,
):
with patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload):
result = method(api, "workspace-1", _account())
assert result["completed_task_ids"] == ["home"]
patch_state.assert_called_once()
assert patch_state.call_args.kwargs["current_tenant_id"] == "workspace-1"
assert patch_state.call_args.kwargs["patch"] == payload
assert patch_state.call_args.kwargs["session"] is db.session
def test_patch_payload_rejects_non_action_fields() -> None:
with pytest.raises(ValidationError, match="Extra inputs are not permitted"):
StepByStepTourStatePatchPayload.model_validate({"action": "skip", "skipped": True})
def test_patch_payload_rejects_task_id_without_task_action() -> None:
with pytest.raises(ValidationError, match="task_id is only supported for task actions"):
StepByStepTourStatePatchPayload.model_validate({"action": "skip", "task_id": "home"})
def test_patch_payload_requires_action() -> None:
with pytest.raises(ValidationError):
StepByStepTourStatePatchPayload.model_validate({"task_id": "home"})
@@ -1,11 +1,77 @@
import pytest
from types import SimpleNamespace
import pytest
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.console import workflow_run_archive
from controllers.console.workflow_run_archive import (
WorkflowRunArchiveDownloadApi,
WorkflowRunArchiveDownloadFileApi,
WorkflowRunArchiveDownloadsApi,
WorkflowRunArchivesApi,
)
from models import TenantAccountRole
@pytest.mark.parametrize(
"role",
[TenantAccountRole.EDITOR, TenantAccountRole.NORMAL, TenantAccountRole.DATASET_OPERATOR],
)
@pytest.mark.parametrize("rbac_enabled", [False, True])
def test_current_owner_or_admin_ids_rejects_non_manager(
monkeypatch: pytest.MonkeyPatch, role: TenantAccountRole, rbac_enabled: bool
) -> None:
current_user = SimpleNamespace(id="account-1", current_role=role)
monkeypatch.setattr(dify_config, "RBAC_ENABLED", rbac_enabled)
monkeypatch.setattr(
workflow_run_archive,
"current_account_with_tenant",
lambda: (current_user, "tenant-1"),
)
with pytest.raises(Forbidden):
workflow_run_archive._current_owner_or_admin_ids()
@pytest.mark.parametrize("role", [TenantAccountRole.OWNER, TenantAccountRole.ADMIN])
@pytest.mark.parametrize("rbac_enabled", [False, True])
def test_current_owner_or_admin_ids_returns_current_ids_for_manager(
monkeypatch: pytest.MonkeyPatch, role: TenantAccountRole, rbac_enabled: bool
) -> None:
current_user = SimpleNamespace(id="account-1", current_role=role)
monkeypatch.setattr(dify_config, "RBAC_ENABLED", rbac_enabled)
monkeypatch.setattr(
workflow_run_archive,
"current_account_with_tenant",
lambda: (current_user, "tenant-1"),
)
assert workflow_run_archive._current_owner_or_admin_ids() == ("tenant-1", "account-1")
@pytest.mark.parametrize(
("method", "args"),
[
(WorkflowRunArchivesApi.get, ()),
(WorkflowRunArchiveDownloadsApi.post, ()),
(WorkflowRunArchiveDownloadApi.get, ("download-1",)),
(WorkflowRunArchiveDownloadFileApi.get, ("download-1",)),
],
)
def test_workflow_run_archive_endpoints_enforce_fixed_workspace_roles(
monkeypatch: pytest.MonkeyPatch, method, args: tuple[str, ...]
) -> None:
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
def reject_non_manager() -> tuple[str, str]:
raise Forbidden()
monkeypatch.setattr(workflow_run_archive, "_current_owner_or_admin_ids", reject_non_manager)
with pytest.raises(Forbidden):
method(None, *args)
@pytest.mark.parametrize(
@@ -28,3 +94,4 @@ def test_workflow_run_archive_endpoints_require_cloud_paid_plan(method) -> None:
"cloud_edition_billing_enabled",
"cloud_edition_billing_paid_plan_required",
} <= decorator_names
assert "rbac_permission_required" not in decorator_names
@@ -1,9 +1,12 @@
import inspect
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import ANY, MagicMock, patch
from unittest.mock import MagicMock, patch
from uuid import NAMESPACE_URL, uuid5
import pytest
from flask import Flask
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from controllers.console.workspace.account import (
AccountDeleteUpdateFeedbackApi,
@@ -12,7 +15,8 @@ from controllers.console.workspace.account import (
ChangeEmailSendEmailApi,
CheckEmailUnique,
)
from models import Account, AccountStatus, Tenant
from models import Account, AccountIntegrate, AccountStatus, Tenant, TenantAccountJoin
from models.account import TenantAccountRole
from services.account_service import AccountService
from services.entities.auth_entities import (
ChangeEmailNewEmailToken,
@@ -45,6 +49,38 @@ def _build_account(email: str, account_id: str = "acc", tenant: Tenant | None =
return account
def _stable_uuid(value: str) -> str:
return str(uuid5(NAMESPACE_URL, value))
def _persist_account_with_tenant(session: Session, email: str, account_name: str = "account") -> tuple[Account, Tenant]:
tenant = Tenant(name=f"{account_name} tenant")
tenant.id = _stable_uuid(f"tenant:{account_name}")
account = Account(name=account_name, email=email, status=AccountStatus.ACTIVE)
account.id = _stable_uuid(f"account:{account_name}")
membership = TenantAccountJoin(
tenant_id=tenant.id,
account_id=account.id,
current=True,
role=TenantAccountRole.OWNER,
)
session.add_all([account, tenant, membership])
session.commit()
account._current_tenant = tenant
account.role = TenantAccountRole.OWNER
return account, tenant
@contextmanager
def _bind_database_session(session: Session):
database_session = scoped_session(sessionmaker(bind=session.get_bind(), expire_on_commit=False))
try:
with patch("extensions.ext_database.db.session", database_session):
yield database_session
finally:
database_session.remove()
def _build_change_email_token(
phase: str,
*,
@@ -387,47 +423,64 @@ class TestChangeEmailValidity:
class TestChangeEmailReset:
@patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email")
@patch("controllers.console.workspace.account.AccountService.update_account_email")
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@patch("controllers.console.workspace.account.AccountService.check_email_unique")
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
@pytest.mark.parametrize(
"sqlite_session",
[(Account, Tenant, TenantAccountJoin, AccountIntegrate)],
indirect=True,
)
def test_should_normalize_new_email_before_update(
self,
mock_is_freeze: MagicMock,
mock_check_unique: MagicMock,
mock_get_data: MagicMock,
mock_revoke_token: MagicMock,
mock_update_account: MagicMock,
mock_send_notify: MagicMock,
app: Flask,
sqlite_session: Session,
):
current_user = _build_account("old@example.com", "acc3")
mock_is_freeze.return_value = False
mock_check_unique.return_value = True
mock_get_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
account_id="acc3",
email="new@example.com",
old_email="OLD@example.com",
)
mock_account_after_update = _build_account("new@example.com", "acc3-updated")
mock_update_account.return_value = mock_account_after_update
with app.test_request_context(
"/account/change-email/reset",
method="POST",
json={"new_email": "New@Example.com", "token": "token-123"},
):
api = ChangeEmailResetApi()
method = inspect.unwrap(api.post)
method(api, current_user)
with _bind_database_session(sqlite_session) as database_session:
current_user, _ = _persist_account_with_tenant(
database_session(),
"old@example.com",
"email-reset-account",
)
account_integration = AccountIntegrate(
account_id=current_user.id,
provider="google",
open_id="google-user",
encrypted_token="encrypted-token",
)
database_session.add(account_integration)
database_session.commit()
mock_get_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
account_id=current_user.id,
email="new@example.com",
old_email="OLD@example.com",
)
mock_is_freeze.assert_called_once_with("new@example.com")
mock_check_unique.assert_called_once_with("new@example.com", session=ANY)
mock_revoke_token.assert_called_once_with("token-123")
mock_update_account.assert_called_once_with(current_user, email="new@example.com", session=ANY)
mock_send_notify.assert_called_once_with(email="new@example.com")
with app.test_request_context(
"/account/change-email/reset",
method="POST",
json={"new_email": "New@Example.com", "token": "token-123"},
):
api = ChangeEmailResetApi()
method = inspect.unwrap(api.post)
response = method(api, current_user)
sqlite_session.expire_all()
persisted_account = sqlite_session.get(Account, current_user.id)
assert response["email"] == "new@example.com"
assert persisted_account is not None
assert persisted_account.email == "new@example.com"
assert sqlite_session.get(AccountIntegrate, account_integration.id) is None
mock_is_freeze.assert_called_once_with("new@example.com")
mock_revoke_token.assert_called_once_with("token-123")
mock_send_notify.assert_called_once_with(email="new@example.com")
@patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email")
@patch("controllers.console.workspace.account.AccountService.update_account_email")
@@ -663,36 +716,47 @@ class TestAccountDeletionFeedback:
class TestCheckEmailUnique:
@patch("controllers.console.workspace.account.AccountService.check_email_unique")
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
def test_should_normalize_email(self, mock_is_freeze: MagicMock, mock_check_unique: MagicMock, app: Flask):
@pytest.mark.parametrize(
"sqlite_session",
[(Account, Tenant, TenantAccountJoin)],
indirect=True,
)
def test_should_normalize_email(
self,
mock_is_freeze: MagicMock,
app: Flask,
sqlite_session: Session,
):
mock_is_freeze.return_value = False
mock_check_unique.return_value = True
with app.test_request_context(
"/account/change-email/check-email-unique",
method="POST",
json={"email": "Case@Test.com"},
):
api = CheckEmailUnique()
method = inspect.unwrap(api.post)
response = method(api)
with _bind_database_session(sqlite_session) as database_session:
_persist_account_with_tenant(database_session(), "different@test.com", "uniqueness-account")
with app.test_request_context(
"/account/change-email/check-email-unique",
method="POST",
json={"email": "Case@Test.com"},
):
api = CheckEmailUnique()
method = inspect.unwrap(api.post)
response = method(api)
assert response == {"result": "success"}
mock_is_freeze.assert_called_once_with("case@test.com")
mock_check_unique.assert_called_once_with("case@test.com", session=ANY)
def test_get_account_by_email_with_case_fallback_uses_lowercase_lookup():
mock_session = MagicMock()
first = MagicMock()
first.scalar_one_or_none.return_value = None
second = MagicMock()
expected_account = MagicMock()
second.scalar_one_or_none.return_value = expected_account
mock_session.execute.side_effect = [first, second]
@pytest.mark.parametrize(
"sqlite_session",
[(Account, Tenant, TenantAccountJoin)],
indirect=True,
)
def test_get_account_by_email_with_case_fallback_uses_lowercase_lookup(sqlite_session: Session):
expected_account, _ = _persist_account_with_tenant(
sqlite_session,
"mixed@test.com",
"case-fallback-account",
)
result = AccountService.get_account_by_email_with_case_fallback("Mixed@Test.com", session=mock_session)
result = AccountService.get_account_by_email_with_case_fallback("Mixed@Test.com", session=sqlite_session)
assert result is expected_account
assert mock_session.execute.call_count == 2
@@ -1,3 +1,4 @@
from types import SimpleNamespace
from typing import override
from unittest.mock import MagicMock, patch
@@ -5,6 +6,8 @@ import pytest
from flask import Flask, request
from flask_login import LoginManager, UserMixin
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import HTTPException
from controllers.common.wraps import _extract_resource_id
@@ -34,6 +37,7 @@ from controllers.console.wraps import (
)
from models import Account
from models.account import AccountStatus, TenantAccountRole
from models.dataset import RateLimitLog
from services.feature_service import LicenseStatus
@@ -657,8 +661,7 @@ class TestRateLimiting:
"""Test rate limiting decorator"""
@patch("controllers.console.wraps.redis_client")
@patch("controllers.console.wraps.db")
def test_should_allow_requests_within_rate_limit(self, mock_db: MagicMock, mock_redis: MagicMock):
def test_should_allow_requests_within_rate_limit(self, mock_redis: MagicMock):
"""Test that requests within rate limit are allowed"""
# Arrange
mock_rate_limit = MagicMock()
@@ -685,8 +688,13 @@ class TestRateLimiting:
mock_redis.zremrangebyscore.assert_called_once()
@patch("controllers.console.wraps.redis_client")
@patch("controllers.console.wraps.db")
def test_should_reject_requests_over_rate_limit(self, mock_db: MagicMock, mock_redis: MagicMock):
@pytest.mark.parametrize("sqlite_session", [(RateLimitLog,)], indirect=True)
def test_should_reject_requests_over_rate_limit(
self,
mock_redis: MagicMock,
sqlite_session: Session,
monkeypatch: pytest.MonkeyPatch,
):
"""Test that requests over rate limit are rejected and logged"""
# Arrange
app = create_app_with_login()
@@ -696,8 +704,7 @@ class TestRateLimiting:
mock_rate_limit.subscription_plan = "pro"
mock_redis.zcard.return_value = 11 # Over limit
mock_session = MagicMock()
mock_db.session = mock_session
monkeypatch.setattr("controllers.console.wraps.db", SimpleNamespace(session=sqlite_session))
@cloud_edition_billing_rate_limit_check("knowledge")
def knowledge_request():
@@ -719,9 +726,11 @@ class TestRateLimiting:
assert exc_info.value.code == 403
assert "rate limit" in str(exc_info.value.description)
# Verify rate limit log was created
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
rate_limit_log = sqlite_session.scalar(select(RateLimitLog))
assert rate_limit_log is not None
assert rate_limit_log.tenant_id == "tenant123"
assert rate_limit_log.subscription_plan == "pro"
assert rate_limit_log.operation == "knowledge"
class TestCloudUtmRecord:
@@ -1,8 +1,11 @@
import inspect
from datetime import UTC, datetime
from unittest.mock import MagicMock, PropertyMock, patch
from uuid import NAMESPACE_URL, uuid5
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
from controllers.console import console_ns
@@ -32,15 +35,17 @@ from controllers.console.workspace.error import (
CurrentPasswordIncorrectError,
InvalidAccountDeletionCodeError,
)
from models import Account
from models.account import AccountStatus
from extensions.storage.storage_type import StorageType
from models import Account, AccountIntegrate, InvitationCode, Tenant, TenantAccountJoin
from models.account import AccountStatus, InvitationCodeStatus, TenantAccountRole
from models.enums import CreatorUserRole
from models.model import UploadFile
from services.errors.account import CurrentPasswordIncorrectError as ServicePwdError
def make_account(account_id: str = "u1", *, status: AccountStatus = AccountStatus.ACTIVE) -> Account:
account = Account(name="John", email=f"{account_id}@test.com", status=status)
account.id = account_id
account.id = str(uuid5(NAMESPACE_URL, f"account:{account_id}"))
account.avatar = "avatar.png"
account.interface_language = "en-US"
account.interface_theme = "light"
@@ -49,12 +54,62 @@ def make_account(account_id: str = "u1", *, status: AccountStatus = AccountStatu
return account
def persist_account_with_tenant(
session: Session,
account_id: str = "u1",
*,
status: AccountStatus = AccountStatus.ACTIVE,
tenant_id: str = "tenant-1",
) -> tuple[Account, Tenant]:
account = make_account(account_id, status=status)
tenant = Tenant(name=tenant_id)
tenant.id = str(uuid5(NAMESPACE_URL, f"tenant:{tenant_id}"))
membership = TenantAccountJoin(
tenant_id=tenant.id,
account_id=account.id,
current=True,
role=TenantAccountRole.OWNER,
)
session.add_all([account, tenant, membership])
session.commit()
account._current_tenant = tenant
account.role = TenantAccountRole.OWNER
return account, tenant
def make_upload_file(*, tenant_id: str, created_by: str) -> UploadFile:
return UploadFile(
tenant_id=tenant_id,
storage_type=StorageType.LOCAL,
key="avatar.png",
name="avatar.png",
size=128,
extension="png",
mime_type="image/png",
created_by_role=CreatorUserRole.ACCOUNT,
created_by=created_by,
created_at=datetime.now(UTC).replace(tzinfo=None),
used=False,
)
class TestAccountInitApi:
def test_init_success(self, app: Flask):
@pytest.mark.parametrize(
"sqlite_session",
[(Account, Tenant, TenantAccountJoin, InvitationCode)],
indirect=True,
)
def test_init_success(self, app: Flask, sqlite_session: Session):
api = AccountInitApi()
method = inspect.unwrap(api.post)
account = make_account(status=AccountStatus.UNINITIALIZED)
account, tenant = persist_account_with_tenant(
sqlite_session,
status=AccountStatus.UNINITIALIZED,
)
invitation_code = InvitationCode(batch="batch-1", code="code123")
sqlite_session.add(invitation_code)
sqlite_session.commit()
payload = {
"interface_language": "en-US",
"timezone": "UTC",
@@ -63,14 +118,24 @@ class TestAccountInitApi:
with (
app.test_request_context("/account/init", json=payload),
patch("controllers.console.workspace.account.db.session.commit", return_value=None),
patch("controllers.console.workspace.account.dify_config.EDITION", "CLOUD"),
patch("controllers.console.workspace.account.db.session.scalar") as scalar_mock,
patch("controllers.console.workspace.account.db.session", sqlite_session),
):
scalar_mock.return_value = MagicMock(status="unused")
resp = method(api, account)
assert resp["result"] == "success"
sqlite_session.expire_all()
persisted_account = sqlite_session.get(Account, account.id)
persisted_invitation = sqlite_session.get(InvitationCode, invitation_code.id)
assert persisted_account is not None
assert persisted_account.status == AccountStatus.ACTIVE
assert persisted_account.interface_language == "en-US"
assert persisted_account.timezone == "UTC"
assert persisted_account.initialized_at is not None
assert persisted_invitation is not None
assert persisted_invitation.status == InvitationCodeStatus.USED
assert persisted_invitation.used_by_account_id == account.id
assert persisted_invitation.used_by_tenant_id == tenant.id
def test_init_already_initialized(self, app: Flask):
api = AccountInitApi()
@@ -93,7 +158,7 @@ class TestAccountProfileApi:
with app.test_request_context("/account/profile"):
result = method(api, user)
assert result["id"] == "u1"
assert result["id"] == user.id
class TestAccountUpdateApis:
@@ -119,29 +184,33 @@ class TestAccountUpdateApis:
):
result = method(api, user)
assert result["id"] == "u1"
assert result["id"] == user.id
class TestAccountAvatarApiGet:
"""GET /account/avatar must not sign arbitrary upload_file IDs (IDOR)."""
def test_get_avatar_signed_url_when_upload_owned_by_current_account(self, app: Flask):
@pytest.mark.parametrize(
"sqlite_session",
[(Account, Tenant, TenantAccountJoin, UploadFile)],
indirect=True,
)
def test_get_avatar_signed_url_when_upload_owned_by_current_account(self, app: Flask, sqlite_session: Session):
api = AccountAvatarApi()
method = inspect.unwrap(api.get)
user = make_account("acc-owner")
tenant_id = "tenant-1"
user, tenant = persist_account_with_tenant(sqlite_session, "acc-owner")
tenant_id = tenant.id
file_id = "550e8400-e29b-41d4-a716-446655440000"
upload_file = MagicMock()
upload_file = make_upload_file(tenant_id=tenant_id, created_by=user.id)
upload_file.id = file_id
upload_file.tenant_id = tenant_id
upload_file.created_by = user.id
upload_file.created_by_role = CreatorUserRole.ACCOUNT
sqlite_session.add(upload_file)
sqlite_session.commit()
with (
app.test_request_context(f"/account/avatar?avatar={file_id}"),
patch("controllers.console.workspace.account.db.session.scalar", return_value=upload_file),
patch("controllers.console.workspace.account.db.session", sqlite_session),
patch(
"controllers.console.workspace.account.file_helpers.get_signed_file_url",
return_value="https://signed/example",
@@ -152,23 +221,35 @@ class TestAccountAvatarApiGet:
assert result == {"avatar_url": "https://signed/example"}
sign_mock.assert_called_once_with(upload_file_id=file_id)
def test_get_avatar_not_found_when_upload_created_by_other_account_same_tenant(self, app: Flask):
@pytest.mark.parametrize(
"sqlite_session",
[(Account, Tenant, TenantAccountJoin, UploadFile)],
indirect=True,
)
def test_get_avatar_not_found_when_upload_created_by_other_account_same_tenant(
self, app: Flask, sqlite_session: Session
):
api = AccountAvatarApi()
method = inspect.unwrap(api.get)
user = make_account("acc-a")
tenant_id = "tenant-1"
user, tenant = persist_account_with_tenant(sqlite_session, "acc-a")
tenant_id = tenant.id
file_id = "550e8400-e29b-41d4-a716-446655440001"
upload_file = MagicMock()
other_account = make_account("acc-b")
other_membership = TenantAccountJoin(
tenant_id=tenant_id,
account_id=other_account.id,
role=TenantAccountRole.NORMAL,
)
upload_file = make_upload_file(tenant_id=tenant_id, created_by=other_account.id)
upload_file.id = file_id
upload_file.tenant_id = tenant_id
upload_file.created_by = "acc-b"
upload_file.created_by_role = CreatorUserRole.ACCOUNT
sqlite_session.add_all([other_account, other_membership, upload_file])
sqlite_session.commit()
with (
app.test_request_context(f"/account/avatar?avatar={file_id}"),
patch("controllers.console.workspace.account.db.session.scalar", return_value=upload_file),
patch("controllers.console.workspace.account.db.session", sqlite_session),
patch(
"controllers.console.workspace.account.file_helpers.get_signed_file_url",
return_value="https://signed/leak",
@@ -179,23 +260,29 @@ class TestAccountAvatarApiGet:
sign_mock.assert_not_called()
def test_get_avatar_not_found_when_upload_belongs_to_other_tenant(self, app: Flask):
@pytest.mark.parametrize(
"sqlite_session",
[(Account, Tenant, TenantAccountJoin, UploadFile)],
indirect=True,
)
def test_get_avatar_not_found_when_upload_belongs_to_other_tenant(self, app: Flask, sqlite_session: Session):
api = AccountAvatarApi()
method = inspect.unwrap(api.get)
user = make_account("acc-owner")
tenant_id = "tenant-1"
user, tenant = persist_account_with_tenant(sqlite_session, "acc-owner")
tenant_id = tenant.id
file_id = "550e8400-e29b-41d4-a716-446655440002"
upload_file = MagicMock()
other_tenant = Tenant(name="tenant-other")
other_tenant.id = str(uuid5(NAMESPACE_URL, "tenant:tenant-other"))
upload_file = make_upload_file(tenant_id=other_tenant.id, created_by=user.id)
upload_file.id = file_id
upload_file.tenant_id = "tenant-other"
upload_file.created_by = user.id
upload_file.created_by_role = CreatorUserRole.ACCOUNT
sqlite_session.add_all([other_tenant, upload_file])
sqlite_session.commit()
with (
app.test_request_context(f"/account/avatar?avatar={file_id}"),
patch("controllers.console.workspace.account.db.session.scalar", return_value=upload_file),
patch("controllers.console.workspace.account.db.session", sqlite_session),
patch(
"controllers.console.workspace.account.file_helpers.get_signed_file_url",
return_value="https://signed/leak",
@@ -246,7 +333,7 @@ class TestAccountPasswordApi:
):
result = method(api, user)
assert result["id"] == "u1"
assert result["id"] == user.id
def test_password_wrong_current(self, app: Flask):
api = AccountPasswordApi()
@@ -271,21 +358,38 @@ class TestAccountPasswordApi:
class TestAccountIntegrateApi:
def test_get_integrates(self, app: Flask):
@pytest.mark.parametrize(
"sqlite_session",
[(Account, Tenant, TenantAccountJoin, AccountIntegrate)],
indirect=True,
)
def test_get_integrates(self, app: Flask, sqlite_session: Session):
api = AccountIntegrateApi()
method = inspect.unwrap(api.get)
account = make_account("acc1")
account, _ = persist_account_with_tenant(sqlite_session, "acc1")
sqlite_session.add(
AccountIntegrate(
account_id=account.id,
provider="github",
open_id="github-user",
encrypted_token="encrypted-token",
)
)
sqlite_session.commit()
with (
app.test_request_context("/"),
patch("controllers.console.workspace.account.db.session.scalars") as scalars_mock,
patch("controllers.console.workspace.account.db.session", sqlite_session),
):
scalars_mock.return_value.all.return_value = []
result = method(api, account)
assert "data" in result
assert len(result["data"]) == 2
assert result["data"][0]["provider"] == "github"
assert result["data"][0]["is_bound"] is True
assert result["data"][0]["link"] is None
assert result["data"][1]["provider"] == "google"
assert result["data"][1]["is_bound"] is False
assert result["data"][1]["link"].endswith("/console/api/oauth/login/google")
class TestAccountDeleteApi:
@@ -1,4 +1,5 @@
import logging
from collections.abc import Iterator
from http import HTTPStatus
from inspect import unwrap
from io import BytesIO
@@ -6,6 +7,8 @@ from unittest.mock import ANY, MagicMock, patch
import pytest
from flask import Flask
from sqlalchemy import Engine, event
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import Unauthorized
@@ -36,6 +39,17 @@ from libs.datetime_utils import naive_utc_now
from models.account import Account, Tenant, TenantCustomConfigDict, TenantStatus
@pytest.fixture
def workspace_session(sqlite_engine: Engine) -> Iterator[scoped_session[Session]]:
"""Provide the callable scoped session expected by Flask-SQLAlchemy controllers."""
Tenant.metadata.create_all(sqlite_engine, tables=[Tenant.__table__])
session = scoped_session(sessionmaker(bind=sqlite_engine, expire_on_commit=False))
try:
yield session
finally:
session.remove()
def make_account(account_id: str = "u1") -> Account:
account = Account(name="Test User", email=f"{account_id}@example.com")
account.id = account_id
@@ -370,11 +384,13 @@ class TestTenantInfoResponse:
class TestSwitchWorkspaceApi:
def test_switch_success(self, app: Flask):
def test_switch_success(self, app: Flask, workspace_session: scoped_session[Session]):
api = SwitchWorkspaceApi()
method = unwrap(api.post)
payload = {"tenant_id": "t2"}
tenant = make_tenant("t2")
workspace_session.add(tenant)
workspace_session.commit()
user = make_account()
with (
app.test_request_context("/workspaces/switch", json=payload),
@@ -383,11 +399,10 @@ class TestSwitchWorkspaceApi:
"controllers.console.workspace.workspace.WorkspaceService.get_tenant_info", return_value={"id": "t2"}
),
):
session = MagicMock()
session.get.return_value = tenant
result = method(api, session, user)
result = method(api, workspace_session, user)
assert result["result"] == "success"
switch_tenant.assert_called_once_with(user, "t2", session=session)
switch_tenant.assert_called_once_with(user, "t2", session=workspace_session)
def test_switch_not_linked(self, app: Flask):
api = SwitchWorkspaceApi()
@@ -401,7 +416,7 @@ class TestSwitchWorkspaceApi:
with pytest.raises(AccountNotLinkTenantError):
method(api, MagicMock(), user)
def test_switch_tenant_not_found(self, app: Flask):
def test_switch_tenant_not_found(self, app: Flask, workspace_session: scoped_session[Session]):
api = SwitchWorkspaceApi()
method = unwrap(api.post)
payload = {"tenant_id": "missing"}
@@ -410,19 +425,21 @@ class TestSwitchWorkspaceApi:
app.test_request_context("/workspaces/switch", json=payload),
patch("controllers.console.workspace.workspace.TenantService.switch_tenant"),
):
session = MagicMock()
session.get.return_value = None
with pytest.raises(ValueError):
method(api, session, user)
method(api, workspace_session, user)
class TestCustomConfigWorkspaceApi:
def test_post_success(self, app: Flask):
def test_post_success(self, app: Flask, workspace_session: scoped_session[Session]):
api = CustomConfigWorkspaceApi()
method = unwrap(api.post)
tenant = make_tenant(custom_config={})
workspace_session.add(tenant)
workspace_session.commit()
payload = {"remove_webapp_brand": True}
events = []
event.listen(workspace_session, "after_commit", lambda _: events.append("commit"))
with (
app.test_request_context("/workspaces/custom-config", json=payload),
patch(
@@ -430,27 +447,29 @@ class TestCustomConfigWorkspaceApi:
side_effect=lambda *args, **kwargs: events.append("get_tenant_info") or {"id": "t1"},
),
):
session = MagicMock()
session.get.return_value = tenant
session.commit.side_effect = lambda: events.append("commit")
result = method(api, session, "t1")
result = method(api, workspace_session, "t1")
assert result["result"] == "success"
assert events == ["commit", "get_tenant_info"]
def test_logo_fallback(self, app: Flask):
def test_logo_fallback(self, app: Flask, workspace_session: scoped_session[Session]):
api = CustomConfigWorkspaceApi()
method = unwrap(api.post)
tenant = make_tenant(custom_config={"replace_webapp_logo": "old-logo"})
workspace_session.add(tenant)
workspace_session.commit()
payload = {"remove_webapp_brand": False}
with (
app.test_request_context("/workspaces/custom-config", json=payload),
patch(
"controllers.console.workspace.workspace.WorkspaceService.get_tenant_info", return_value={"id": "t1"}
"controllers.console.workspace.workspace.WorkspaceService.get_tenant_info",
return_value={"id": "t1"},
),
):
session = MagicMock()
session.get.return_value = tenant
result = method(api, session, "t1")
result = method(api, workspace_session, "t1")
assert tenant.custom_config_dict["replace_webapp_logo"] == "old-logo"
assert result["result"] == "success"
@@ -541,14 +560,19 @@ class TestWebappLogoWorkspaceApi:
class TestWorkspaceInfoApi:
def test_post_success(self, app: Flask):
def test_post_success(self, app: Flask, workspace_session: scoped_session[Session]):
api = WorkspaceInfoApi()
method = unwrap(api.post)
tenant = make_tenant()
workspace_session.add(tenant)
workspace_session.commit()
payload = {"name": "New Name"}
events = []
with (
app.test_request_context("/workspaces/info", json=payload),
patch("controllers.console.workspace.workspace.db.get_or_404", return_value=tenant),
patch("controllers.console.workspace.workspace.db.session", workspace_session),
patch(
"controllers.console.workspace.workspace.WorkspaceService.get_tenant_info",
side_effect=lambda *args, **kwargs: (
@@ -1,8 +1,11 @@
import json
from collections.abc import Iterator
from unittest.mock import MagicMock
import pytest
from pytest_mock import MockerFixture
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from core.agent.cot_agent_runner import CotAgentRunner
from core.agent.entities import AgentScratchpadUnit
@@ -26,7 +29,7 @@ class DummyRunner(CotAgentRunner):
@pytest.fixture
def runner(mocker: MockerFixture):
def runner(mocker: MockerFixture, sqlite_engine: Engine) -> Iterator[DummyRunner]:
# Prevent BaseAgentRunner __init__ from hitting database
mocker.patch(
"core.agent.base_agent_runner.BaseAgentRunner.organize_agent_history",
@@ -81,9 +84,12 @@ def runner(mocker: MockerFixture):
runner.agent_callback = None
runner.memory = None
runner.history_prompt_messages = []
runner.session = MagicMock()
runner.session = Session(sqlite_engine)
return runner
try:
yield runner
finally:
runner.session.close()
class TestFillInputs:
@@ -1,9 +1,12 @@
import json
from collections.abc import Iterator
from typing import Any
from unittest.mock import MagicMock
import pytest
from pytest_mock import MockerFixture
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from core.agent.errors import AgentMaxIterationError
from core.agent.fc_agent_runner import FunctionCallAgentRunner
@@ -69,7 +72,7 @@ class DummyResult:
@pytest.fixture
def runner(mocker: MockerFixture):
def runner(mocker: MockerFixture, sqlite_engine: Engine) -> Iterator[FunctionCallAgentRunner]:
# Completely bypass BaseAgentRunner __init__ to avoid DB / Flask context
mocker.patch(
"core.agent.base_agent_runner.BaseAgentRunner.__init__",
@@ -131,7 +134,7 @@ def runner(mocker: MockerFixture):
runner._current_thoughts = []
runner.files = []
runner.agent_callback = MagicMock()
runner.session = MagicMock()
runner.session = Session(sqlite_engine)
runner._init_prompt_tools = MagicMock(return_value=({}, []))
runner.create_agent_thought = MagicMock(return_value="thought1")
@@ -139,7 +142,10 @@ def runner(mocker: MockerFixture):
runner.recalc_llm_max_tokens = MagicMock()
runner.update_prompt_message_tool = MagicMock()
return runner
try:
yield runner
finally:
runner.session.close()
# ==============================
@@ -485,7 +485,11 @@ class TestBaseAppGeneratorExtras:
account.id = "account-id"
account.tenant_id = "tenant-id"
factory = base_app_generator._get_draft_var_saver_factory(InvokeFrom.DEBUGGER, account)
factory = base_app_generator._get_draft_var_saver_factory(
InvokeFrom.DEBUGGER,
account,
tenant_id="tenant-id",
)
saver = factory(
app_id="app-id",
node_id="node-id",
@@ -185,7 +185,11 @@ def test_generate_appends_pause_layer_and_forwards_state(mocker: MockerFixture):
return_value="converted",
)
mocker.patch.object(WorkflowAppGenerator, "_handle_response", return_value="response")
mocker.patch.object(WorkflowAppGenerator, "_get_draft_var_saver_factory", return_value=MagicMock())
draft_saver_factory = mocker.patch.object(
WorkflowAppGenerator,
"_get_draft_var_saver_factory",
return_value=MagicMock(),
)
pause_layer = MagicMock(name="pause-layer")
mocker.patch(
@@ -209,7 +213,7 @@ def test_generate_appends_pause_layer_and_forwards_state(mocker: MockerFixture):
mocker.patch("core.app.apps.workflow.app_generator.threading.Thread", DummyThread)
app_model = SimpleNamespace(mode="workflow")
app_model = SimpleNamespace(mode="workflow", tenant_id="tenant")
app_config = SimpleNamespace(app_id="app", tenant_id="tenant", workflow_id="wf")
application_generate_entity = SimpleNamespace(
task_id="task",
@@ -240,6 +244,7 @@ def test_generate_appends_pause_layer_and_forwards_state(mocker: MockerFixture):
assert result == "converted"
assert worker_kwargs["kwargs"]["graph_engine_layers"] == ("base-layer", pause_layer)
assert worker_kwargs["kwargs"]["graph_runtime_state"] is graph_runtime_state
assert draft_saver_factory.call_args.kwargs["tenant_id"] == app_model.tenant_id
def test_resume_path_runs_worker_with_runtime_state(mocker: MockerFixture):
@@ -301,7 +306,7 @@ def test_resume_path_runs_worker_with_runtime_state(mocker: MockerFixture):
pause_config = SimpleNamespace(session_factory=MagicMock(), state_owner_user_id="owner")
app_model = SimpleNamespace(mode="workflow")
app_model = SimpleNamespace(mode="workflow", tenant_id="tenant")
app_config = SimpleNamespace(app_id="app", tenant_id="tenant", workflow_id="workflow")
application_generate_entity = SimpleNamespace(
task_id="task",
@@ -4,6 +4,8 @@ from time import time
from unittest.mock import Mock
import pytest
from sqlalchemy import Engine
from sqlalchemy.orm import Session, sessionmaker
from core.app.app_config.entities import WorkflowUIBasedAppConfig
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom, WorkflowAppGenerateEntity
@@ -32,6 +34,13 @@ from models.model import AppMode
from repositories.factory import DifyAPIRepositoryFactory
@pytest.fixture
def sqlite_session_factory(sqlite_engine: Engine) -> sessionmaker[Session]:
"""Provide the real session factory injected into the persistence layer."""
return sessionmaker(sqlite_engine, expire_on_commit=False)
def _create_initialized_response_stream_filter() -> ResponseStreamFilter:
"""Build a `ResponseStreamFilter` that has already run `initialize()`.
@@ -211,27 +220,25 @@ class TestPauseStatePersistenceLayer:
workflow_execution_id=workflow_execution_id,
)
def test_init_with_dependency_injection(self):
session_factory = Mock(name="session_factory")
def test_init_with_dependency_injection(self, sqlite_session_factory: sessionmaker[Session]):
state_owner_user_id = "user-123"
layer = PauseStatePersistenceLayer(
session_factory=session_factory,
session_factory=sqlite_session_factory,
state_owner_user_id=state_owner_user_id,
generate_entity=self._create_generate_entity(),
response_stream_filter=ResponseStreamFilter(),
)
assert layer._session_maker is session_factory
assert layer._session_maker is sqlite_session_factory
assert layer._state_owner_user_id == state_owner_user_id
with pytest.raises(GraphEngineLayerNotInitializedError):
_ = layer.graph_runtime_state
assert layer.command_channel is None
def test_initialize_sets_dependencies(self):
session_factory = Mock(name="session_factory")
def test_initialize_sets_dependencies(self, sqlite_session_factory: sessionmaker[Session]):
layer = PauseStatePersistenceLayer(
session_factory=session_factory,
session_factory=sqlite_session_factory,
state_owner_user_id="owner",
generate_entity=self._create_generate_entity(),
response_stream_filter=ResponseStreamFilter(),
@@ -245,11 +252,12 @@ class TestPauseStatePersistenceLayer:
assert layer.graph_runtime_state is graph_runtime_state
assert layer.command_channel is command_channel
def test_on_event_with_graph_run_paused_event(self, monkeypatch: pytest.MonkeyPatch):
session_factory = Mock(name="session_factory")
def test_on_event_with_graph_run_paused_event(
self, monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session]
):
generate_entity = self._create_generate_entity(workflow_execution_id="run-123")
layer = PauseStatePersistenceLayer(
session_factory=session_factory,
session_factory=sqlite_session_factory,
state_owner_user_id="owner-123",
generate_entity=generate_entity,
response_stream_filter=_create_initialized_response_stream_filter(),
@@ -272,7 +280,7 @@ class TestPauseStatePersistenceLayer:
layer.on_event(event)
mock_factory.assert_called_once_with(session_factory)
mock_factory.assert_called_once_with(sqlite_session_factory)
assert mock_repo.create_workflow_pause.call_count == 1
call_kwargs = mock_repo.create_workflow_pause.call_args.kwargs
assert call_kwargs["workflow_run_id"] == "run-123"
@@ -285,11 +293,12 @@ class TestPauseStatePersistenceLayer:
assert isinstance(pause_reasons, list)
def test_on_event_enriches_hitl_pause_reasons_before_persisting(self, monkeypatch: pytest.MonkeyPatch):
session_factory = Mock(name="session_factory")
def test_on_event_enriches_hitl_pause_reasons_before_persisting(
self, monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session]
):
generate_entity = self._create_generate_entity(workflow_execution_id="run-123")
layer = PauseStatePersistenceLayer(
session_factory=session_factory,
session_factory=sqlite_session_factory,
state_owner_user_id="owner-123",
generate_entity=generate_entity,
response_stream_filter=_create_initialized_response_stream_filter(),
@@ -343,10 +352,11 @@ class TestPauseStatePersistenceLayer:
)
assert mock_repo.create_workflow_pause.call_args.kwargs["pause_reasons"] == [enriched_reason]
def test_on_event_ignores_non_paused_events(self, monkeypatch: pytest.MonkeyPatch):
session_factory = Mock(name="session_factory")
def test_on_event_ignores_non_paused_events(
self, monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session]
):
layer = PauseStatePersistenceLayer(
session_factory=session_factory,
session_factory=sqlite_session_factory,
state_owner_user_id="owner-123",
generate_entity=self._create_generate_entity(),
response_stream_filter=ResponseStreamFilter(),
@@ -372,10 +382,11 @@ class TestPauseStatePersistenceLayer:
mock_factory.assert_not_called()
mock_repo.create_workflow_pause.assert_not_called()
def test_on_event_raises_when_graph_runtime_state_is_uninitialized(self):
session_factory = Mock(name="session_factory")
def test_on_event_raises_when_graph_runtime_state_is_uninitialized(
self, sqlite_session_factory: sessionmaker[Session]
):
layer = PauseStatePersistenceLayer(
session_factory=session_factory,
session_factory=sqlite_session_factory,
state_owner_user_id="owner-123",
generate_entity=self._create_generate_entity(),
response_stream_filter=ResponseStreamFilter(),
@@ -386,10 +397,11 @@ class TestPauseStatePersistenceLayer:
with pytest.raises(GraphEngineLayerNotInitializedError):
layer.on_event(event)
def test_on_event_asserts_when_workflow_execution_id_missing(self, monkeypatch: pytest.MonkeyPatch):
session_factory = Mock(name="session_factory")
def test_on_event_asserts_when_workflow_execution_id_missing(
self, monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session]
):
layer = PauseStatePersistenceLayer(
session_factory=session_factory,
session_factory=sqlite_session_factory,
state_owner_user_id="owner-123",
generate_entity=self._create_generate_entity(),
response_stream_filter=_create_initialized_response_stream_filter(),
@@ -494,12 +506,13 @@ def test_workflow_resumption_context_dumps_loads_roundtrip(state: WorkflowResump
assert restored_entity.extras["trace_session_id"] == "session-1"
def test_on_event_persists_response_stream_filter_dump(monkeypatch: pytest.MonkeyPatch) -> None:
session_factory = Mock(name="session_factory")
def test_on_event_persists_response_stream_filter_dump(
monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session]
) -> None:
generate_entity = TestPauseStatePersistenceLayer._create_generate_entity(workflow_execution_id="run-123")
response_stream_filter = _create_initialized_response_stream_filter()
layer = PauseStatePersistenceLayer(
session_factory=session_factory,
session_factory=sqlite_session_factory,
state_owner_user_id="owner-123",
generate_entity=generate_entity,
response_stream_filter=response_stream_filter,
@@ -1,15 +1,52 @@
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from sqlalchemy.orm import Session
from clients.agent_backend.errors import AgentBackendRunFailedError
from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.entities.queue_entities import QueueErrorEvent
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
from core.errors.error import QuotaExceededError
from graphon.model_runtime.errors.invoke import InvokeAuthorizationError, InvokeError, InvokeRateLimitError
from models.enums import MessageStatus
from models.enums import ConversationFromSource, MessageStatus
from models.model import AppMode, Message
def _persist_message(session: Session, *, message_id: str) -> Message:
message = Message(
id=message_id,
app_id="app-1",
model_provider=None,
model_id=None,
override_model_configs=None,
conversation_id="conversation-1",
inputs={},
query="query",
message={},
message_unit_price=Decimal(0),
answer="",
answer_unit_price=Decimal(0),
parent_message_id=None,
total_price=None,
currency="USD",
status=MessageStatus.NORMAL,
error=None,
message_metadata=None,
invoke_from=InvokeFrom.WEB_APP,
from_source=ConversationFromSource.CONSOLE,
from_end_user_id=None,
from_account_id="account-1",
workflow_run_id=None,
app_mode=AppMode.COMPLETION,
)
session.add(message)
session.commit()
session.expunge_all()
return message
class TestBasedGenerateTaskPipeline:
@@ -58,26 +95,33 @@ class TestBasedGenerateTaskPipeline:
assert "Knowledge retrieval failed" in str(err)
assert "agent_run_id=run-1" in str(err)
def test_handle_error_updates_message_when_found(self, pipeline):
@pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True)
def test_handle_error_updates_message_when_found(self, pipeline, sqlite_session: Session):
event = QueueErrorEvent(error=ValueError("oops"))
message = SimpleNamespace(status=MessageStatus.NORMAL, error=None)
session = Mock()
session.scalar.return_value = message
_persist_message(sqlite_session, message_id="msg-1")
err = pipeline.handle_error(event=event, session=session, message_id="msg-1")
err = pipeline.handle_error(event=event, session=sqlite_session, message_id="msg-1")
assert err is event.error
assert message.status == MessageStatus.ERROR
assert message.error == "oops"
sqlite_session.flush()
sqlite_session.expire_all()
updated_message = sqlite_session.get(Message, "msg-1")
assert updated_message is not None
assert updated_message.status == MessageStatus.ERROR
assert updated_message.error == "oops"
def test_handle_error_returns_err_when_message_missing(self, pipeline):
@pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True)
def test_handle_error_returns_err_when_message_missing(self, pipeline, sqlite_session: Session):
event = QueueErrorEvent(error=ValueError("oops"))
session = Mock()
session.scalar.return_value = None
_persist_message(sqlite_session, message_id="other-message")
err = pipeline.handle_error(event=event, session=session, message_id="msg-1")
err = pipeline.handle_error(event=event, session=sqlite_session, message_id="msg-1")
assert err is event.error
untouched_message = sqlite_session.get(Message, "other-message")
assert untouched_message is not None
assert untouched_message.status == MessageStatus.NORMAL
assert untouched_message.error is None
def test_error_to_stream_response_and_ping(self, pipeline):
error_response = pipeline.error_to_stream_response(ValueError("boom"))
@@ -1,11 +1,51 @@
"""Unit tests for Notion extraction, HTTP parsing, and persisted metadata updates."""
import json
import uuid
from collections.abc import Iterator
from types import SimpleNamespace
from unittest import mock
import httpx
import pytest
from pytest_mock import MockerFixture
from sqlalchemy import Engine
from sqlalchemy.orm import Session, sessionmaker
from core.rag.extractor import notion_extractor
from core.rag.index_processor.constant.index_type import IndexStructureType
from models.base import TypeBase
from models.dataset import Document as DocumentModel
from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus
@pytest.fixture
def persisted_document(
monkeypatch: pytest.MonkeyPatch,
sqlite_engine: Engine,
) -> Iterator[tuple[sessionmaker[Session], DocumentModel]]:
"""Persist a Notion document and bind the extractor's ``db.session`` to SQLite."""
TypeBase.metadata.create_all(sqlite_engine, tables=[DocumentModel.__table__])
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
document = DocumentModel(
id=str(uuid.uuid4()),
tenant_id=str(uuid.uuid4()),
dataset_id=str(uuid.uuid4()),
position=1,
data_source_type=DataSourceType.NOTION_IMPORT,
data_source_info=json.dumps({"source": "notion", "last_edited_time": "2025-01-01T00:00:00.000Z"}),
batch="batch",
name="Notion page",
created_from=DocumentCreatedFrom.API,
created_by=str(uuid.uuid4()),
indexing_status=IndexingStatus.COMPLETED,
doc_form=IndexStructureType.PARAGRAPH_INDEX,
)
with session_maker() as sqlite_session:
sqlite_session.add(document)
sqlite_session.commit()
monkeypatch.setattr(notion_extractor, "db", SimpleNamespace(session=sqlite_session))
yield session_maker, document
def _mock_response(data, status_code: int = 200, text: str = ""):
@@ -394,7 +434,11 @@ class TestNotionMetadataAndCredentialMethods:
assert extractor.update_last_edited_time(None) is None
def test_update_last_edited_time_updates_document_and_commits(self, monkeypatch: pytest.MonkeyPatch):
def test_update_last_edited_time_updates_document_and_commits(
self,
monkeypatch: pytest.MonkeyPatch,
persisted_document: tuple[sessionmaker[Session], DocumentModel],
):
extractor = notion_extractor.NotionExtractor(
notion_workspace_id="ws",
notion_obj_id="obj",
@@ -402,40 +446,20 @@ class TestNotionMetadataAndCredentialMethods:
tenant_id="tenant",
notion_access_token="token",
)
class FakeDocumentModel:
data_source_info = "data_source_info"
id = "id"
execute_calls = []
class FakeUpdateStmt:
def where(self, *args):
return self
def values(self, **kwargs):
return self
class FakeSession:
committed = False
def execute(self, stmt):
execute_calls.append(stmt)
def commit(self):
self.committed = True
fake_db = SimpleNamespace(session=FakeSession())
monkeypatch.setattr(notion_extractor, "DocumentModel", FakeDocumentModel)
monkeypatch.setattr(notion_extractor, "update", lambda model: FakeUpdateStmt())
monkeypatch.setattr(notion_extractor, "db", fake_db)
monkeypatch.setattr(extractor, "get_notion_last_edited_time", lambda: "2026-01-01T00:00:00.000Z")
session_maker, document = persisted_document
doc_model = SimpleNamespace(id="doc-1", data_source_info_dict={"source": "notion"})
extractor.update_last_edited_time(doc_model)
extractor.update_last_edited_time(document)
assert execute_calls
assert fake_db.session.committed is True
# Closing the writer session rolls back an uncommitted update before the independent read below.
notion_extractor.db.session.close()
with session_maker() as verification_session:
stored_document = verification_session.get(DocumentModel, document.id)
assert stored_document is not None
assert stored_document.data_source_info_dict == {
"source": "notion",
"last_edited_time": "2026-01-01T00:00:00.000Z",
}
def test_get_notion_last_edited_time_uses_page_and_database_urls(self, mocker: MockerFixture):
extractor_page = notion_extractor.NotionExtractor(
@@ -176,6 +176,22 @@ class TestWaterCrawlAPIClient:
with pytest.raises(expected_exception):
client.process_response(_response(status, {"message": "bad", "errors": {"url": ["x"]}}))
@pytest.mark.parametrize(
("status", "expected_exception"),
[
(401, WaterCrawlAuthenticationError),
(403, WaterCrawlPermissionError),
(422, WaterCrawlBadRequestError),
],
)
def test_process_response_error_statuses_with_non_json_body(self, status: int, expected_exception: type[Exception]):
client = WaterCrawlAPIClient(api_key="k")
response = _response(status, text="<html>upstream error</html>")
response.json.side_effect = json.JSONDecodeError("Expecting value", response.text, 0)
with pytest.raises(expected_exception):
client.process_response(response)
def test_process_response_204_returns_none(self):
client = WaterCrawlAPIClient(api_key="k")
assert client.process_response(_response(204, None)) is None
@@ -185,6 +201,14 @@ class TestWaterCrawlAPIClient:
assert client.process_response(_response(200, {"ok": True})) == {"ok": True}
assert client.process_response(_response(200, None)) == {}
def test_process_response_json_payload_with_invalid_body_raises_clear_error(self):
client = WaterCrawlAPIClient(api_key="k")
response = _response(200, text="<html>upstream error</html>")
response.json.side_effect = json.JSONDecodeError("Expecting value", response.text, 0)
with pytest.raises(ValueError, match="Invalid JSON response from WaterCrawl"):
client.process_response(response)
def test_process_response_accepts_json_content_type_parameters(self):
client = WaterCrawlAPIClient(api_key="k")
@@ -3,7 +3,10 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import Mock, patch
import pytest
from sqlalchemy.orm import Session
from core.app.app_config.entities import DatasetRetrieveConfigEntity
from core.app.entities.app_invoke_entities import InvokeFrom
@@ -14,13 +17,14 @@ def _retrieve_config() -> DatasetRetrieveConfigEntity:
return DatasetRetrieveConfigEntity(retrieve_strategy=DatasetRetrieveConfigEntity.RetrieveStrategy.MULTIPLE)
def test_get_dataset_tools_returns_empty_for_empty_dataset_ids() -> None:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_get_dataset_tools_returns_empty_for_empty_dataset_ids(sqlite_session: Session) -> None:
# Arrange
retrieve_config = _retrieve_config()
# Act
tools = DatasetRetrieverTool.get_dataset_tools(
session=MagicMock(),
session=sqlite_session,
tenant_id="tenant",
dataset_ids=[],
retrieve_config=retrieve_config,
@@ -35,13 +39,14 @@ def test_get_dataset_tools_returns_empty_for_empty_dataset_ids() -> None:
assert tools == []
def test_get_dataset_tools_returns_empty_for_missing_retrieve_config() -> None:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_get_dataset_tools_returns_empty_for_missing_retrieve_config(sqlite_session: Session) -> None:
# Arrange
dataset_ids = ["d1"]
# Act
tools = DatasetRetrieverTool.get_dataset_tools(
session=MagicMock(),
session=sqlite_session,
tenant_id="tenant",
dataset_ids=dataset_ids,
retrieve_config=None, # type: ignore[arg-type]
@@ -56,7 +61,8 @@ def test_get_dataset_tools_returns_empty_for_missing_retrieve_config() -> None:
assert tools == []
def test_get_dataset_tools_builds_tool_and_restores_strategy() -> None:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_get_dataset_tools_builds_tool_and_restores_strategy(sqlite_session: Session) -> None:
# Arrange
retrieve_config = _retrieve_config()
retrieval_tool = SimpleNamespace(name="dataset_tool", description="desc", run=lambda query: f"result:{query}")
@@ -66,7 +72,7 @@ def test_get_dataset_tools_builds_tool_and_restores_strategy() -> None:
# Act
with patch("core.tools.utils.dataset_retriever_tool.DatasetRetrieval", return_value=feature):
tools = DatasetRetrieverTool.get_dataset_tools(
session=MagicMock(),
session=sqlite_session,
tenant_id="tenant",
dataset_ids=["d1"],
retrieve_config=retrieve_config,
@@ -83,7 +89,7 @@ def test_get_dataset_tools_builds_tool_and_restores_strategy() -> None:
assert retrieve_config.retrieve_strategy == DatasetRetrieveConfigEntity.RetrieveStrategy.MULTIPLE
def _build_dataset_tool() -> tuple[DatasetRetrieverTool, SimpleNamespace]:
def _build_dataset_tool(sqlite_session: Session) -> tuple[DatasetRetrieverTool, SimpleNamespace]:
retrieval_tool = SimpleNamespace(
name="dataset_tool",
description="desc",
@@ -93,7 +99,7 @@ def _build_dataset_tool() -> tuple[DatasetRetrieverTool, SimpleNamespace]:
feature.to_dataset_retriever_tool.return_value = [retrieval_tool]
with patch("core.tools.utils.dataset_retriever_tool.DatasetRetrieval", return_value=feature):
tools = DatasetRetrieverTool.get_dataset_tools(
session=MagicMock(),
session=sqlite_session,
tenant_id="tenant",
dataset_ids=["d1"],
retrieve_config=_retrieve_config(),
@@ -106,9 +112,10 @@ def _build_dataset_tool() -> tuple[DatasetRetrieverTool, SimpleNamespace]:
return tools[0], retrieval_tool
def test_runtime_parameters_shape() -> None:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_runtime_parameters_shape(sqlite_session: Session) -> None:
# Arrange
tool, _ = _build_dataset_tool()
tool, _ = _build_dataset_tool(sqlite_session)
# Act
params = tool.get_runtime_parameters()
@@ -118,33 +125,36 @@ def test_runtime_parameters_shape() -> None:
assert params[0].name == "query"
def test_empty_query_behavior() -> None:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_empty_query_behavior(sqlite_session: Session) -> None:
# Arrange
tool, _ = _build_dataset_tool()
tool, _ = _build_dataset_tool(sqlite_session)
# Act
empty_query = list(tool.invoke(session=MagicMock(), user_id="u", tool_parameters={}))
empty_query = list(tool.invoke(session=sqlite_session, user_id="u", tool_parameters={}))
# Assert
assert len(empty_query) == 1
assert empty_query[0].message.text == "please input query"
def test_query_invocation_result() -> None:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_query_invocation_result(sqlite_session: Session) -> None:
# Arrange
tool, _ = _build_dataset_tool()
tool, _ = _build_dataset_tool(sqlite_session)
# Act
result = list(tool.invoke(session=MagicMock(), user_id="u", tool_parameters={"query": "hello"}))
result = list(tool.invoke(session=sqlite_session, user_id="u", tool_parameters={"query": "hello"}))
# Assert
assert len(result) == 1
assert result[0].message.text == "result:hello"
def test_validate_credentials() -> None:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_validate_credentials(sqlite_session: Session) -> None:
# Arrange
tool, _ = _build_dataset_tool()
tool, _ = _build_dataset_tool(sqlite_session)
# Act
result = tool.validate_credentials(credentials={}, parameters={}, format_only=False)
@@ -1,9 +1,12 @@
"""MCP tool tests using real SQLite sessions for the ORM invocation contract."""
from __future__ import annotations
import base64
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from sqlalchemy.orm import Session
from core.app.entities.app_invoke_entities import InvokeFrom
from core.mcp.types import (
@@ -97,7 +100,8 @@ def test_mcp_tool_usage_extraction_helpers():
assert derived.total_tokens == 0
def test_mcp_tool_invoke_handles_content_types_and_structured_output():
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_mcp_tool_invoke_handles_content_types_and_structured_output(sqlite_session: Session):
tool = _build_mcp_tool()
img_data = base64.b64encode(b"img").decode()
blob_data = base64.b64encode(b"blob").decode()
@@ -123,7 +127,7 @@ def test_mcp_tool_invoke_handles_content_types_and_structured_output():
)
with patch.object(MCPTool, "invoke_remote_mcp_tool", return_value=result):
messages = list(tool.invoke(session=MagicMock(), user_id="user-1", tool_parameters={"a": 1}))
messages = list(tool.invoke(session=sqlite_session, user_id="user-1", tool_parameters={"a": 1}))
types = [m.type for m in messages]
assert ToolInvokeMessage.MessageType.JSON in types
@@ -133,7 +137,8 @@ def test_mcp_tool_invoke_handles_content_types_and_structured_output():
assert tool.latest_usage.total_tokens == 5
def test_mcp_tool_invoke_raises_for_unsupported_embedded_resource():
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_mcp_tool_invoke_raises_for_unsupported_embedded_resource(sqlite_session: Session):
tool = _build_mcp_tool()
# Use model_construct to bypass pydantic validation and force unsupported resource path.
bad_resource = EmbeddedResource.model_construct(type="resource", resource=object())
@@ -141,7 +146,7 @@ def test_mcp_tool_invoke_raises_for_unsupported_embedded_resource():
with patch.object(MCPTool, "invoke_remote_mcp_tool", return_value=result):
with pytest.raises(ToolInvokeError, match="Unsupported embedded resource type"):
list(tool.invoke(session=MagicMock(), user_id="user-1", tool_parameters={}))
list(tool.invoke(session=sqlite_session, user_id="user-1", tool_parameters={}))
def test_mcp_tool_handle_none_parameter_filters_empty_values():
@@ -1,27 +1,61 @@
"""Unit tests for `ToolFileManager` behavior.
"""Unit tests for ``ToolFileManager`` behavior.
Covers signing, file persistence flows, and retrieval APIs with mocked
storage/session boundaries (httpx, SimpleNamespace, Mock/patch) to avoid real
IO.
File metadata is persisted through real SQLite-backed sessions. Storage and
remote HTTP remain mocked because they are external I/O boundaries.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
from collections.abc import Iterator
from unittest.mock import Mock, patch
from uuid import UUID, uuid4
import httpx
import pytest
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
import core.tools.tool_file_manager as tool_file_manager_module
from core.tools.tool_file_manager import ToolFileManager
from graphon.file import FileTransferMethod
from graphon.file import FileTransferMethod, FileType
from models.base import TypeBase
from models.enums import CreatorUserRole
from models.model import MessageFile
from models.tools import ToolFile
def _patch_session_factory(session: Mock):
session_cm = MagicMock()
session_cm.__enter__.return_value = session
session_cm.__exit__.return_value = False
return patch("core.tools.tool_file_manager.session_factory.create_session", return_value=session_cm)
@pytest.fixture
def sqlite_tool_file_session(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> Iterator[Session]:
"""Bind manager-owned sessions to SQLite and expose a setup/assertion session."""
TypeBase.metadata.create_all(sqlite_engine, tables=[ToolFile.__table__, MessageFile.__table__])
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
monkeypatch.setattr(tool_file_manager_module.session_factory, "create_session", factory)
with factory() as session:
yield session
def _tool_file(*, file_key: str = "k1", mimetype: str = "text/plain", name: str = "file.txt") -> ToolFile:
return ToolFile(
user_id=str(uuid4()),
tenant_id=str(uuid4()),
conversation_id=str(uuid4()),
file_key=file_key,
mimetype=mimetype,
original_url=None,
name=name,
size=12,
)
def _message_file(*, url: str | None) -> MessageFile:
return MessageFile(
message_id=str(uuid4()),
type=FileType.IMAGE,
transfer_method=FileTransferMethod.TOOL_FILE,
created_by_role=CreatorUserRole.ACCOUNT,
created_by=str(uuid4()),
url=url,
)
def test_tool_file_manager_sign_file_builds_url() -> None:
@@ -29,120 +63,105 @@ def test_tool_file_manager_sign_file_builds_url() -> None:
assert "/files/tools/tf-1.png" in url
def test_create_file_by_raw_stores_file_and_persists_record() -> None:
def test_create_file_by_raw_stores_file_and_persists_record(sqlite_tool_file_session: Session) -> None:
manager = ToolFileManager()
session = Mock()
session.refresh.side_effect = lambda model: setattr(model, "id", "tf-1")
def tool_file_factory(**kwargs):
return SimpleNamespace(**kwargs)
user_id = str(uuid4())
tenant_id = str(uuid4())
conversation_id = str(uuid4())
with (
patch("core.tools.tool_file_manager.storage") as storage,
patch("core.tools.tool_file_manager.ToolFile", side_effect=tool_file_factory),
patch("core.tools.tool_file_manager.guess_extension", return_value=".txt"),
patch("core.tools.tool_file_manager.uuid4", return_value=SimpleNamespace(hex="abc")),
_patch_session_factory(session),
patch("core.tools.tool_file_manager.uuid4", return_value=UUID(int=0xABC)),
):
file_model = manager.create_file_by_raw(
user_id="u1",
tenant_id="t1",
conversation_id="c1",
user_id=user_id,
tenant_id=tenant_id,
conversation_id=conversation_id,
file_binary=b"hello",
mimetype="text/plain",
filename="readme",
)
assert file_model.name.endswith(".txt")
storage.save.assert_called_once()
session.add.assert_called_once()
session.commit.assert_called_once()
session.refresh.assert_called_once_with(file_model)
persisted = sqlite_tool_file_session.get(ToolFile, file_model.id)
assert persisted is not None
assert persisted.name == "readme.txt"
assert persisted.file_key == f"tools/{tenant_id}/{UUID(int=0xABC).hex}.txt"
storage.save.assert_called_once_with(persisted.file_key, b"hello")
def test_create_file_by_raw_prefers_filename_extension_over_mimetype() -> None:
def test_create_file_by_raw_prefers_filename_extension_over_mimetype(
sqlite_tool_file_session: Session,
) -> None:
manager = ToolFileManager()
session = Mock()
session.refresh.side_effect = lambda model: setattr(model, "id", "tf-docx")
def tool_file_factory(**kwargs):
return SimpleNamespace(**kwargs)
tenant_id = str(uuid4())
with (
patch("core.tools.tool_file_manager.storage") as storage,
patch("core.tools.tool_file_manager.ToolFile", side_effect=tool_file_factory),
patch("core.tools.tool_file_manager.uuid4", return_value=SimpleNamespace(hex="abc")),
_patch_session_factory(session),
patch("core.tools.tool_file_manager.uuid4", return_value=UUID(int=0xABC)),
):
file_model = manager.create_file_by_raw(
user_id="u1",
tenant_id="t1",
conversation_id="c1",
user_id=str(uuid4()),
tenant_id=tenant_id,
conversation_id=str(uuid4()),
file_binary=b"docx",
mimetype="application/octet-stream",
filename="report.docx",
)
assert file_model.name == "report.docx"
assert file_model.file_key == "tools/t1/abc.docx"
storage.save.assert_called_once_with("tools/t1/abc.docx", b"docx")
session.add.assert_called_once_with(file_model)
session.commit.assert_called_once()
session.refresh.assert_called_once_with(file_model)
persisted = sqlite_tool_file_session.get(ToolFile, file_model.id)
assert persisted is not None
assert persisted.name == "report.docx"
assert persisted.file_key == f"tools/{tenant_id}/{UUID(int=0xABC).hex}.docx"
storage.save.assert_called_once_with(persisted.file_key, b"docx")
def test_create_file_by_url_downloads_and_persists_record() -> None:
def test_create_file_by_url_downloads_and_persists_record(sqlite_tool_file_session: Session) -> None:
manager = ToolFileManager()
tenant_id = str(uuid4())
response = Mock()
response.content = b"binary"
response.headers = {"Content-Type": "application/octet-stream"}
response.raise_for_status.return_value = None
session = Mock()
def tool_file_factory(**kwargs):
return SimpleNamespace(**kwargs)
session.refresh.side_effect = lambda model: setattr(model, "id", "tf-2")
with (
patch("core.tools.tool_file_manager.storage") as storage,
patch("core.tools.tool_file_manager.ToolFile", side_effect=tool_file_factory),
patch("core.tools.tool_file_manager.uuid4", return_value=SimpleNamespace(hex="def")),
_patch_session_factory(session),
patch("core.tools.tool_file_manager.uuid4", return_value=UUID(int=0xDEF)),
patch("core.tools.tool_file_manager.remote_fetcher.make_request", return_value=response),
):
file_model = manager.create_file_by_url("u1", "t1", "https://example.com/f.bin", "c1")
file_model = manager.create_file_by_url(str(uuid4()), tenant_id, "https://example.com/f.bin", str(uuid4()))
assert file_model.file_key.startswith("tools/t1/")
storage.save.assert_called_once()
session.add.assert_called_once_with(file_model)
session.commit.assert_called_once()
session.refresh.assert_called_once_with(file_model)
persisted = sqlite_tool_file_session.get(ToolFile, file_model.id)
assert persisted is not None
assert persisted.file_key == f"tools/{tenant_id}/{UUID(int=0xDEF).hex}.bin"
assert persisted.original_url == "https://example.com/f.bin"
storage.save.assert_called_once_with(persisted.file_key, b"binary")
def test_create_file_by_url_prefers_url_extension_over_mimetype() -> None:
def test_create_file_by_url_prefers_url_extension_over_mimetype(
sqlite_tool_file_session: Session,
) -> None:
manager = ToolFileManager()
tenant_id = str(uuid4())
response = Mock()
response.content = b"docx"
response.headers = {"Content-Type": "application/octet-stream"}
response.raise_for_status.return_value = None
session = Mock()
def tool_file_factory(**kwargs):
return SimpleNamespace(**kwargs)
session.refresh.side_effect = lambda model: setattr(model, "id", "tf-docx")
with (
patch("core.tools.tool_file_manager.storage") as storage,
patch("core.tools.tool_file_manager.ToolFile", side_effect=tool_file_factory),
patch("core.tools.tool_file_manager.uuid4", return_value=SimpleNamespace(hex="urlabc")),
_patch_session_factory(session),
patch("core.tools.tool_file_manager.uuid4", return_value=UUID(int=0xABC)),
patch("core.tools.tool_file_manager.remote_fetcher.make_request", return_value=response),
):
file_model = manager.create_file_by_url("u1", "t1", "https://example.com/report.docx?download=1", "c1")
file_model = manager.create_file_by_url(
str(uuid4()), tenant_id, "https://example.com/report.docx?download=1", str(uuid4())
)
assert file_model.file_key == "tools/t1/urlabc.docx"
assert file_model.name == "urlabc.docx"
storage.save.assert_called_once_with("tools/t1/urlabc.docx", b"docx")
persisted = sqlite_tool_file_session.get(ToolFile, file_model.id)
assert persisted is not None
assert persisted.file_key == f"tools/{tenant_id}/{UUID(int=0xABC).hex}.docx"
assert persisted.name == f"{UUID(int=0xABC).hex}.docx"
storage.save.assert_called_once_with(persisted.file_key, b"docx")
def test_create_file_by_url_raises_on_timeout() -> None:
@@ -156,121 +175,72 @@ def test_create_file_by_url_raises_on_timeout() -> None:
manager.create_file_by_url("u1", "t1", "https://example.com/f.bin", "c1")
def test_get_file_binary_returns_none_when_not_found() -> None:
# Arrange
manager = ToolFileManager()
session = Mock()
session.scalar.return_value = None
# Act
with _patch_session_factory(session):
result = manager.get_file_binary("missing")
# Assert
assert result is None
def test_get_file_binary_returns_none_when_not_found(sqlite_tool_file_session: Session) -> None:
assert ToolFileManager().get_file_binary(str(uuid4())) is None
def test_get_file_binary_returns_bytes_when_found() -> None:
# Arrange
manager = ToolFileManager()
tool_file = SimpleNamespace(file_key="k1", mimetype="text/plain")
session = Mock()
session.scalar.return_value = tool_file
def test_get_file_binary_returns_bytes_when_found(sqlite_tool_file_session: Session) -> None:
tool_file = _tool_file()
sqlite_tool_file_session.add(tool_file)
sqlite_tool_file_session.commit()
# Act
with patch("core.tools.tool_file_manager.storage") as storage:
storage.load_once.return_value = b"hello"
with _patch_session_factory(session):
result = manager.get_file_binary("id1")
result = ToolFileManager().get_file_binary(tool_file.id)
# Assert
assert result == (b"hello", "text/plain")
storage.load_once.assert_called_once_with("k1")
def test_get_file_binary_by_message_file_id_when_messagefile_missing() -> None:
# Arrange
manager = ToolFileManager()
session = Mock()
session.scalar.side_effect = [None, None]
# Act
with _patch_session_factory(session):
result = manager.get_file_binary_by_message_file_id("mf-1")
# Assert
assert result is None
def test_get_file_binary_by_message_file_id_when_messagefile_missing(
sqlite_tool_file_session: Session,
) -> None:
assert ToolFileManager().get_file_binary_by_message_file_id(str(uuid4())) is None
def test_get_file_binary_by_message_file_id_when_url_is_none() -> None:
# Arrange
manager = ToolFileManager()
message_file = SimpleNamespace(url=None)
session = Mock()
session.scalar.side_effect = [message_file, None]
def test_get_file_binary_by_message_file_id_when_url_is_none(sqlite_tool_file_session: Session) -> None:
message_file = _message_file(url=None)
sqlite_tool_file_session.add(message_file)
sqlite_tool_file_session.commit()
# Act
with _patch_session_factory(session):
result = manager.get_file_binary_by_message_file_id("mf-1")
# Assert
assert result is None
assert ToolFileManager().get_file_binary_by_message_file_id(message_file.id) is None
def test_get_file_binary_by_message_file_id_returns_bytes_when_found() -> None:
# Arrange
manager = ToolFileManager()
message_file = SimpleNamespace(url="https://x/files/tools/tool123.png")
tool_file = SimpleNamespace(file_key="k2", mimetype="image/png")
session = Mock()
session.scalar.side_effect = [message_file, tool_file]
def test_get_file_binary_by_message_file_id_returns_bytes_when_found(
sqlite_tool_file_session: Session,
) -> None:
tool_file = _tool_file(file_key="k2", mimetype="image/png", name="image.png")
message_file = _message_file(url=f"https://x/files/tools/{tool_file.id}.png")
sqlite_tool_file_session.add_all([tool_file, message_file])
sqlite_tool_file_session.commit()
# Act
with patch("core.tools.tool_file_manager.storage") as storage:
storage.load_once.return_value = b"img"
with _patch_session_factory(session):
result = manager.get_file_binary_by_message_file_id("mf-1")
result = ToolFileManager().get_file_binary_by_message_file_id(message_file.id)
# Assert
assert result == (b"img", "image/png")
storage.load_once.assert_called_once_with("k2")
def test_get_file_generator_returns_none_when_toolfile_missing() -> None:
# Arrange
manager = ToolFileManager()
session = Mock()
session.scalar.return_value = None
def test_get_file_generator_returns_none_when_toolfile_missing(sqlite_tool_file_session: Session) -> None:
stream, tool_file = ToolFileManager().get_file_generator_by_tool_file_id(str(uuid4()))
# Act
with _patch_session_factory(session):
stream, tool_file = manager.get_file_generator_by_tool_file_id("tool123")
# Assert
assert stream is None
assert tool_file is None
def test_get_file_generator_returns_stream_when_found() -> None:
# Arrange
manager = ToolFileManager()
tool_file = SimpleNamespace(
id="tool123",
file_key="k2",
mimetype="image/png",
original_url=None,
name="image.png",
size=12,
)
session = Mock()
session.scalar.return_value = tool_file
def test_get_file_generator_returns_stream_when_found(sqlite_tool_file_session: Session) -> None:
tool_file = _tool_file(file_key="k2", mimetype="image/png", name="image.png")
sqlite_tool_file_session.add(tool_file)
sqlite_tool_file_session.commit()
# Act
with patch("core.tools.tool_file_manager.storage") as storage:
stream = iter([b"a", b"b"])
storage.load_stream.return_value = stream
with _patch_session_factory(session):
result_stream, result_file = manager.get_file_generator_by_tool_file_id("tool123")
assert list(result_stream) == [b"a", b"b"]
assert result_file is not None
assert result_file.related_id == "tool123"
assert result_file.mime_type == "image/png"
assert result_file.transfer_method == FileTransferMethod.TOOL_FILE
storage.load_stream.return_value = iter([b"a", b"b"])
result_stream, result_file = ToolFileManager().get_file_generator_by_tool_file_id(tool_file.id)
assert result_stream is not None
assert list(result_stream) == [b"a", b"b"]
assert result_file is not None
assert result_file.related_id == tool_file.id
assert result_file.mime_type == "image/png"
assert result_file.transfer_method == FileTransferMethod.TOOL_FILE
@@ -1,13 +1,12 @@
from collections.abc import Generator
from contextlib import contextmanager
from collections.abc import Iterator
from types import SimpleNamespace
from unittest.mock import ANY, patch
from uuid import uuid4
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy import select
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import Session, sessionmaker
from core.app.entities.app_invoke_entities import ChatAppGenerateEntity
from core.entities.provider_entities import ProviderQuotaType, QuotaUnit
@@ -16,40 +15,29 @@ from models import TenantCreditPool
from models.provider import ProviderType
@contextmanager
def _patched_credit_pool_session_factory(engine: Engine) -> Generator[None, None, None]:
session_maker = sessionmaker(bind=engine, expire_on_commit=False)
sessions = []
def _session():
session = session_maker()
sessions.append(session)
return session
with patch("events.event_handlers.update_provider_when_message_created.db", SimpleNamespace(session=_session)):
try:
yield
finally:
for session in sessions:
session.close()
@pytest.fixture
def credit_pool_session_factory(sqlite_engine: Engine) -> Iterator[sessionmaker[Session]]:
"""Bind message-created accounting to fixture-owned SQLite sessions."""
TenantCreditPool.__table__.create(sqlite_engine)
session_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
with patch("events.event_handlers.update_provider_when_message_created.db.session", session_factory):
yield session_factory
def test_message_created_trial_credit_accounting_does_not_raise_when_balance_is_insufficient() -> None:
engine = create_engine("sqlite:///:memory:")
TenantCreditPool.__table__.create(engine)
def test_message_created_trial_credit_accounting_does_not_raise_when_balance_is_insufficient(
credit_pool_session_factory: sessionmaker[Session],
) -> None:
tenant_id = str(uuid4())
pool_id = str(uuid4())
with engine.begin() as connection:
connection.execute(
TenantCreditPool.__table__.insert(),
{
"id": pool_id,
"tenant_id": tenant_id,
"pool_type": ProviderQuotaType.TRIAL,
"quota_limit": 10,
"quota_used": 9,
},
)
pool = TenantCreditPool(
tenant_id=tenant_id,
pool_type=ProviderQuotaType.TRIAL,
quota_limit=10,
quota_used=9,
)
pool.id = pool_id
with credit_pool_session_factory.begin() as session:
session.add(pool)
system_configuration = SimpleNamespace(
current_quota_type=ProviderQuotaType.TRIAL,
@@ -77,7 +65,6 @@ def test_message_created_trial_credit_accounting_does_not_raise_when_balance_is_
message = SimpleNamespace(message_tokens=2, answer_tokens=1)
with (
_patched_credit_pool_session_factory(engine),
patch.object(update_provider_when_message_created, "_execute_provider_updates"),
):
update_provider_when_message_created.handle(
@@ -85,8 +72,8 @@ def test_message_created_trial_credit_accounting_does_not_raise_when_balance_is_
application_generate_entity=application_generate_entity,
)
with engine.connect() as connection:
quota_used = connection.scalar(select(TenantCreditPool.quota_used).where(TenantCreditPool.id == pool_id))
with credit_pool_session_factory() as session:
quota_used = session.scalar(select(TenantCreditPool.quota_used).where(TenantCreditPool.id == pool_id))
assert quota_used == 10
@@ -1,36 +1,28 @@
import uuid
from unittest.mock import MagicMock, patch
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import UTC, datetime
from unittest.mock import patch
import pytest
from httpx import Response
from sqlalchemy import Engine
from sqlalchemy.orm import Session, sessionmaker
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from core.app.file_access import DatabaseFileAccessController, FileAccessScope, bind_file_access_scope
from core.workflow.file_reference import build_file_reference, parse_file_reference, resolve_file_record_id
from extensions.storage.storage_type import StorageType
from factories.file_factory.builders import build_from_mapping as _build_from_mapping
from graphon.file import File, FileTransferMethod, FileType, FileUploadConfig
from models import ToolFile, UploadFile
def _make_session_ctx_mock(scalar_return=None):
"""Return a mock usable as the ``session_factory.create_session()`` context manager.
Patch ``factories.file_factory.builders.session_factory`` and set
``mock_sf.create_session.return_value = <this mock>`` to intercept DB calls
without requiring a live Flask app or database engine.
"""
session = MagicMock()
session.__enter__.return_value = session
session.__exit__.return_value = False
session.scalar.return_value = scalar_return
return session
from models import CreatorUserRole, ToolFile, UploadFile
# Test Data
TEST_TENANT_ID = "test_tenant_id"
TEST_UPLOAD_FILE_ID = str(uuid.uuid4())
TEST_TOOL_FILE_ID = str(uuid.uuid4())
TEST_REMOTE_URL = "http://example.com/test.jpg"
TEST_END_USER_ID = "end-user-id"
TEST_ACCESS_CONTROLLER = DatabaseFileAccessController()
# Test Config
@@ -52,39 +44,52 @@ def build_from_mapping(*, mapping, tenant_id, config=None, strict_type_validatio
)
@dataclass(frozen=True)
class FileRecords:
session: Session
upload_file: UploadFile
tool_file: ToolFile
# Fixtures
@pytest.fixture
def mock_upload_file():
mock = MagicMock(spec=UploadFile)
mock.id = TEST_UPLOAD_FILE_ID
mock.tenant_id = TEST_TENANT_ID
mock.name = "test.jpg"
mock.extension = "jpg"
mock.mime_type = "image/jpeg"
mock.source_url = TEST_REMOTE_URL
mock.size = 1024
mock.key = "test_key"
session = _make_session_ctx_mock(scalar_return=mock)
with patch("factories.file_factory.builders.session_factory") as mock_sf:
mock_sf.create_session.return_value = session
# yield session.scalar so callers can inspect call_args and mutate return_value
yield session.scalar
@pytest.fixture(autouse=True)
def file_records(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> Iterator[FileRecords]:
"""Persist authorized file rows and bind builder-owned sessions to SQLite."""
UploadFile.metadata.create_all(sqlite_engine, tables=[UploadFile.__table__, ToolFile.__table__])
sqlite_session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
monkeypatch.setattr("core.db.session_factory._session_maker", sqlite_session_maker)
upload_file = UploadFile(
tenant_id=TEST_TENANT_ID,
storage_type=StorageType.LOCAL,
key="test_key",
name="test.jpg",
size=1024,
extension="jpg",
mime_type="image/jpeg",
created_by_role=CreatorUserRole.END_USER,
created_by=TEST_END_USER_ID,
created_at=datetime(2026, 1, 1, tzinfo=UTC),
used=False,
source_url=TEST_REMOTE_URL,
)
upload_file.id = TEST_UPLOAD_FILE_ID
tool_file = ToolFile(
user_id=TEST_END_USER_ID,
tenant_id=TEST_TENANT_ID,
conversation_id=None,
file_key="tool_file.pdf",
mimetype="application/pdf",
original_url="http://example.com/tool.pdf",
name="tool_file.pdf",
size=2048,
)
tool_file.id = TEST_TOOL_FILE_ID
@pytest.fixture
def mock_tool_file():
mock = MagicMock(spec=ToolFile)
mock.id = TEST_TOOL_FILE_ID
mock.tenant_id = TEST_TENANT_ID
mock.name = "tool_file.pdf"
mock.file_key = "tool_file.pdf"
mock.mimetype = "application/pdf"
mock.original_url = "http://example.com/tool.pdf"
mock.size = 2048
session = _make_session_ctx_mock(scalar_return=mock)
with patch("factories.file_factory.builders.session_factory") as mock_sf:
mock_sf.create_session.return_value = session
yield mock
with sqlite_session_maker() as session:
session.add_all([upload_file, tool_file])
session.commit()
yield FileRecords(session=session, upload_file=upload_file, tool_file=tool_file)
@pytest.fixture
@@ -122,7 +127,7 @@ def tool_file_mapping(file_type="document"):
# Tests
def test_build_from_mapping_backward_compatibility(mock_upload_file):
def test_build_from_mapping_backward_compatibility():
mapping = local_file_mapping(file_type="image")
file = build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
assert isinstance(file, File)
@@ -133,7 +138,7 @@ def test_build_from_mapping_backward_compatibility(mock_upload_file):
assert file.storage_key == "test_key"
def test_build_from_mapping_accepts_opaque_reference_for_local_file(mock_upload_file):
def test_build_from_mapping_accepts_opaque_reference_for_local_file():
mapping = {
"transfer_method": "local_file",
"reference": build_file_reference(record_id=TEST_UPLOAD_FILE_ID, storage_key="test_key"),
@@ -148,7 +153,7 @@ def test_build_from_mapping_accepts_opaque_reference_for_local_file(mock_upload_
assert resolve_file_record_id(file.reference) == TEST_UPLOAD_FILE_ID
def test_build_from_mapping_accepts_opaque_related_id_for_tool_file(mock_tool_file):
def test_build_from_mapping_accepts_opaque_related_id_for_tool_file():
mapping = {
"transfer_method": "tool_file",
"related_id": build_file_reference(record_id=TEST_TOOL_FILE_ID, storage_key="tool_file.pdf"),
@@ -165,10 +170,11 @@ def test_build_from_mapping_accepts_opaque_related_id_for_tool_file(mock_tool_fi
assert file.storage_key == "tool_file.pdf"
def test_build_from_mapping_prefers_tool_filename_extension_over_mimetype(mock_tool_file):
mock_tool_file.name = "report.docx"
mock_tool_file.file_key = "tools/test_tenant_id/file.bin"
mock_tool_file.mimetype = "application/octet-stream"
def test_build_from_mapping_prefers_tool_filename_extension_over_mimetype(file_records: FileRecords):
file_records.tool_file.name = "report.docx"
file_records.tool_file.file_key = "tools/test_tenant_id/file.bin"
file_records.tool_file.mimetype = "application/octet-stream"
file_records.session.commit()
mapping = tool_file_mapping(file_type="document")
file = build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
@@ -186,7 +192,7 @@ def test_build_from_mapping_prefers_tool_filename_extension_over_mimetype(mock_t
("document", False, "Detected file type does not match"),
],
)
def test_build_from_local_file_strict_validation(mock_upload_file, file_type, should_pass, expected_error):
def test_build_from_local_file_strict_validation(file_type, should_pass, expected_error):
mapping = local_file_mapping(file_type=file_type)
if should_pass:
file = build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID, strict_type_validation=True)
@@ -203,7 +209,7 @@ def test_build_from_local_file_strict_validation(mock_upload_file, file_type, sh
("image", False, "Detected file type does not match"),
],
)
def test_build_from_tool_file_strict_validation(mock_tool_file, file_type, should_pass, expected_error):
def test_build_from_tool_file_strict_validation(file_type, should_pass, expected_error):
"""Strict type validation for tool_file."""
mapping = tool_file_mapping(file_type=file_type)
if should_pass:
@@ -282,27 +288,27 @@ def test_build_from_remote_url_without_strict_validation(mock_http_head):
assert file.filename == "remote_test.jpg"
def test_tool_file_not_found():
def test_tool_file_not_found(file_records: FileRecords):
"""Test ToolFile not found in database."""
session = _make_session_ctx_mock(scalar_return=None)
with patch("factories.file_factory.builders.session_factory") as mock_sf:
mock_sf.create_session.return_value = session
mapping = tool_file_mapping()
with pytest.raises(ValueError, match=f"ToolFile {TEST_TOOL_FILE_ID} not found"):
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
file_records.session.delete(file_records.tool_file)
file_records.session.commit()
mapping = tool_file_mapping()
with pytest.raises(ValueError, match=f"ToolFile {TEST_TOOL_FILE_ID} not found"):
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
def test_local_file_not_found():
def test_local_file_not_found(file_records: FileRecords):
"""Test UploadFile not found in database."""
session = _make_session_ctx_mock(scalar_return=None)
with patch("factories.file_factory.builders.session_factory") as mock_sf:
mock_sf.create_session.return_value = session
mapping = local_file_mapping()
with pytest.raises(ValueError, match="Invalid upload file"):
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
file_records.session.delete(file_records.upload_file)
file_records.session.commit()
mapping = local_file_mapping()
with pytest.raises(ValueError, match="Invalid upload file"):
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
def test_build_without_type_specification(mock_upload_file):
def test_build_without_type_specification():
"""Test the situation where no file type is specified"""
mapping = {
"transfer_method": "local_file",
@@ -321,7 +327,7 @@ def test_build_without_type_specification(mock_upload_file):
("video", False, "File validation failed"),
],
)
def test_file_validation_with_config(mock_upload_file, file_type, should_pass, expected_error):
def test_file_validation_with_config(file_type, should_pass, expected_error):
"""Test the validation of files and configurations"""
mapping = local_file_mapping(file_type=file_type)
if should_pass:
@@ -354,73 +360,65 @@ def test_invalid_uuid_format():
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
def test_tenant_mismatch():
def test_tenant_mismatch(file_records: FileRecords):
"""Test that tenant mismatch raises security error."""
# Create a mock upload file with a different tenant_id
mock_file = MagicMock(spec=UploadFile)
mock_file.id = TEST_UPLOAD_FILE_ID
mock_file.tenant_id = "different_tenant_id"
mock_file.name = "test.jpg"
mock_file.extension = "jpg"
mock_file.mime_type = "image/jpeg"
mock_file.source_url = TEST_REMOTE_URL
mock_file.size = 1024
mock_file.key = "test_key"
file_records.upload_file.tenant_id = "different_tenant_id"
file_records.session.commit()
# Mock the database query to return None (no file found for this tenant)
session = _make_session_ctx_mock(scalar_return=None)
with patch("factories.file_factory.builders.session_factory") as mock_sf:
mock_sf.create_session.return_value = session
mapping = local_file_mapping()
with pytest.raises(ValueError, match="Invalid upload file"):
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
mapping = local_file_mapping()
with pytest.raises(ValueError, match="Invalid upload file"):
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
def test_build_from_mapping_scopes_upload_file_to_end_user(mock_upload_file):
def test_build_from_mapping_scopes_upload_file_to_end_user():
scope = FileAccessScope(
tenant_id=TEST_TENANT_ID,
user_id="end-user-id",
user_id=TEST_END_USER_ID,
user_from=UserFrom.END_USER,
invoke_from=InvokeFrom.WEB_APP,
)
with bind_file_access_scope(scope):
build_from_mapping(mapping=local_file_mapping(), tenant_id=TEST_TENANT_ID)
file = build_from_mapping(mapping=local_file_mapping(), tenant_id=TEST_TENANT_ID)
stmt = mock_upload_file.call_args.args[0]
whereclause = str(stmt.whereclause)
assert "upload_files.created_by_role" in whereclause
assert "upload_files.created_by" in whereclause
assert resolve_file_record_id(file.reference) == TEST_UPLOAD_FILE_ID
unauthorized_scope = FileAccessScope(
tenant_id=TEST_TENANT_ID,
user_id="different-end-user",
user_from=UserFrom.END_USER,
invoke_from=InvokeFrom.WEB_APP,
)
with bind_file_access_scope(unauthorized_scope):
with pytest.raises(ValueError, match="Invalid upload file"):
build_from_mapping(mapping=local_file_mapping(), tenant_id=TEST_TENANT_ID)
def test_build_from_mapping_scopes_tool_file_to_end_user():
tool_file = MagicMock(spec=ToolFile)
tool_file.id = TEST_TOOL_FILE_ID
tool_file.tenant_id = TEST_TENANT_ID
tool_file.name = "tool_file.pdf"
tool_file.file_key = "tool_file.pdf"
tool_file.mimetype = "application/pdf"
tool_file.original_url = "http://example.com/tool.pdf"
tool_file.size = 2048
scope = FileAccessScope(
tenant_id=TEST_TENANT_ID,
user_id="end-user-id",
user_id=TEST_END_USER_ID,
user_from=UserFrom.END_USER,
invoke_from=InvokeFrom.WEB_APP,
)
session = _make_session_ctx_mock(scalar_return=tool_file)
with patch("factories.file_factory.builders.session_factory") as mock_sf:
mock_sf.create_session.return_value = session
with bind_file_access_scope(scope):
with bind_file_access_scope(scope):
file = build_from_mapping(mapping=tool_file_mapping(), tenant_id=TEST_TENANT_ID)
assert resolve_file_record_id(file.reference) == TEST_TOOL_FILE_ID
unauthorized_scope = FileAccessScope(
tenant_id=TEST_TENANT_ID,
user_id="different-end-user",
user_from=UserFrom.END_USER,
invoke_from=InvokeFrom.WEB_APP,
)
with bind_file_access_scope(unauthorized_scope):
with pytest.raises(ValueError, match=f"ToolFile {TEST_TOOL_FILE_ID} not found"):
build_from_mapping(mapping=tool_file_mapping(), tenant_id=TEST_TENANT_ID)
stmt = session.scalar.call_args.args[0]
whereclause = str(stmt.whereclause)
assert "tool_files.user_id" in whereclause
def test_disallowed_file_types(mock_upload_file):
def test_disallowed_file_types():
"""Test that disallowed file types are rejected."""
# Config that only allows image and document types
restricted_config = FileUploadConfig(
@@ -433,12 +431,12 @@ def test_disallowed_file_types(mock_upload_file):
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID, config=restricted_config)
def test_disallowed_extensions(mock_upload_file):
def test_disallowed_extensions(file_records: FileRecords):
"""Test that disallowed file extensions are rejected for custom type."""
# Mock a file with .exe extension
mock_upload_file.return_value.extension = "exe"
mock_upload_file.return_value.name = "malicious.exe"
mock_upload_file.return_value.mime_type = "application/x-msdownload"
file_records.upload_file.extension = "exe"
file_records.upload_file.name = "malicious.exe"
file_records.upload_file.mime_type = "application/x-msdownload"
file_records.session.commit()
# Config that only allows specific extensions for custom files
restricted_config = FileUploadConfig(
@@ -456,11 +454,12 @@ def test_disallowed_extensions(mock_upload_file):
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID, config=restricted_config)
def test_custom_file_type_uses_extension_validation_under_strict_mode(mock_upload_file):
def test_custom_file_type_uses_extension_validation_under_strict_mode(file_records: FileRecords):
"""Custom form uploads are classified by the configured extension list."""
mock_upload_file.return_value.extension = "txt"
mock_upload_file.return_value.name = "notes.txt"
mock_upload_file.return_value.mime_type = "text/plain"
file_records.upload_file.extension = "txt"
file_records.upload_file.name = "notes.txt"
file_records.upload_file.mime_type = "text/plain"
file_records.session.commit()
custom_config = FileUploadConfig(
allowed_file_types=[FileType.CUSTOM],
@@ -6,6 +6,7 @@ import pytest
from core.app.entities.app_invoke_entities import InvokeFrom
from models.enums import ConversationFromSource, MessageStatus
from services.agent import observability_service as observability_service_module
from services.agent.observability_service import AgentLogQueryParams, AgentObservabilityService
@@ -66,7 +67,7 @@ def test_resolve_source_filters_accepts_multiple_structured_sources() -> None:
def test_statistics_all_source_includes_debugger_messages() -> None:
source_filter = AgentObservabilityService.resolve_source_filter("all")
scope_sql = AgentObservabilityService._statistics_message_scope_sql(source_filter)
scope_sql = AgentObservabilityService._statistics_webapp_message_scope_sql(source_filter)
assert "m.app_id = :app_id" in scope_sql
assert "m.invoke_from != :debugger" not in scope_sql
@@ -75,7 +76,7 @@ def test_statistics_all_source_includes_debugger_messages() -> None:
def test_statistics_explicit_source_filters_invoke_from() -> None:
source_filter = AgentObservabilityService.resolve_source_filter("debugger")
scope_sql = AgentObservabilityService._statistics_message_scope_sql(source_filter)
scope_sql = AgentObservabilityService._statistics_webapp_message_scope_sql(source_filter)
assert "m.invoke_from = :source" in scope_sql
@@ -83,7 +84,7 @@ def test_statistics_explicit_source_filters_invoke_from() -> None:
def test_statistics_workflow_app_source_covers_all_versions_and_nodes() -> None:
source_filter = AgentObservabilityService.resolve_source_filter("workflow:app-2")
scope_sql = AgentObservabilityService._statistics_message_scope_sql(source_filter)
scope_sql = AgentObservabilityService._statistics_workflow_binding_filters_sql(source_filter)
assert "wanb.app_id = :source_app_id" in scope_sql
assert "wanb.workflow_id = :workflow_id" not in scope_sql
@@ -91,6 +92,144 @@ def test_statistics_workflow_app_source_covers_all_versions_and_nodes() -> None:
assert "wanb.node_id = :node_id" not in scope_sql
def test_statistics_workflow_chat_context_only_uses_chat_runs() -> None:
source_filter = AgentObservabilityService.resolve_source_filter("workflow:app-2")
scope_sql = AgentObservabilityService._statistics_workflow_message_scope_sql(source_filter)
assert "wr.id = m.workflow_run_id" in scope_sql
assert "wr.type = :chat_workflow_type" in scope_sql
def test_workflow_metadata_numeric_sql_supports_postgresql_and_mysql(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(observability_service_module, "dify_config", SimpleNamespace(DB_TYPE="postgresql"))
postgres_sql = AgentObservabilityService._workflow_execution_metadata_numeric_sql(
("agent_log", "agent_backend", "usage", "total_tokens"), "BIGINT"
)
assert "CAST(wne.execution_metadata AS JSONB)" in postgres_sql
assert "#>> '{agent_log,agent_backend,usage,total_tokens}'" in postgres_sql
monkeypatch.setattr(observability_service_module, "dify_config", SimpleNamespace(DB_TYPE="mysql"))
mysql_sql = AgentObservabilityService._workflow_execution_metadata_numeric_sql(("total_tokens",), "BIGINT")
assert "JSON_EXTRACT(wne.execution_metadata, '$.total_tokens')" in mysql_sql
assert " AS UNSIGNED)" in mysql_sql
def test_workflow_statistics_include_run_without_message(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeResult:
def __init__(self, rows):
self._rows = rows
def all(self):
return self._rows
class FakeSession:
def __init__(self):
self.queries: list[str] = []
def execute(self, stmt, args):
query = str(stmt)
self.queries.append(query)
if "WITH agent_run_usage" in query:
return FakeResult(
[
SimpleNamespace(
_mapping={
"date": "2026-07-21",
"message_count": 1,
"conversation_count": 1,
"end_user_count": 1,
"token_count": 454_064,
"total_price": Decimal("2.323470"),
"avg_latency": 59.93,
"latency_sum": 59.93,
"answer_tokens": 2_126,
"like_count": 0,
}
)
]
)
return FakeResult([])
monkeypatch.setattr(observability_service_module, "dify_config", SimpleNamespace(DB_TYPE="postgresql"))
monkeypatch.setattr(
observability_service_module,
"convert_datetime_to_date",
lambda field: f"DATE({field})",
)
session = FakeSession()
service = AgentObservabilityService(session)
payload = service.get_statistics_summary(
app=SimpleNamespace(id="agent-app", tenant_id="tenant-1"), # type: ignore[arg-type]
agent_id="agent-1",
params=observability_service_module.AgentStatisticsQueryParams(source="workflow:workflow-app"),
)
assert payload["summary"]["total_messages"] == 1
assert payload["summary"]["total_conversations"] == 1
assert payload["summary"]["total_end_users"] == 1
assert payload["summary"]["total_tokens"] == 454_064
assert payload["summary"]["total_price"] == "2.323470"
assert len(session.queries) == 2
assert "FROM workflow_runs wr" in session.queries[0]
assert "FROM messages m" not in session.queries[0]
assert "WHERE wr.type != :chat_workflow_type" in session.queries[0]
assert "FROM messages m" in session.queries[1]
assert "COUNT(m.id) AS message_count" in session.queries[1]
assert "SUM(COALESCE(m.message_tokens, 0)" in session.queries[1]
def test_merge_daily_statistics_combines_webapp_and_workflow_rows() -> None:
rows = [
{
"date": "2026-07-21",
"message_count": 2,
"conversation_count": 1,
"end_user_count": 1,
"token_count": 30,
"total_price": Decimal("0.003"),
"avg_latency": 1.5,
"latency_sum": 3,
"answer_tokens": 12,
"like_count": 1,
},
{
"date": "2026-07-21",
"message_count": 1,
"conversation_count": 1,
"end_user_count": 1,
"token_count": 20,
"total_price": Decimal("0.002"),
"avg_latency": 2,
"latency_sum": 2,
"answer_tokens": 8,
"like_count": 0,
},
]
merged = AgentObservabilityService._merge_daily_statistics(rows)
assert merged == [
{
"date": "2026-07-21",
"message_count": 3,
"conversation_count": 2,
"end_user_count": 2,
"token_count": 50,
"total_price": Decimal("0.005"),
"avg_latency": pytest.approx(5 / 3),
"latency_sum": 5.0,
"answer_tokens": 20,
"like_count": 1,
}
]
def test_apply_status_filter_accepts_multiple_statuses() -> None:
class FakeStmt:
def __init__(self):
@@ -5,6 +5,7 @@ from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy.orm import Session
from services.agent.skill_tool_inference_service import (
SkillToolInferenceError,
@@ -29,7 +30,8 @@ def _service(preview=_SKILL_MD_PREVIEW):
return SkillToolInferenceService(drive_service=drive), drive
def test_infer_returns_suggestions_with_inferred_from(monkeypatch):
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_infer_returns_suggestions_with_inferred_from(monkeypatch, sqlite_session: Session):
service, drive = _service()
raw = (
'{"inferable": true, "reason": null, "cli_tools": [{"name": "ffmpeg",'
@@ -38,8 +40,12 @@ def test_infer_returns_suggestions_with_inferred_from(monkeypatch):
' "env_suggestions": [{"key": "OPENAI_API_KEY", "reason": "whisper call", "secret_likely": true}]}]}'
)
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
session = MagicMock()
result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=session)
result = service.infer(
tenant_id="t-1",
agent_id="a-1",
slug="audio-transcribe",
session=sqlite_session,
)
assert result["inferable"] is True
tool = result["cli_tools"][0]
@@ -47,11 +53,13 @@ def test_infer_returns_suggestions_with_inferred_from(monkeypatch):
assert tool["inferred_from"] == "audio-transcribe"
assert tool["env_suggestions"] == [{"key": "OPENAI_API_KEY", "reason": "whisper call", "secret_likely": True}]
drive.preview.assert_called_once_with(
tenant_id="t-1", agent_id="a-1", key="audio-transcribe/SKILL.md", session=session
tenant_id="t-1", agent_id="a-1", key="audio-transcribe/SKILL.md", session=sqlite_session
)
assert not sqlite_session.in_transaction()
def test_infer_threads_skill_md_into_the_prompt(monkeypatch):
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_infer_threads_skill_md_into_the_prompt(monkeypatch, sqlite_session: Session):
service, _ = _service()
captured: dict[str, str] = {}
@@ -60,21 +68,25 @@ def test_infer_threads_skill_md_into_the_prompt(monkeypatch):
return '{"inferable": false, "cli_tools": [], "reason": "none"}'
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(fake_invoke)):
service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=MagicMock())
service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session)
assert "Files inside the skill package" not in captured["prompt"]
assert "ffmpeg" in captured["prompt"] # SKILL.md body present
assert not sqlite_session.in_transaction()
def test_infer_not_inferable_passes_reason_through(monkeypatch):
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_infer_not_inferable_passes_reason_through(monkeypatch, sqlite_session: Session):
service, _ = _service()
raw = '{"inferable": false, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"}'
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=MagicMock())
result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session)
assert result == {"inferable": False, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"}
assert not sqlite_session.in_transaction()
def test_infer_retries_once_then_422(monkeypatch):
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_infer_retries_once_then_422(monkeypatch, sqlite_session: Session):
service, _ = _service()
calls: list[int] = []
@@ -84,37 +96,44 @@ def test_infer_retries_once_then_422(monkeypatch):
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(bad_invoke)):
with pytest.raises(SkillToolInferenceError) as exc_info:
service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=MagicMock())
service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session)
assert len(calls) == 2 # one retry
assert exc_info.value.code == "inference_failed"
assert exc_info.value.status_code == 422
assert not sqlite_session.in_transaction()
def test_infer_repairs_slightly_malformed_json(monkeypatch):
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_infer_repairs_slightly_malformed_json(monkeypatch, sqlite_session: Session):
service, _ = _service()
raw = 'Here you go: {"inferable": true, "cli_tools": [], "reason": null,}'
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=MagicMock())
result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session)
assert result["inferable"] is True
assert not sqlite_session.in_transaction()
def test_missing_skill_maps_to_404():
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_missing_skill_maps_to_404(sqlite_session: Session):
drive = MagicMock()
drive.preview.side_effect = AgentDriveError("drive_key_not_found", "nope", status_code=404)
service = SkillToolInferenceService(drive_service=drive)
with pytest.raises(SkillToolInferenceError) as exc_info:
service.infer(tenant_id="t-1", agent_id="a-1", slug="ghost", session=MagicMock())
service.infer(tenant_id="t-1", agent_id="a-1", slug="ghost", session=sqlite_session)
assert exc_info.value.code == "skill_not_found"
assert exc_info.value.status_code == 404
assert not sqlite_session.in_transaction()
def test_binary_skill_md_maps_to_404():
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_binary_skill_md_maps_to_404(sqlite_session: Session):
service, _ = _service(preview={"key": "x/SKILL.md", "size": 1, "truncated": False, "binary": True, "text": None})
with pytest.raises(SkillToolInferenceError) as exc_info:
service.infer(tenant_id="t-1", agent_id="a-1", slug="x", session=MagicMock())
service.infer(tenant_id="t-1", agent_id="a-1", slug="x", session=sqlite_session)
assert exc_info.value.code == "skill_not_found"
assert not sqlite_session.in_transaction()
# ── real-path coverage: _invoke / passthrough ────────────────────────────────
@@ -157,11 +176,13 @@ def test_invoke_maps_model_failure_to_422_and_success_returns_text(monkeypatch):
assert call["stream"] is False
def test_load_skill_md_passes_through_non_missing_drive_errors():
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_load_skill_md_passes_through_non_missing_drive_errors(sqlite_session: Session):
drive = MagicMock()
drive.preview.side_effect = AgentDriveError("agent_not_found", "tenant mismatch", status_code=404)
service = SkillToolInferenceService(drive_service=drive)
with pytest.raises(SkillToolInferenceError) as exc_info:
service.infer(tenant_id="t-1", agent_id="a-1", slug="x", session=MagicMock())
service.infer(tenant_id="t-1", agent_id="a-1", slug="x", session=sqlite_session)
assert exc_info.value.code == "agent_not_found"
assert not sqlite_session.in_transaction()
@@ -46,7 +46,7 @@ class TestCatalog:
assert call.tenant_id == "tenant-1"
assert call.account_id == "acct-1"
assert call.json is None
assert call.params == {"billing_enabled": svc.dify_config.BILLING_ENABLED}
assert call.params is None
assert len(out.groups) == 1
assert out.groups[0].group_key == "workspace"
@@ -624,12 +624,12 @@ class TestMyPermissions:
assert out.app.overrides == []
assert out.dataset.overrides == []
if role == "owner":
assert "billing.view" in out.workspace.permission_keys
assert "snippets.management" in out.workspace.permission_keys
assert "app.acl.preview" in out.workspace.permission_keys
assert "dataset.acl.preview" in out.workspace.permission_keys
assert "app.acl.preview" in out.app.default_permission_keys
assert "dataset.acl.preview" in out.dataset.default_permission_keys
assert not any(key.startswith("billing.") for key in out.workspace.permission_keys)
if role == "editor":
assert "app.acl.log_and_annotation" in out.app.default_permission_keys
@@ -1,4 +1,6 @@
import pytest
from pytest_mock import MockerFixture
from sqlalchemy.orm import Session
from services.rag_pipeline.pipeline_template.built_in.built_in_retrieval import BuiltInPipelineTemplateRetrieval
from services.rag_pipeline.pipeline_template.pipeline_template_type import PipelineTemplateType
@@ -10,7 +12,8 @@ def test_get_type() -> None:
assert retrieval.get_type() == PipelineTemplateType.BUILTIN
def test_get_pipeline_templates(mocker: MockerFixture) -> None:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_get_pipeline_templates(mocker: MockerFixture, sqlite_session: Session) -> None:
mocker.patch.object(
BuiltInPipelineTemplateRetrieval,
"_get_builtin_data",
@@ -22,14 +25,15 @@ def test_get_pipeline_templates(mocker: MockerFixture) -> None:
},
)
retrieval = BuiltInPipelineTemplateRetrieval()
session = mocker.Mock()
templates = retrieval.get_pipeline_templates("en-US", session=session)
templates = retrieval.get_pipeline_templates("en-US", session=sqlite_session)
assert templates == {"pipeline_templates": [{"id": "tpl-1"}]}
assert not sqlite_session.in_transaction()
def test_get_pipeline_template_detail(mocker: MockerFixture) -> None:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_get_pipeline_template_detail(mocker: MockerFixture, sqlite_session: Session) -> None:
mocker.patch.object(
BuiltInPipelineTemplateRetrieval,
"_get_builtin_data",
@@ -40,39 +44,45 @@ def test_get_pipeline_template_detail(mocker: MockerFixture) -> None:
},
)
retrieval = BuiltInPipelineTemplateRetrieval()
session = mocker.Mock()
detail = retrieval.get_pipeline_template_detail("tpl-1", session=session)
detail = retrieval.get_pipeline_template_detail("tpl-1", session=sqlite_session)
assert detail == {"id": "tpl-1", "name": "Template 1"}
assert not sqlite_session.in_transaction()
def test_get_pipeline_templates_missing_language_returns_empty_dict(mocker: MockerFixture) -> None:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_get_pipeline_templates_missing_language_returns_empty_dict(
mocker: MockerFixture, sqlite_session: Session
) -> None:
mocker.patch.object(
BuiltInPipelineTemplateRetrieval,
"_get_builtin_data",
return_value={"pipeline_templates": {}},
)
retrieval = BuiltInPipelineTemplateRetrieval()
session = mocker.Mock()
result = retrieval.get_pipeline_templates("fr-FR", session=session)
result = retrieval.get_pipeline_templates("fr-FR", session=sqlite_session)
assert result == {}
assert not sqlite_session.in_transaction()
def test_get_pipeline_template_detail_returns_none_for_unknown_id(mocker: MockerFixture) -> None:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_get_pipeline_template_detail_returns_none_for_unknown_id(
mocker: MockerFixture, sqlite_session: Session
) -> None:
mocker.patch.object(
BuiltInPipelineTemplateRetrieval,
"_get_builtin_data",
return_value={"pipeline_templates": {"tpl-1": {"id": "tpl-1"}}},
)
retrieval = BuiltInPipelineTemplateRetrieval()
session = mocker.Mock()
result = retrieval.get_pipeline_template_detail("nonexistent-id", session=session)
result = retrieval.get_pipeline_template_detail("nonexistent-id", session=sqlite_session)
assert result is None
assert not sqlite_session.in_transaction()
def test_get_builtin_data_reads_from_file_and_caches(mocker: MockerFixture) -> None:
@@ -1,35 +1,54 @@
from types import SimpleNamespace
from pytest_mock import MockerFixture
import pytest
from sqlalchemy.orm import Session
from models.dataset import PipelineBuiltInTemplate
from services.rag_pipeline.pipeline_template.database.database_retrieval import DatabasePipelineTemplateRetrieval
from services.rag_pipeline.pipeline_template.pipeline_template_type import PipelineTemplateType
TEMPLATE_ID = "11111111-1111-1111-1111-111111111111"
def test_get_pipeline_templates(mocker: MockerFixture) -> None:
built_in_template = SimpleNamespace(
id="tpl-1",
name="Template 1",
def _template(
*,
template_id: str = TEMPLATE_ID,
language: str = "en-US",
name: str = "Template 1",
) -> PipelineBuiltInTemplate:
template = PipelineBuiltInTemplate(
name=name,
description="desc",
icon={"background": "#fff"},
copyright="copyright",
privacy_policy="https://example.com/privacy",
position=1,
chunk_structure="general",
yaml_content="workflow:\n graph:\n nodes: []",
install_count=0,
language=language,
)
scalars_mock = mocker.Mock()
scalars_mock.all.return_value = [built_in_template]
session_mock = mocker.Mock()
session_mock.scalars.return_value = scalars_mock
template.id = template_id
return template
@pytest.mark.parametrize("sqlite_session", [(PipelineBuiltInTemplate,)], indirect=True)
def test_get_pipeline_templates(sqlite_session: Session) -> None:
target = _template()
wrong_language = _template(
template_id="22222222-2222-2222-2222-222222222222",
language="zh-Hans",
name="Wrong Language",
)
sqlite_session.add_all([target, wrong_language])
sqlite_session.commit()
retrieval = DatabasePipelineTemplateRetrieval()
result = retrieval.get_pipeline_templates("en-US", session=session_mock)
result = retrieval.get_pipeline_templates("en-US", session=sqlite_session)
assert retrieval.get_type() == PipelineTemplateType.DATABASE
assert result == {
"pipeline_templates": [
{
"id": "tpl-1",
"id": TEMPLATE_ID,
"name": "Template 1",
"description": "desc",
"icon": {"background": "#fff"},
@@ -40,24 +59,19 @@ def test_get_pipeline_templates(mocker: MockerFixture) -> None:
}
]
}
assert sqlite_session.in_transaction()
def test_get_pipeline_template_detail_returns_detail(mocker: MockerFixture) -> None:
session_mock = mocker.Mock()
session_mock.get.return_value = SimpleNamespace(
id="tpl-1",
name="Template 1",
icon={"background": "#fff"},
description="desc",
chunk_structure="general",
yaml_content="workflow:\n graph:\n nodes: []",
)
@pytest.mark.parametrize("sqlite_session", [(PipelineBuiltInTemplate,)], indirect=True)
def test_get_pipeline_template_detail_returns_detail(sqlite_session: Session) -> None:
sqlite_session.add(_template())
sqlite_session.commit()
retrieval = DatabasePipelineTemplateRetrieval()
detail = retrieval.get_pipeline_template_detail("tpl-1", session=session_mock)
detail = retrieval.get_pipeline_template_detail(TEMPLATE_ID, session=sqlite_session)
assert detail == {
"id": "tpl-1",
"id": TEMPLATE_ID,
"name": "Template 1",
"icon_info": {"background": "#fff"},
"description": "desc",
@@ -65,13 +79,14 @@ def test_get_pipeline_template_detail_returns_detail(mocker: MockerFixture) -> N
"export_data": "workflow:\n graph:\n nodes: []",
"graph": {"nodes": []},
}
assert sqlite_session.in_transaction()
def test_get_pipeline_template_detail_returns_none_when_not_found(mocker: MockerFixture) -> None:
session_mock = mocker.Mock()
session_mock.get.return_value = None
@pytest.mark.parametrize("sqlite_session", [(PipelineBuiltInTemplate,)], indirect=True)
def test_get_pipeline_template_detail_returns_none_when_not_found(sqlite_session: Session) -> None:
retrieval = DatabasePipelineTemplateRetrieval()
result = retrieval.get_pipeline_template_detail("missing", session=session_mock)
result = retrieval.get_pipeline_template_detail(TEMPLATE_ID, session=sqlite_session)
assert result is None
assert sqlite_session.in_transaction()
@@ -6,6 +6,7 @@ import datetime
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy.orm import Session
from repositories.api_workflow_run_repository import WorkflowRunCleanupRef
from services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs import WorkflowRunCleanup
@@ -472,24 +473,24 @@ class TestRunDryRunMode:
class TestTriggerLogMethods:
def test_delete_trigger_logs(self, cleanup):
session = MagicMock()
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_delete_trigger_logs(self, cleanup, sqlite_session: Session):
with patch(
"services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.SQLAlchemyWorkflowTriggerLogRepository"
) as RepoClass:
instance = RepoClass.return_value
instance.delete_by_run_ids.return_value = 5
result = cleanup._delete_trigger_logs(session, ["r1", "r2"])
result = cleanup._delete_trigger_logs(sqlite_session, ["r1", "r2"])
assert result == 5
def test_count_trigger_logs(self, cleanup):
session = MagicMock()
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_count_trigger_logs(self, cleanup, sqlite_session: Session):
with patch(
"services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.SQLAlchemyWorkflowTriggerLogRepository"
) as RepoClass:
instance = RepoClass.return_value
instance.count_by_run_ids.return_value = 3
result = cleanup._count_trigger_logs(session, ["r1"])
result = cleanup._count_trigger_logs(sqlite_session, ["r1"])
assert result == 3
@@ -499,26 +500,22 @@ class TestTriggerLogMethods:
class TestNodeExecutionMethods:
def test_count_node_executions(self, cleanup):
session = MagicMock()
session.get_bind.return_value = MagicMock()
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_count_node_executions(self, cleanup, sqlite_session: Session):
with patch(
"services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.DifyAPIRepositoryFactory"
) as factory:
repo = factory.create_api_workflow_node_execution_repository.return_value
repo.count_by_runs.return_value = (10, 2)
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.sessionmaker"):
result = cleanup._count_node_executions_by_run_ids(session, ["r1"])
result = cleanup._count_node_executions_by_run_ids(sqlite_session, ["r1"])
assert result == (10, 2)
def test_delete_node_executions(self, cleanup):
session = MagicMock()
session.get_bind.return_value = MagicMock()
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_delete_node_executions(self, cleanup, sqlite_session: Session):
with patch(
"services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.DifyAPIRepositoryFactory"
) as factory:
repo = factory.create_api_workflow_node_execution_repository.return_value
repo.delete_by_runs.return_value = (5, 1)
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.sessionmaker"):
result = cleanup._delete_node_executions_by_run_ids(session, ["r1"])
result = cleanup._delete_node_executions_by_run_ids(sqlite_session, ["r1"])
assert result == (5, 1)
@@ -692,30 +692,41 @@ class TestTenantService:
@pytest.mark.parametrize("sqlite_session", [(TenantAccountJoin,)], indirect=True)
def test_iter_member_account_id_batches_uses_offset_limit(self, sqlite_session: Session):
tenant_id = "00000000-0000-0000-0000-000000000001"
account_ids = [
"00000000-0000-0000-0000-000000000011",
"00000000-0000-0000-0000-000000000012",
"00000000-0000-0000-0000-000000000013",
]
for index, account_id in enumerate(account_ids, start=1):
membership = TenantAccountJoin(
tenant_id="00000000-0000-0000-0000-000000000001",
joins = [
TenantAccountJoin(
tenant_id=tenant_id,
account_id=account_id,
role=TenantAccountRole.NORMAL,
current=False,
)
membership.id = f"00000000-0000-0000-0000-{index:012d}"
sqlite_session.add(membership)
for account_id in account_ids
]
for index, join in enumerate(joins, start=21):
join.id = f"00000000-0000-0000-0000-{index:012d}"
sqlite_session.add_all(joins)
sqlite_session.commit()
batches = list(
TenantService.iter_member_account_id_batches(
"00000000-0000-0000-0000-000000000001",
2,
session=sqlite_session,
)
)
pagination_parameters: list[tuple[int, int]] = []
def record_sql(_conn, _cursor, statement, parameters, _context, _executemany):
if "FROM tenant_account_joins" in statement:
pagination_parameters.append((parameters[-2], parameters[-1]))
bind = sqlite_session.get_bind()
event.listen(bind, "before_cursor_execute", record_sql)
try:
batches = list(TenantService.iter_member_account_id_batches(tenant_id, 2, session=sqlite_session))
finally:
event.remove(bind, "before_cursor_execute", record_sql)
assert batches == [account_ids[:2], account_ids[2:]]
assert pagination_parameters == [(2, 0), (2, 2), (2, 4)]
# ==================== get_account_role_in_tenant Tests ====================
# Backs the auth pipeline's `load_workspace_role`: None => non-member
@@ -1216,7 +1227,8 @@ class TestTenantService:
mock_tenant, mock_operator, mock_member, "remove", session=sqlite_session
)
def test_get_rbac_workspace_owner_account_id(self):
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_get_rbac_workspace_owner_account_id(self, sqlite_session: Session):
mock_roles = Paginated[MembersInRole](data=[MembersInRole(account_id="owner-account")])
mock_rbac_roles = MagicMock()
mock_rbac_roles.members.return_value = mock_roles
@@ -1229,10 +1241,11 @@ class TestTenantService:
patch("services.account_service.RBACService.Roles", mock_rbac_roles),
):
owner_account_id = AccountService.get_rbac_workspace_owner_account_id(
"tenant-1", "acct-1", session=MagicMock()
"tenant-1", "acct-1", session=sqlite_session
)
assert owner_account_id == "owner-account"
assert not sqlite_session.in_transaction()
call = mock_rbac_roles.members.call_args
assert call.kwargs["tenant_id"] == "tenant-1"
assert call.kwargs["account_id"] == "acct-1"
@@ -1,22 +1,38 @@
"""Unit tests for API token caching and SQLite-backed token lookup."""
from __future__ import annotations
from collections.abc import Iterator
from datetime import datetime
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from werkzeug.exceptions import Unauthorized
import services.api_token_service as api_token_service_module
from models.engine import db
from models.model import ApiToken
from services.api_token_service import ApiTokenCache, CachedApiToken
@pytest.fixture
def api_token_db() -> Iterator[Session]:
"""Provide the production database extension with an isolated SQLite token table."""
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
db.init_app(app)
with app.app_context():
ApiToken.__table__.create(db.engine)
with Session(db.engine, expire_on_commit=False) as session:
yield session
class TestQueryTokenFromDb:
def test_should_return_api_token_and_cache_when_token_exists(
self, flask_app_with_containers: Flask, db_session_with_containers
):
def test_should_return_api_token_and_cache_when_token_exists(self, api_token_db: Session) -> None:
tenant_id = str(uuid4())
app_id = str(uuid4())
token_value = f"app-test-{uuid4()}"
@@ -27,8 +43,8 @@ class TestQueryTokenFromDb:
api_token.tenant_id = tenant_id
api_token.type = "app"
api_token.token = token_value
db_session_with_containers.add(api_token)
db_session_with_containers.commit()
api_token_db.add(api_token)
api_token_db.commit()
with (
patch.object(api_token_service_module.ApiTokenCache, "set") as mock_cache_set,
@@ -41,9 +57,7 @@ class TestQueryTokenFromDb:
mock_cache_set.assert_called_once()
mock_record_usage.assert_called_once_with(token_value, "app")
def test_should_cache_null_and_raise_unauthorized_when_token_not_found(
self, flask_app_with_containers: Flask, db_session_with_containers
):
def test_should_cache_null_and_raise_unauthorized_when_token_not_found(self, api_token_db: Session) -> None:
with (
patch.object(api_token_service_module.ApiTokenCache, "set") as mock_cache_set,
patch.object(api_token_service_module, "record_token_usage") as mock_record_usage,
@@ -3,204 +3,156 @@ Comprehensive unit tests for ConversationService.
This file provides complete test coverage for all ConversationService methods.
Tests are organized by functionality and include edge cases, error handling,
and both positive and negative test scenarios.
and both positive and negative test scenarios. Database paths use isolated
in-memory SQLite sessions with persisted ORM rows.
"""
from unittest.mock import MagicMock, Mock, create_autospec, patch
import json
from unittest.mock import patch
import pytest
from sqlalchemy import asc, desc
from sqlalchemy.orm import Session
from core.app.entities.app_invoke_entities import InvokeFrom
from libs.datetime_utils import naive_utc_now
from models import Account, ConversationVariable
from models.model import App, Conversation, EndUser, Message
from models.enums import AppStatus, ConversationFromSource, ConversationStatus
from models.model import App, AppMode, Conversation
from services.conversation_service import ConversationService
TENANT_ID = "11111111-1111-1111-1111-111111111111"
APP_ID = "22222222-2222-2222-2222-222222222222"
ACCOUNT_ID = "33333333-3333-3333-3333-333333333333"
CONVERSATION_ID = "44444444-4444-4444-4444-444444444444"
VARIABLE_ID = "55555555-5555-5555-5555-555555555555"
OTHER_VARIABLE_ID = "66666666-6666-6666-6666-666666666666"
OTHER_APP_ID = "77777777-7777-7777-7777-777777777777"
OTHER_CONVERSATION_ID = "88888888-8888-8888-8888-888888888888"
OTHER_APP_VARIABLE_ID = "99999999-9999-9999-9999-999999999999"
OTHER_CONVERSATION_VARIABLE_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
def _conversation_variable(
*,
variable_id: str,
name: str,
value: str,
conversation_id: str = CONVERSATION_ID,
app_id: str = APP_ID,
) -> ConversationVariable:
return ConversationVariable(
id=variable_id,
conversation_id=conversation_id,
app_id=app_id,
data=json.dumps(
{
"id": variable_id,
"name": name,
"value_type": "string",
"value": value,
}
),
)
class ConversationServiceTestDataFactory:
"""
Factory for creating test data and mock objects.
Factory for creating test ORM objects.
Provides reusable methods to create consistent mock objects for testing
Provides reusable methods to create consistent model objects for testing
conversation-related operations.
"""
@staticmethod
def create_account_mock(account_id: str = "account-123", **kwargs) -> Mock:
def create_account(account_id: str = ACCOUNT_ID, **kwargs) -> Account:
"""
Create a mock Account object.
Create an Account object.
Args:
account_id: Unique identifier for the account
**kwargs: Additional attributes to set on the mock
**kwargs: Additional attributes to set on the model
Returns:
Mock Account object with specified attributes
Account object with specified attributes
"""
account = create_autospec(Account, instance=True)
account = Account(name="Test User", email="test@example.com")
account.id = account_id
for key, value in kwargs.items():
setattr(account, key, value)
return account
@staticmethod
def create_end_user_mock(user_id: str = "user-123", **kwargs) -> Mock:
def create_app(app_id: str = APP_ID, tenant_id: str = TENANT_ID, **kwargs) -> App:
"""
Create a mock EndUser object.
Args:
user_id: Unique identifier for the end user
**kwargs: Additional attributes to set on the mock
Returns:
Mock EndUser object with specified attributes
"""
user = create_autospec(EndUser, instance=True)
user.id = user_id
for key, value in kwargs.items():
setattr(user, key, value)
return user
@staticmethod
def create_app_mock(app_id: str = "app-123", tenant_id: str = "tenant-123", **kwargs) -> Mock:
"""
Create a mock App object.
Create an App object.
Args:
app_id: Unique identifier for the app
tenant_id: Tenant/workspace identifier
**kwargs: Additional attributes to set on the mock
**kwargs: Additional attributes to set on the model
Returns:
Mock App object with specified attributes
App object with specified attributes
"""
app = create_autospec(App, instance=True)
app.id = app_id
app.tenant_id = tenant_id
app.name = kwargs.get("name", "Test App")
app.mode = kwargs.get("mode", "chat")
app.status = kwargs.get("status", "normal")
app = App(
id=app_id,
tenant_id=tenant_id,
name=kwargs.get("name", "Test App"),
mode=kwargs.get("mode", AppMode.CHAT),
status=kwargs.get("status", AppStatus.NORMAL),
description="",
enable_site=False,
enable_api=False,
max_active_requests=None,
)
for key, value in kwargs.items():
setattr(app, key, value)
return app
@staticmethod
def create_conversation_mock(
conversation_id: str = "conv-123",
app_id: str = "app-123",
from_source: str = "console",
def create_conversation(
conversation_id: str = CONVERSATION_ID,
app_id: str = APP_ID,
from_source: ConversationFromSource = ConversationFromSource.CONSOLE,
**kwargs,
) -> Mock:
) -> Conversation:
"""
Create a mock Conversation object.
Create a Conversation object.
Args:
conversation_id: Unique identifier for the conversation
app_id: Associated app identifier
from_source: Source of conversation ('console' or 'api')
**kwargs: Additional attributes to set on the mock
**kwargs: Additional attributes to set on the model
Returns:
Mock Conversation object with specified attributes
Conversation object with specified attributes
"""
conversation = create_autospec(Conversation, instance=True)
conversation.id = conversation_id
conversation.app_id = app_id
conversation.from_source = from_source
conversation.from_end_user_id = kwargs.get("from_end_user_id")
conversation.from_account_id = kwargs.get("from_account_id")
conversation.is_deleted = kwargs.get("is_deleted", False)
conversation.name = kwargs.get("name", "Test Conversation")
conversation.status = kwargs.get("status", "normal")
conversation.created_at = kwargs.get("created_at", naive_utc_now())
conversation.updated_at = kwargs.get("updated_at", naive_utc_now())
conversation = Conversation(
id=conversation_id,
app_id=app_id,
mode=AppMode.CHAT,
name=kwargs.get("name", "Test Conversation"),
status=kwargs.get("status", ConversationStatus.NORMAL),
from_source=from_source,
from_end_user_id=kwargs.get("from_end_user_id"),
from_account_id=kwargs.get("from_account_id", ACCOUNT_ID),
is_deleted=kwargs.get("is_deleted", False),
created_at=kwargs.get("created_at", naive_utc_now()),
updated_at=kwargs.get("updated_at", naive_utc_now()),
)
conversation._inputs = {}
for key, value in kwargs.items():
setattr(conversation, key, value)
return conversation
@staticmethod
def create_message_mock(
message_id: str = "msg-123",
conversation_id: str = "conv-123",
app_id: str = "app-123",
**kwargs,
) -> Mock:
"""
Create a mock Message object.
Args:
message_id: Unique identifier for the message
conversation_id: Associated conversation identifier
app_id: Associated app identifier
**kwargs: Additional attributes to set on the mock
Returns:
Mock Message object with specified attributes
"""
message = create_autospec(Message, instance=True)
message.id = message_id
message.conversation_id = conversation_id
message.app_id = app_id
message.query = kwargs.get("query", "Test message content")
message.created_at = kwargs.get("created_at", naive_utc_now())
for key, value in kwargs.items():
setattr(message, key, value)
return message
@staticmethod
def create_conversation_variable_mock(
variable_id: str = "var-123",
conversation_id: str = "conv-123",
app_id: str = "app-123",
**kwargs,
) -> Mock:
"""
Create a mock ConversationVariable object.
Args:
variable_id: Unique identifier for the variable
conversation_id: Associated conversation identifier
app_id: Associated app identifier
**kwargs: Additional attributes to set on the mock
Returns:
Mock ConversationVariable object with specified attributes
"""
variable = create_autospec(ConversationVariable, instance=True)
variable.id = variable_id
variable.conversation_id = conversation_id
variable.app_id = app_id
variable.data = {"name": kwargs.get("name", "test_var"), "value": kwargs.get("value", "test_value")}
variable.created_at = kwargs.get("created_at", naive_utc_now())
variable.updated_at = kwargs.get("updated_at", naive_utc_now())
# Mock to_variable method
mock_variable = Mock()
mock_variable.id = variable_id
mock_variable.name = kwargs.get("name", "test_var")
mock_variable.value_type = kwargs.get("value_type", "string")
mock_variable.value = kwargs.get("value", "test_value")
mock_variable.description = kwargs.get("description", "")
mock_variable.selector = kwargs.get("selector", {})
mock_variable.model_dump.return_value = {
"id": variable_id,
"name": kwargs.get("name", "test_var"),
"value_type": kwargs.get("value_type", "string"),
"value": kwargs.get("value", "test_value"),
"description": kwargs.get("description", ""),
"selector": kwargs.get("selector", {}),
}
variable.to_variable.return_value = mock_variable
for key, value in kwargs.items():
setattr(variable, key, value)
return variable
@pytest.mark.parametrize("sqlite_session", [(Conversation,)], indirect=True)
class TestConversationServicePagination:
"""Test conversation pagination operations."""
def test_pagination_with_empty_include_ids(self):
def test_pagination_with_empty_include_ids(self, sqlite_session: Session):
"""
Test that empty include_ids returns empty result.
@@ -208,15 +160,14 @@ class TestConversationServicePagination:
and return empty results without querying the database.
"""
# Arrange - Set up test data
mock_session = MagicMock() # Mock database session
mock_app_model = ConversationServiceTestDataFactory.create_app_mock()
mock_user = ConversationServiceTestDataFactory.create_account_mock()
app_model = ConversationServiceTestDataFactory.create_app()
user = ConversationServiceTestDataFactory.create_account()
# Act - Call the service method with empty include_ids
result = ConversationService.pagination_by_last_id(
session=mock_session,
app_model=mock_app_model,
user=mock_user,
session=sqlite_session,
app_model=app_model,
user=user,
last_id=None,
limit=20,
invoke_from=InvokeFrom.WEB_APP,
@@ -228,21 +179,21 @@ class TestConversationServicePagination:
assert result.data == [] # No conversations returned
assert result.has_more is False # No more pages available
assert result.limit == 20 # Limit preserved in response
assert not sqlite_session.in_transaction()
def test_pagination_returns_empty_when_user_is_none(self):
def test_pagination_returns_empty_when_user_is_none(self, sqlite_session: Session):
"""
Test that pagination returns empty result when user is None.
This ensures proper handling of unauthenticated requests.
"""
# Arrange
mock_session = MagicMock()
mock_app_model = ConversationServiceTestDataFactory.create_app_mock()
app_model = ConversationServiceTestDataFactory.create_app()
# Act
result = ConversationService.pagination_by_last_id(
session=mock_session,
app_model=mock_app_model,
session=sqlite_session,
app_model=app_model,
user=None, # No user provided
last_id=None,
limit=20,
@@ -253,6 +204,7 @@ class TestConversationServicePagination:
assert result.data == []
assert result.has_more is False
assert result.limit == 20
assert not sqlite_session.in_transaction()
class TestConversationServiceHelpers:
@@ -291,14 +243,14 @@ class TestConversationServiceHelpers:
Should create a less-than filter condition.
"""
# Arrange
mock_conversation = ConversationServiceTestDataFactory.create_conversation_mock()
mock_conversation.updated_at = naive_utc_now()
conversation = ConversationServiceTestDataFactory.create_conversation()
conversation.updated_at = naive_utc_now()
# Act
condition = ConversationService._build_filter_condition(
sort_field="updated_at",
sort_direction=desc,
reference_conversation=mock_conversation,
reference_conversation=conversation,
)
# Assert
@@ -312,14 +264,14 @@ class TestConversationServiceHelpers:
Should create a greater-than filter condition.
"""
# Arrange
mock_conversation = ConversationServiceTestDataFactory.create_conversation_mock()
mock_conversation.created_at = naive_utc_now()
conversation = ConversationServiceTestDataFactory.create_conversation()
conversation.created_at = naive_utc_now()
# Act
condition = ConversationService._build_filter_condition(
sort_field="created_at",
sort_direction=asc,
reference_conversation=mock_conversation,
reference_conversation=conversation,
)
# Assert
@@ -330,36 +282,71 @@ class TestConversationServiceHelpers:
class TestConversationServiceConversationalVariable:
"""Test conversational variable operations."""
@patch("services.conversation_service.ConversationService.get_conversation")
@pytest.mark.parametrize("sqlite_session", [(Conversation, ConversationVariable)], indirect=True)
@patch("services.conversation_service.dify_config")
def test_get_conversational_variable_with_name_filter_mysql(self, mock_config, mock_get_conversation):
def test_get_conversational_variable_with_name_filter_mysql(
self,
mock_config,
sqlite_session: Session,
):
"""
Test variable filtering by name for MySQL databases.
Should apply JSON extraction filter for variable names.
"""
# Arrange
app_model = ConversationServiceTestDataFactory.create_app_mock()
user = ConversationServiceTestDataFactory.create_account_mock()
conversation = ConversationServiceTestDataFactory.create_conversation_mock()
mock_get_conversation.return_value = conversation
app_model = ConversationServiceTestDataFactory.create_app()
user = ConversationServiceTestDataFactory.create_account()
conversation = ConversationServiceTestDataFactory.create_conversation()
matching_variable = _conversation_variable(
variable_id=VARIABLE_ID,
name="test_var",
value="matching",
)
other_variable = _conversation_variable(
variable_id=OTHER_VARIABLE_ID,
name="unrelated",
value="excluded",
)
other_app_variable = _conversation_variable(
variable_id=OTHER_APP_VARIABLE_ID,
name="test_var",
value="other-app",
app_id=OTHER_APP_ID,
)
other_conversation_variable = _conversation_variable(
variable_id=OTHER_CONVERSATION_VARIABLE_ID,
name="test_var",
value="other-conversation",
conversation_id=OTHER_CONVERSATION_ID,
)
sqlite_session.add_all(
[
conversation,
matching_variable,
other_variable,
other_app_variable,
other_conversation_variable,
]
)
sqlite_session.commit()
mock_config.DB_TYPE = "mysql"
# Mock session
mock_session = MagicMock()
mock_session.scalars.return_value.all.return_value = []
# Act
ConversationService.get_conversational_variable(
result = ConversationService.get_conversational_variable(
app_model=app_model,
conversation_id="conv-123",
conversation_id=CONVERSATION_ID,
user=user,
limit=10,
last_id=None,
variable_name="test_var",
session=mock_session,
session=sqlite_session,
)
# Assert - JSON filter should be applied
assert mock_session.scalars.called
# Assert - SQLite executes the MySQL-compatible JSON extraction boundary.
assert result.has_more is False
assert result.limit == 10
assert len(result.data) == 1
assert result.data[0]["id"] == VARIABLE_ID
assert result.data[0]["name"] == "test_var"
assert result.data[0]["value"] == "matching"
@@ -6,6 +6,7 @@ from services.feature_service import FeatureService, SystemFeatureModel
def test_system_feature_model_defaults_enable_learn_app():
assert SystemFeatureModel().enable_learn_app is True
assert SystemFeatureModel().enable_step_by_step_tour is False
@pytest.mark.parametrize("enabled", [True, False])
@@ -15,3 +16,12 @@ def test_get_system_features_reads_enable_learn_app(monkeypatch: pytest.MonkeyPa
result = FeatureService.get_system_features()
assert result.enable_learn_app is enabled
@pytest.mark.parametrize("enabled", [True, False])
def test_get_system_features_reads_enable_step_by_step_tour(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None:
monkeypatch.setattr(feature_service_module.dify_config, "ENABLE_STEP_BY_STEP_TOUR", enabled)
result = FeatureService.get_system_features()
assert result.enable_step_by_step_tour is enabled
@@ -0,0 +1,30 @@
import pytest
from services.feature_service import FeatureService, SystemFeatureModel
@pytest.mark.parametrize(
("env_value", "expected"),
[
(False, False),
(True, True),
],
ids=["disabled_by_env", "enabled_by_env"],
)
def test_fulfill_system_params_from_env_sets_allow_public_access(
monkeypatch: pytest.MonkeyPatch,
env_value: bool,
expected: bool,
):
monkeypatch.setattr("services.feature_service.dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED", env_value)
system_features = SystemFeatureModel()
FeatureService._fulfill_system_params_from_env(system_features)
assert system_features.webapp_auth.allow_public_access is expected
def test_get_system_features_defaults_allow_public_access_to_true():
system_features = FeatureService.get_system_features()
assert system_features.webapp_auth.allow_public_access is True
@@ -1,6 +1,7 @@
"""Unit tests for human-input test delivery with SQLite-backed member lookup."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from uuid import uuid4
@@ -19,7 +20,9 @@ from core.workflow.human_input_adapter import (
)
from graphon.runtime import VariablePool
from models.account import Account, TenantAccountJoin
from models.engine import db
from services import human_input_delivery_test_service as service_module
from services.feature_service import FeatureModel
from services.human_input_delivery_test_service import (
DeliveryTestContext,
DeliveryTestEmailRecipient,
@@ -90,8 +93,14 @@ class TestDeliveryTestRegistry:
with pytest.raises(DeliveryTestUnsupportedError, match="Delivery method does not support test send."):
registry.dispatch(context=context, method=method)
def test_default(self, flask_app_with_containers: Flask, db_session_with_containers: Session):
registry = DeliveryTestRegistry.default()
def test_default(self) -> None:
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
db.init_app(app)
with app.app_context():
registry = DeliveryTestRegistry.default()
assert len(registry._handlers) == 1
assert isinstance(registry._handlers[0], EmailDeliveryTestHandler)
@@ -112,24 +121,24 @@ class TestEmailDeliveryTestHandler:
handler = EmailDeliveryTestHandler(session_factory=engine)
assert handler._session_factory.kw["bind"] == engine
def test_supports(self):
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
def test_supports(self, sqlite_engine: Engine) -> None:
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
method = EmailDeliveryMethod(config=_make_valid_email_config())
assert handler.supports(method) is True
assert handler.supports(MagicMock()) is False
def test_send_test_unsupported_method(self):
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
def test_send_test_unsupported_method(self, sqlite_engine: Engine) -> None:
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
with pytest.raises(DeliveryTestUnsupportedError):
handler.send_test(context=MagicMock(), method=MagicMock())
def test_send_test_feature_disabled(self, monkeypatch: pytest.MonkeyPatch):
def test_send_test_feature_disabled(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> None:
monkeypatch.setattr(
service_module.FeatureService,
"get_features",
lambda _tenant_id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=False),
lambda _tenant_id, **_kwargs: FeatureModel(human_input_email_delivery_enabled=False),
)
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
context = DeliveryTestContext(
tenant_id="t1", app_id="a1", node_id="n1", node_title="title", rendered_content="content"
)
@@ -138,15 +147,15 @@ class TestEmailDeliveryTestHandler:
with pytest.raises(DeliveryTestError, match="Email delivery is not available"):
handler.send_test(context=context, method=method)
def test_send_test_mail_not_inited(self, monkeypatch: pytest.MonkeyPatch):
def test_send_test_mail_not_inited(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> None:
monkeypatch.setattr(
service_module.FeatureService,
"get_features",
lambda _id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True),
lambda _id, **_kwargs: FeatureModel(human_input_email_delivery_enabled=True),
)
monkeypatch.setattr(service_module.mail, "is_inited", lambda: False)
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
context = DeliveryTestContext(
tenant_id="t1", app_id="a1", node_id="n1", node_title="title", rendered_content="content"
)
@@ -155,15 +164,15 @@ class TestEmailDeliveryTestHandler:
with pytest.raises(DeliveryTestError, match="Mail client is not initialized."):
handler.send_test(context=context, method=method)
def test_send_test_no_recipients(self, monkeypatch: pytest.MonkeyPatch):
def test_send_test_no_recipients(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> None:
monkeypatch.setattr(
service_module.FeatureService,
"get_features",
lambda _id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True),
lambda _id, **_kwargs: FeatureModel(human_input_email_delivery_enabled=True),
)
monkeypatch.setattr(service_module.mail, "is_inited", lambda: True)
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
handler._resolve_recipients = MagicMock(return_value=[])
context = DeliveryTestContext(
@@ -174,18 +183,18 @@ class TestEmailDeliveryTestHandler:
with pytest.raises(DeliveryTestError, match="No recipients configured"):
handler.send_test(context=context, method=method)
def test_send_test_success(self, monkeypatch: pytest.MonkeyPatch):
def test_send_test_success(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> None:
monkeypatch.setattr(
service_module.FeatureService,
"get_features",
lambda _id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True),
lambda _id, **_kwargs: FeatureModel(human_input_email_delivery_enabled=True),
)
monkeypatch.setattr(service_module.mail, "is_inited", lambda: True)
mock_mail_send = MagicMock()
monkeypatch.setattr(service_module.mail, "send", mock_mail_send)
monkeypatch.setattr(service_module, "render_email_template", lambda t, s: f"RENDERED_{t}")
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
handler._resolve_recipients = MagicMock(return_value=["test@example.com"])
variable_pool = VariablePool()
@@ -210,11 +219,11 @@ class TestEmailDeliveryTestHandler:
assert kwargs["to"] == "test@example.com"
assert "RENDERED_Subj" in kwargs["subject"]
def test_send_test_sanitizes_subject(self, monkeypatch: pytest.MonkeyPatch):
def test_send_test_sanitizes_subject(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> None:
monkeypatch.setattr(
service_module.FeatureService,
"get_features",
lambda _id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True),
lambda _id, **_kwargs: FeatureModel(human_input_email_delivery_enabled=True),
)
monkeypatch.setattr(service_module.mail, "is_inited", lambda: True)
mock_mail_send = MagicMock()
@@ -225,7 +234,7 @@ class TestEmailDeliveryTestHandler:
lambda template, substitutions: template.replace("{{ recipient_email }}", substitutions["recipient_email"]),
)
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
handler._resolve_recipients = MagicMock(return_value=["test@example.com"])
context = DeliveryTestContext(
@@ -249,8 +258,8 @@ class TestEmailDeliveryTestHandler:
_, kwargs = mock_mail_send.call_args
assert kwargs["subject"] == "Notice BCC:test@example.com"
def test_resolve_recipients_external(self):
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
def test_resolve_recipients_external(self, sqlite_engine: Engine) -> None:
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
method = EmailDeliveryMethod(
config=EmailDeliveryConfig(
recipients=EmailRecipients(
@@ -262,19 +271,18 @@ class TestEmailDeliveryTestHandler:
)
assert handler._resolve_recipients(tenant_id="t1", method=method) == ["ext@example.com"]
def test_resolve_recipients_member(self, flask_app_with_containers: Flask, db_session_with_containers: Session):
@pytest.mark.parametrize("sqlite_session", [(Account, TenantAccountJoin)], indirect=True)
def test_resolve_recipients_member(self, sqlite_engine: Engine, sqlite_session: Session) -> None:
tenant_id = str(uuid4())
account = Account(name="Test User", email="member@example.com")
db_session_with_containers.add(account)
db_session_with_containers.commit()
sqlite_session.add(account)
sqlite_session.commit()
join = TenantAccountJoin(tenant_id=tenant_id, account_id=account.id)
db_session_with_containers.add(join)
db_session_with_containers.commit()
sqlite_session.add(join)
sqlite_session.commit()
from extensions.ext_database import db
handler = EmailDeliveryTestHandler(session_factory=db.engine)
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
method = EmailDeliveryMethod(
config=EmailDeliveryConfig(
recipients=EmailRecipients(items=[MemberRecipient(reference_id=account.id)], include_bound_group=False),
@@ -284,23 +292,20 @@ class TestEmailDeliveryTestHandler:
)
assert handler._resolve_recipients(tenant_id=tenant_id, method=method) == ["member@example.com"]
def test_resolve_recipients_whole_workspace(
self, flask_app_with_containers: Flask, db_session_with_containers: Session
):
@pytest.mark.parametrize("sqlite_session", [(Account, TenantAccountJoin)], indirect=True)
def test_resolve_recipients_whole_workspace(self, sqlite_engine: Engine, sqlite_session: Session) -> None:
tenant_id = str(uuid4())
account1 = Account(name="User 1", email=f"u1-{uuid4()}@example.com")
account2 = Account(name="User 2", email=f"u2-{uuid4()}@example.com")
db_session_with_containers.add_all([account1, account2])
db_session_with_containers.commit()
sqlite_session.add_all([account1, account2])
sqlite_session.commit()
for acc in [account1, account2]:
join = TenantAccountJoin(tenant_id=tenant_id, account_id=acc.id)
db_session_with_containers.add(join)
db_session_with_containers.commit()
sqlite_session.add(join)
sqlite_session.commit()
from extensions.ext_database import db
handler = EmailDeliveryTestHandler(session_factory=db.engine)
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
method = EmailDeliveryMethod(
config=EmailDeliveryConfig(
recipients=EmailRecipients(items=[], include_bound_group=True),
@@ -311,8 +316,8 @@ class TestEmailDeliveryTestHandler:
recipients = handler._resolve_recipients(tenant_id=tenant_id, method=method)
assert set(recipients) == {account1.email, account2.email}
def test_query_workspace_member_emails_empty_ids(self):
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
def test_query_workspace_member_emails_empty_ids(self, sqlite_engine: Engine) -> None:
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
assert handler._query_workspace_member_emails(tenant_id="t1", user_ids=[]) == {}
def test_build_substitutions(self):
@@ -0,0 +1,238 @@
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from sqlalchemy.exc import IntegrityError
from models.account import Account, AccountStatus
from models.onboarding import AccountStepByStepTourState
from services import step_by_step_tour_service as service_module
from services.step_by_step_tour_service import StepByStepTourService
class _ScalarResult:
def __init__(self, state: AccountStepByStepTourState | None) -> None:
self._state = state
def scalar_one_or_none(self) -> AccountStepByStepTourState | None:
return self._state
class _FakeSession:
def __init__(self, state: AccountStepByStepTourState | None = None) -> None:
self.state = state
self.added: list[AccountStepByStepTourState] = []
self.commit_count = 0
self.flush_count = 0
self.refresh_count = 0
self.rollback_count = 0
def execute(self, _stmt) -> _ScalarResult:
return _ScalarResult(self.state)
def add(self, state: AccountStepByStepTourState) -> None:
self.state = state
self.added.append(state)
def flush(self) -> None:
self.flush_count += 1
def commit(self) -> None:
self.commit_count += 1
def refresh(self, state: AccountStepByStepTourState) -> None:
self.refresh_count += 1
state.updated_at = datetime(2026, 6, 28, tzinfo=UTC)
def rollback(self) -> None:
self.rollback_count += 1
class _RaceInsertSession(_FakeSession):
def __init__(self, state_after_rollback: AccountStepByStepTourState) -> None:
super().__init__(state=None)
self.state_after_rollback = state_after_rollback
def flush(self) -> None:
self.flush_count += 1
raise IntegrityError("insert", {}, Exception("duplicate"))
def rollback(self) -> None:
super().rollback()
self.state = self.state_after_rollback
def _account(*, initialized_at: datetime | None = None, created_at: datetime | None = None) -> Account:
account = Account(name="User", email="user@example.com", status=AccountStatus.ACTIVE)
account.id = "account-1"
account.initialized_at = initialized_at
account.created_at = created_at or datetime(2026, 6, 28)
return account
def _state() -> AccountStepByStepTourState:
state = AccountStepByStepTourState(account_id="account-1")
state.updated_at = datetime(2026, 6, 28, tzinfo=UTC)
return state
def _set_tour_config(monkeypatch: pytest.MonkeyPatch, *, enabled: bool, rollout_started_at: datetime | None) -> None:
monkeypatch.setattr(service_module.dify_config, "ENABLE_STEP_BY_STEP_TOUR", enabled)
monkeypatch.setattr(service_module.dify_config, "STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT", rollout_started_at)
def test_get_state_creates_state_and_records_first_workspace_for_eligible_account(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
session = _FakeSession()
result = StepByStepTourService.get_state(
account=_account(initialized_at=datetime(2026, 6, 28)),
current_tenant_id="workspace-1",
session=session,
)
assert result["first_workspace_id"] == "workspace-1"
assert result["completed_task_ids"] == []
assert len(session.added) == 1
assert session.added[0].account_id == "account-1"
assert session.commit_count == 1
assert session.refresh_count == 1
def test_is_eligible_does_not_depend_on_cloud_edition(monkeypatch: pytest.MonkeyPatch) -> None:
_set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
monkeypatch.setattr(service_module.dify_config, "EDITION", "SELF_HOSTED")
result = StepByStepTourService.is_eligible(_account(initialized_at=datetime(2026, 6, 28)))
assert result is True
def test_get_state_does_not_create_state_for_ineligible_account_without_existing_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
session = _FakeSession()
result = StepByStepTourService.get_state(
account=_account(initialized_at=datetime(2026, 5, 31)),
current_tenant_id="workspace-1",
session=session,
)
assert result == {
"first_workspace_id": None,
"skipped": False,
"completed_task_ids": [],
"manually_enabled_workspace_ids": [],
"manually_disabled_workspace_ids": [],
"updated_at": None,
}
assert session.added == []
assert session.commit_count == 0
def test_patch_state_persists_even_when_account_is_not_eligible(monkeypatch: pytest.MonkeyPatch) -> None:
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
session = _FakeSession()
result = StepByStepTourService.patch_state(
account=_account(initialized_at=datetime(2026, 6, 28)),
current_tenant_id="workspace-2",
patch={"action": "enable_current_workspace"},
session=session,
)
assert result["skipped"] is False
assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
assert result["manually_disabled_workspace_ids"] == []
assert len(session.added) == 1
assert session.commit_count == 1
def test_patch_state_skip_action_sets_skipped_and_removes_current_workspace_enable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
state = _state()
state.manually_enabled_workspace_ids = ["workspace-1", "workspace-2"]
session = _FakeSession(state=state)
result = StepByStepTourService.patch_state(
account=_account(initialized_at=datetime(2026, 6, 28)),
current_tenant_id="workspace-1",
patch={"action": "skip"},
session=session,
)
assert result["skipped"] is True
assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
assert result["manually_disabled_workspace_ids"] == []
assert session.added == []
assert session.commit_count == 1
def test_patch_state_disable_action_moves_current_workspace_to_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
state = _state()
state.manually_enabled_workspace_ids = ["workspace-1", "workspace-2"]
session = _FakeSession(state=state)
result = StepByStepTourService.patch_state(
account=_account(initialized_at=datetime(2026, 6, 28)),
current_tenant_id="workspace-1",
patch={"action": "disable_current_workspace"},
session=session,
)
assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
assert result["manually_disabled_workspace_ids"] == ["workspace-1"]
assert session.commit_count == 1
def test_patch_state_complete_and_uncomplete_task(monkeypatch: pytest.MonkeyPatch) -> None:
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
state = _state()
state.completed_task_ids = ["home"]
session = _FakeSession(state=state)
StepByStepTourService.patch_state(
account=_account(initialized_at=datetime(2026, 6, 28)),
current_tenant_id="workspace-1",
patch={"action": "complete_task", "task_id": "studio"},
session=session,
)
result = StepByStepTourService.patch_state(
account=_account(initialized_at=datetime(2026, 6, 28)),
current_tenant_id="workspace-1",
patch={"action": "uncomplete_task", "task_id": "home"},
session=session,
)
assert result["completed_task_ids"] == ["studio"]
def test_patch_state_recovers_when_concurrent_request_created_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
existing_state = _state()
existing_state.manually_enabled_workspace_ids = ["workspace-1"]
session = _RaceInsertSession(state_after_rollback=existing_state)
result = StepByStepTourService.patch_state(
account=_account(initialized_at=datetime(2026, 6, 28)),
current_tenant_id="workspace-2",
patch={"action": "enable_current_workspace"},
session=session,
)
assert result["manually_enabled_workspace_ids"] == ["workspace-1", "workspace-2"]
assert session.flush_count == 1
assert session.rollback_count == 1
assert session.commit_count == 1
@@ -5,11 +5,46 @@ from unittest.mock import Mock, patch
import pytest
from socketio.exceptions import TimeoutError as SocketIOTimeoutError
from sqlalchemy import Engine
from sqlalchemy.orm import Session
from models.base import TypeBase
from models.model import App, AppMode, IconType
from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository
from services.workflow_collaboration_service import SYNC_REQUEST_TIMEOUT_SECONDS, WorkflowCollaborationService
@pytest.fixture
def db_session(sqlite_engine: Engine) -> Iterator[Session]:
"""Provide a real session for tenant-scoped workflow app access checks."""
TypeBase.metadata.create_all(sqlite_engine, tables=[App.__table__])
with Session(sqlite_engine, expire_on_commit=False) as session:
yield session
def _app(*, app_id: str, tenant_id: str) -> App:
return 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,
use_icon_as_answer_icon=False,
)
class TestWorkflowCollaborationService:
@pytest.fixture
def service(self) -> tuple[WorkflowCollaborationService, Mock, Mock]:
@@ -19,7 +54,7 @@ class TestWorkflowCollaborationService:
return WorkflowCollaborationService(repository, socketio, server_id="server-1"), repository, socketio
def test_authorize_and_join_workflow_room_returns_leader_status(
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
self, service: tuple[WorkflowCollaborationService, Mock, Mock], db_session: Session
) -> None:
# Arrange
collaboration_service, repository, socketio = service
@@ -36,7 +71,7 @@ class TestWorkflowCollaborationService:
patch.object(collaboration_service, "broadcast_online_users"),
):
# Act
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=Mock())
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=db_session)
# Assert
assert result == ("u-1", True)
@@ -49,25 +84,25 @@ class TestWorkflowCollaborationService:
socketio.emit.assert_called_once_with("status", {"isLeader": True}, room="sid-1")
def test_authorize_and_join_workflow_room_returns_none_when_missing_user(
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
self, service: tuple[WorkflowCollaborationService, Mock, Mock], db_session: Session
) -> None:
# Arrange
collaboration_service, _repository, socketio = service
socketio.get_session.return_value = {}
# Act
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=Mock())
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=db_session)
# Assert
assert result is None
def test_authorize_and_join_workflow_room_returns_none_when_missing_tenant(
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
self, service: tuple[WorkflowCollaborationService, Mock, Mock], db_session: Session
) -> None:
collaboration_service, repository, socketio = service
socketio.get_session.return_value = {"user_id": "u-1", "username": "Jane", "avatar": None}
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=Mock())
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=db_session)
assert result is None
repository.set_session_info.assert_not_called()
@@ -75,7 +110,7 @@ class TestWorkflowCollaborationService:
socketio.emit.assert_not_called()
def test_authorize_and_join_workflow_room_returns_none_when_workflow_is_not_accessible(
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
self, service: tuple[WorkflowCollaborationService, Mock, Mock], db_session: Session
) -> None:
collaboration_service, repository, socketio = service
socketio.get_session.return_value = {
@@ -86,7 +121,7 @@ class TestWorkflowCollaborationService:
}
with patch.object(collaboration_service, "_can_access_workflow", return_value=False):
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=Mock())
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=db_session)
assert result is None
repository.set_session_info.assert_not_called()
@@ -110,15 +145,23 @@ class TestWorkflowCollaborationService:
{"user_id": "u-1", "username": "Jane", "avatar": "avatar.png", "tenant_id": "t-1"},
)
def test_can_access_workflow_uses_session(self, service: tuple[WorkflowCollaborationService, Mock, Mock]) -> None:
def test_can_access_workflow_uses_session(
self, service: tuple[WorkflowCollaborationService, Mock, Mock], db_session: Session
) -> None:
collaboration_service, _repository, _socketio = service
session = Mock()
session.scalar.return_value = "wf-1"
db_session.add_all(
[
_app(app_id="wf-1", tenant_id="tenant-1"),
_app(app_id="wf-other", tenant_id="tenant-other"),
]
)
db_session.commit()
result = collaboration_service._can_access_workflow("wf-1", "tenant-1", session=session)
result = collaboration_service._can_access_workflow("wf-1", "tenant-1", session=db_session)
assert result is True
session.scalar.assert_called_once()
assert collaboration_service._can_access_workflow("wf-1", "tenant-other", session=db_session) is False
assert collaboration_service._can_access_workflow("wf-other", "tenant-other", session=db_session) is True
def test_relay_collaboration_event_unauthorized(
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
@@ -1,10 +1,10 @@
import dataclasses
import secrets
import uuid
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
import pytest
from sqlalchemy import Engine
from sqlalchemy.orm import Session
from core.workflow.system_variables import SystemVariableKey
@@ -34,17 +34,11 @@ from services.workflow_draft_variable_service import (
_model_to_insertion_dict,
)
@pytest.fixture
def mock_engine() -> Engine:
return Mock(spec=Engine)
@pytest.fixture
def mock_session(mock_engine) -> Session:
mock_session = Mock(spec=Session)
mock_session.get_bind.return_value = mock_engine
return mock_session
SQLITE_MODELS = (Workflow, WorkflowDraftVariable, WorkflowDraftVariableFile, WorkflowNodeExecutionModel)
pytestmark = [
pytest.mark.usefixtures("sqlite_session"),
pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True),
]
class TestDraftVariableSaver:
@@ -52,13 +46,13 @@ class TestDraftVariableSaver:
suffix = secrets.token_hex(6)
return f"test_app_id_{suffix}"
def test__should_variable_be_visible(self):
mock_session = MagicMock(spec=Session)
def test__should_variable_be_visible(self, sqlite_session: Session):
mock_user = Account(name="test", email="test@example.com")
mock_user.id = str(uuid.uuid4())
test_app_id = self._get_test_app_id()
saver = DraftVariableSaver(
session=mock_session,
session=sqlite_session,
tenant_id="test-tenant-id",
app_id=test_app_id,
node_id="test_node_id",
node_type=BuiltinNodeTypes.START,
@@ -68,7 +62,7 @@ class TestDraftVariableSaver:
assert saver._should_variable_be_visible("123_456", BuiltinNodeTypes.IF_ELSE, "output") == False
assert saver._should_variable_be_visible("123", BuiltinNodeTypes.START, "output") == True
def test__normalize_variable_for_start_node(self):
def test__normalize_variable_for_start_node(self, sqlite_session: Session):
@dataclasses.dataclass(frozen=True)
class TestCase:
name: str
@@ -116,11 +110,11 @@ class TestDraftVariableSaver:
),
]
mock_session = MagicMock(spec=Session)
mock_user = MagicMock()
test_app_id = self._get_test_app_id()
saver = DraftVariableSaver(
session=mock_session,
session=sqlite_session,
tenant_id="test-tenant-id",
app_id=test_app_id,
node_id=_NODE_ID,
node_type=BuiltinNodeTypes.START,
@@ -133,12 +127,12 @@ class TestDraftVariableSaver:
assert node_id == c.expected_node_id, fail_msg
assert name == c.expected_name, fail_msg
def test_build_variables_from_start_mapping_rebuilds_system_files(self):
mock_session = MagicMock(spec=Session)
def test_build_variables_from_start_mapping_rebuilds_system_files(self, sqlite_session: Session):
mock_user = MagicMock(spec=Account)
mock_user.id = str(uuid.uuid4())
saver = DraftVariableSaver(
session=mock_session,
session=sqlite_session,
tenant_id="tenant-1",
app_id=self._get_test_app_id(),
node_id="start",
node_type=BuiltinNodeTypes.START,
@@ -161,13 +155,10 @@ class TestDraftVariableSaver:
"tenant_id": "legacy-tenant",
}
with (
patch.object(saver, "_resolve_app_tenant_id", return_value="tenant-1"),
patch(
"services.workflow_draft_variable_service.build_file_from_stored_mapping",
return_value=rebuilt_file,
) as rebuild_file,
):
with patch(
"services.workflow_draft_variable_service.build_file_from_stored_mapping",
return_value=rebuilt_file,
) as rebuild_file:
draft_vars = saver._build_variables_from_start_mapping({"sys.files": [raw_file]})
sys_var = draft_vars[0]
@@ -175,17 +166,7 @@ class TestDraftVariableSaver:
rebuild_file.assert_called_once_with(file_mapping=raw_file, tenant_id="tenant-1")
@pytest.fixture
def mock_session(self):
"""Mock SQLAlchemy session."""
from sqlalchemy import Engine
mock_session = MagicMock(spec=Session)
mock_engine = MagicMock(spec=Engine)
mock_session.get_bind.return_value = mock_engine
return mock_session
@pytest.fixture
def draft_saver(self, mock_session):
def draft_saver(self, sqlite_session: Session):
"""Create DraftVariableSaver instance with user context."""
# Create a mock user
mock_user = MagicMock(spec=Account)
@@ -193,7 +174,8 @@ class TestDraftVariableSaver:
mock_user.tenant_id = "test-tenant-id"
return DraftVariableSaver(
session=mock_session,
session=sqlite_session,
tenant_id="test-tenant-id",
app_id="test-app-id",
node_id="test-node-id",
node_type=BuiltinNodeTypes.LLM,
@@ -201,7 +183,7 @@ class TestDraftVariableSaver:
user=mock_user,
)
def test_draft_saver_with_small_variables(self, draft_saver: DraftVariableSaver, mock_session):
def test_draft_saver_with_small_variables(self, draft_saver: DraftVariableSaver):
with patch(
"services.workflow_draft_variable_service.DraftVariableSaver._try_offload_large_variable", autospec=True
) as _mock_try_offload:
@@ -213,7 +195,7 @@ class TestDraftVariableSaver:
assert draft_var.file_id is None
_mock_try_offload.return_value = None
def test_draft_saver_with_large_variables(self, draft_saver: DraftVariableSaver, mock_session):
def test_draft_saver_with_large_variables(self, draft_saver: DraftVariableSaver):
with patch(
"services.workflow_draft_variable_service.DraftVariableSaver._try_offload_large_variable", autospec=True
) as _mock_try_offload:
@@ -235,6 +217,40 @@ class TestDraftVariableSaver:
# Should not have large variable metadata
assert draft_var.file_id == mock_draft_var_file.id
def test_try_offload_large_variable_uses_resource_tenant(self, sqlite_session: Session):
mock_user = MagicMock(spec=Account)
mock_user.id = "test-user-id"
mock_user.current_tenant_id = ""
saver = DraftVariableSaver(
session=sqlite_session,
tenant_id="app-tenant-id",
app_id="test-app-id",
node_id="test-node-id",
node_type=BuiltinNodeTypes.LLM,
node_execution_id="test-execution-id",
user=mock_user,
)
upload_file = SimpleNamespace(id="upload-file-id")
truncation_result = SimpleNamespace(result=StringSegment(value="..."), truncated=True)
with (
patch(
"services.workflow_draft_variable_service.VariableTruncator.truncate", return_value=truncation_result
),
patch("services.workflow_draft_variable_service.FileService") as file_service_class,
patch("services.workflow_draft_variable_service.sessionmaker") as sessionmaker_mock,
):
file_service_class.return_value.upload_file.return_value = upload_file
result = saver._try_offload_large_variable("large_var", StringSegment(value="large value"))
assert result is not None
_, variable_file = result
assert file_service_class.return_value.upload_file.call_args.kwargs["tenant_id"] == "app-tenant-id"
assert variable_file.tenant_id == "app-tenant-id"
sessionmaker_mock.return_value.begin.return_value.__enter__.return_value.add.assert_called_once_with(
variable_file
)
@patch("services.workflow_draft_variable_service._batch_upsert_draft_variable", autospec=True)
def test_save_method_integration(self, mock_batch_upsert, draft_saver):
"""Test complete save workflow."""
@@ -248,15 +264,17 @@ class TestDraftVariableSaver:
assert len(draft_vars) == 2
@patch("services.workflow_draft_variable_service._batch_upsert_draft_variable", autospec=True)
def test_start_node_save_persists_sys_timestamp_and_workflow_run_id(self, mock_batch_upsert):
def test_start_node_save_persists_sys_timestamp_and_workflow_run_id(
self, mock_batch_upsert, sqlite_session: Session
):
"""Start node should persist common `sys.*` variables, not only `sys.files`."""
mock_session = MagicMock(spec=Session)
mock_user = MagicMock(spec=Account)
mock_user.id = "test-user-id"
mock_user.tenant_id = "test-tenant-id"
saver = DraftVariableSaver(
session=mock_session,
session=sqlite_session,
tenant_id="test-tenant-id",
app_id="test-app-id",
node_id="start-node-id",
node_type=BuiltinNodeTypes.START,
@@ -284,14 +302,14 @@ class TestDraftVariableSaver:
}
@patch("services.workflow_draft_variable_service._batch_upsert_draft_variable", autospec=True)
def test_start_node_save_normalizes_reserved_prefix_outputs(self, mock_batch_upsert):
mock_session = MagicMock(spec=Session)
def test_start_node_save_normalizes_reserved_prefix_outputs(self, mock_batch_upsert, sqlite_session: Session):
mock_user = MagicMock(spec=Account)
mock_user.id = "test-user-id"
mock_user.tenant_id = "test-tenant-id"
saver = DraftVariableSaver(
session=mock_session,
session=sqlite_session,
tenant_id="test-tenant-id",
app_id="test-app-id",
node_id="start-node-id",
node_type=BuiltinNodeTypes.START,
@@ -344,8 +362,8 @@ class TestWorkflowDraftVariableService:
rag_pipeline_variables=[],
)
def test_list_variables_without_values_excludes_node_ids(self, mock_session):
service = WorkflowDraftVariableService(mock_session)
def test_list_variables_without_values_excludes_node_ids(self, sqlite_session: Session):
service = WorkflowDraftVariableService(sqlite_session)
variable = WorkflowDraftVariable.new_node_variable(
app_id="app-1",
node_id="node-1",
@@ -353,8 +371,21 @@ class TestWorkflowDraftVariableService:
value=StringSegment(value="value"),
node_execution_id="execution-1",
)
mock_session.scalar.return_value = 1
mock_session.scalars.return_value = [variable]
variable.user_id = "user-1"
excluded_system = WorkflowDraftVariable.new_sys_variable(
app_id="app-1", name="query", value=StringSegment(value="hidden"), node_execution_id="execution-1"
)
excluded_system.user_id = "user-1"
other_user = WorkflowDraftVariable.new_node_variable(
app_id="app-1",
node_id="node-2",
name="output",
value=StringSegment(value="other"),
node_execution_id="execution-2",
)
other_user.user_id = "user-2"
sqlite_session.add_all([variable, excluded_system, other_user])
sqlite_session.commit()
result = service.list_variables_without_values(
app_id="app-1",
@@ -365,16 +396,11 @@ class TestWorkflowDraftVariableService:
)
assert result.total == 1
assert result.variables == [variable]
assert [item.id for item in result.variables] == [variable.id]
stmt = mock_session.scalars.call_args.args[0]
compiled = stmt.compile()
excluded_node_ids = next(value for value in compiled.params.values() if isinstance(value, (list, tuple)))
assert set(excluded_node_ids) == {SYSTEM_VARIABLE_NODE_ID, CONVERSATION_VARIABLE_NODE_ID}
def test_reset_conversation_variable(self, mock_session):
def test_reset_conversation_variable(self, sqlite_session: Session):
"""Test resetting a conversation variable"""
service = WorkflowDraftVariableService(mock_session)
service = WorkflowDraftVariableService(sqlite_session)
test_app_id = self._get_test_app_id()
workflow = self._create_test_workflow(test_app_id)
@@ -397,9 +423,9 @@ class TestWorkflowDraftVariableService:
mock_reset_conv.assert_called_once_with(workflow, variable)
assert result == expected_result
def test_reset_node_variable_with_no_execution_id(self, mock_session):
def test_reset_node_variable_with_no_execution_id(self, sqlite_session: Session):
"""Test resetting a node variable with no execution ID - should delete variable"""
service = WorkflowDraftVariableService(mock_session)
service = WorkflowDraftVariableService(sqlite_session)
test_app_id = self._get_test_app_id()
workflow = self._create_test_workflow(test_app_id)
@@ -415,33 +441,18 @@ class TestWorkflowDraftVariableService:
)
# Manually set to None to simulate the test condition
variable.node_execution_id = None
sqlite_session.add(variable)
sqlite_session.commit()
result = service._reset_node_var_or_sys_var(workflow, variable)
# Should delete the variable and return None
mock_session.delete.assert_called_once_with(instance=variable)
mock_session.flush.assert_called_once()
assert sqlite_session.get(WorkflowDraftVariable, variable.id) is None
assert result is None
def test_reset_node_variable_with_missing_execution_record(
self,
mock_engine,
mock_session,
monkeypatch: pytest.MonkeyPatch,
):
def test_reset_node_variable_with_missing_execution_record(self, sqlite_session: Session):
"""Test resetting a node variable when execution record doesn't exist"""
mock_repo_session = Mock(spec=Session)
mock_session_maker = MagicMock()
# Mock the context manager protocol for sessionmaker
mock_session_maker.return_value.__enter__.return_value = mock_repo_session
mock_session_maker.return_value.__exit__.return_value = None
monkeypatch.setattr("services.workflow_draft_variable_service.sessionmaker", mock_session_maker)
service = WorkflowDraftVariableService(mock_session)
# Mock the repository to return None (no execution record found)
service._api_node_execution_repo = Mock()
service._api_node_execution_repo.get_execution_by_id.return_value = None
service = WorkflowDraftVariableService(sqlite_session)
test_app_id = self._get_test_app_id()
workflow = self._create_test_workflow(test_app_id)
@@ -451,32 +462,17 @@ class TestWorkflowDraftVariableService:
variable = WorkflowDraftVariable.new_node_variable(
app_id=test_app_id, node_id="test_node_id", name="test_var", value=test_value, node_execution_id="exec-id"
)
# Variable is editable by default from factory method
sqlite_session.add(variable)
sqlite_session.commit()
result = service._reset_node_var_or_sys_var(workflow, variable)
mock_session_maker.assert_called_once_with(bind=mock_engine, expire_on_commit=False)
# Should delete the variable and return None
mock_session.delete.assert_called_once_with(instance=variable)
mock_session.flush.assert_called_once()
assert sqlite_session.get(WorkflowDraftVariable, variable.id) is None
assert result is None
def test_reset_node_variable_with_valid_execution_record(
self,
mock_session,
monkeypatch: pytest.MonkeyPatch,
):
"""Test resetting a node variable with valid execution record - should restore from execution"""
mock_repo_session = Mock(spec=Session)
mock_session_maker = MagicMock()
# Mock the context manager protocol for sessionmaker
mock_session_maker.return_value.__enter__.return_value = mock_repo_session
mock_session_maker.return_value.__exit__.return_value = None
mock_session_maker = monkeypatch.setattr(
"services.workflow_draft_variable_service.sessionmaker", mock_session_maker
)
service = WorkflowDraftVariableService(mock_session)
def test_reset_node_variable_with_valid_execution_record(self, sqlite_session: Session):
"""Reset a node variable from its execution output and flush the restored value."""
service = WorkflowDraftVariableService(sqlite_session)
# Create mock execution record
mock_execution = Mock(spec=WorkflowNodeExecutionModel)
@@ -494,11 +490,13 @@ class TestWorkflowDraftVariableService:
variable = WorkflowDraftVariable.new_node_variable(
app_id=test_app_id, node_id="test_node_id", name="test_var", value=test_value, node_execution_id="exec-id"
)
# Variable is editable by default from factory method
sqlite_session.add(variable)
sqlite_session.commit()
# Mock workflow methods
mock_node_config = {"type": "test_node"}
with (
patch.object(sqlite_session, "flush", wraps=sqlite_session.flush) as flush,
patch.object(workflow, "get_node_config_by_id", return_value=mock_node_config, autospec=True),
patch.object(workflow, "get_node_type_from_node_config", return_value=BuiltinNodeTypes.LLM, autospec=True),
):
@@ -506,15 +504,13 @@ class TestWorkflowDraftVariableService:
# Verify last_edited_at was reset
assert variable.last_edited_at is None
# Verify session.flush was called
mock_session.flush.assert_called()
flush.assert_called()
# Should return the updated variable
assert result == variable
def test_reset_non_editable_system_variable_raises_error(self, mock_session):
def test_reset_non_editable_system_variable_raises_error(self, sqlite_session: Session):
"""Test that resetting a non-editable system variable raises an error"""
service = WorkflowDraftVariableService(mock_session)
service = WorkflowDraftVariableService(sqlite_session)
test_app_id = self._get_test_app_id()
workflow = self._create_test_workflow(test_app_id)
@@ -534,9 +530,9 @@ class TestWorkflowDraftVariableService:
assert "cannot reset system variable" in str(exc_info.value)
assert f"variable_id={variable.id}" in str(exc_info.value)
def test_reset_editable_system_variable_succeeds(self, mock_session):
def test_reset_editable_system_variable_succeeds(self, sqlite_session: Session):
"""Test that resetting an editable system variable succeeds"""
service = WorkflowDraftVariableService(mock_session)
service = WorkflowDraftVariableService(sqlite_session)
test_app_id = self._get_test_app_id()
workflow = self._create_test_workflow(test_app_id)
@@ -550,6 +546,8 @@ class TestWorkflowDraftVariableService:
node_execution_id="exec-id",
editable=True, # Editable system variable
)
sqlite_session.add(variable)
sqlite_session.commit()
# Create mock execution record
mock_execution = Mock(spec=WorkflowNodeExecutionModel)
@@ -559,16 +557,17 @@ class TestWorkflowDraftVariableService:
service._api_node_execution_repo = Mock()
service._api_node_execution_repo.get_execution_by_id.return_value = mock_execution
result = service._reset_node_var_or_sys_var(workflow, variable)
with patch.object(sqlite_session, "flush", wraps=sqlite_session.flush) as flush:
result = service._reset_node_var_or_sys_var(workflow, variable)
# Should succeed and return the variable
assert result == variable
assert variable.last_edited_at is None
mock_session.flush.assert_called()
flush.assert_called()
def test_reset_query_system_variable_succeeds(self, mock_session):
def test_reset_query_system_variable_succeeds(self, sqlite_session: Session):
"""Test that resetting query system variable (another editable one) succeeds"""
service = WorkflowDraftVariableService(mock_session)
service = WorkflowDraftVariableService(sqlite_session)
test_app_id = self._get_test_app_id()
workflow = self._create_test_workflow(test_app_id)
@@ -582,6 +581,8 @@ class TestWorkflowDraftVariableService:
node_execution_id="exec-id",
editable=True, # Editable system variable
)
sqlite_session.add(variable)
sqlite_session.commit()
# Create mock execution record
mock_execution = Mock(spec=WorkflowNodeExecutionModel)
@@ -591,12 +592,13 @@ class TestWorkflowDraftVariableService:
service._api_node_execution_repo = Mock()
service._api_node_execution_repo.get_execution_by_id.return_value = mock_execution
result = service._reset_node_var_or_sys_var(workflow, variable)
with patch.object(sqlite_session, "flush", wraps=sqlite_session.flush) as flush:
result = service._reset_node_var_or_sys_var(workflow, variable)
# Should succeed and return the variable
assert result == variable
assert variable.last_edited_at is None
mock_session.flush.assert_called()
flush.assert_called()
def test_system_variable_editability_check(self):
"""Test the system variable editability function directly"""

Some files were not shown because too many files have changed in this diff Show More