Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b95d313128 | ||
|
|
45ac70132c | ||
|
|
6e772259bb | ||
|
|
af465fb527 | ||
|
|
62bbc0dbeb | ||
|
|
ed1289281d | ||
|
|
e37f32e4a0 | ||
|
|
150613cc05 | ||
|
|
a5194ff17b | ||
|
|
cdaf5d4562 | ||
|
|
145b0e622e | ||
|
|
4bab62d458 | ||
|
|
b99f8941bb | ||
|
|
32d2594877 | ||
|
|
7b188287da | ||
|
|
f6db23444d | ||
|
|
a5c989d82b | ||
|
|
2622b1ce0a | ||
|
|
1e672530c4 | ||
|
|
852169d99c | ||
|
|
d7860571aa | ||
|
|
3683831738 | ||
|
|
f5024f5440 | ||
|
|
4f8477427e | ||
|
|
8d406aec31 | ||
|
|
f8754286ec | ||
|
|
7da6b2ce36 | ||
|
|
aa718add24 | ||
|
|
fcc1d39f5e | ||
|
|
861831f176 | ||
|
|
d333aa31ea | ||
|
|
97e6a14c13 | ||
|
|
6a338654fb | ||
|
|
8f075c82bc | ||
|
|
28d17da3b1 | ||
|
|
891b2dc537 | ||
|
|
75f7069541 | ||
|
|
b7193d1cba | ||
|
|
ef0115d340 | ||
|
|
3afb9b3230 | ||
|
|
0862641533 | ||
|
|
cbd7872520 | ||
|
|
23882a704e | ||
|
|
1bd5254ea7 | ||
|
|
e13069dbe3 | ||
|
|
edd3d4a218 | ||
|
|
79751f7622 | ||
|
|
cb64446fa3 | ||
|
|
e77ee82526 | ||
|
|
b3f163fb0f |
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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()
|
||||
@@ -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]]]:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
-179
@@ -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"
|
||||
|
||||
+17
-13
@@ -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
|
||||
|
||||
|
||||
+22
-12
@@ -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:
|
||||
|
||||
+44
-29
@@ -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()
|
||||
|
||||
+13
-16
@@ -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"
|
||||
|
||||
+22
-8
@@ -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
|
||||
+49
-44
@@ -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
Reference in New Issue
Block a user