mirror of
https://github.com/langgenius/dify.git
synced 2026-01-08 07:14:14 +00:00
Feat/add status filter to workflow runs (#26850)
Some checks are pending
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Waiting to run
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Blocked by required conditions
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Blocked by required conditions
Main CI Pipeline / Check Changed Files (push) Waiting to run
Main CI Pipeline / API Tests (push) Blocked by required conditions
Main CI Pipeline / Web Tests (push) Blocked by required conditions
Main CI Pipeline / Style Check (push) Waiting to run
Main CI Pipeline / VDB Tests (push) Blocked by required conditions
Main CI Pipeline / DB Migration Test (push) Blocked by required conditions
Some checks are pending
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Waiting to run
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Blocked by required conditions
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Blocked by required conditions
Main CI Pipeline / Check Changed Files (push) Waiting to run
Main CI Pipeline / API Tests (push) Blocked by required conditions
Main CI Pipeline / Web Tests (push) Blocked by required conditions
Main CI Pipeline / Style Check (push) Waiting to run
Main CI Pipeline / VDB Tests (push) Blocked by required conditions
Main CI Pipeline / DB Migration Test (push) Blocked by required conditions
Co-authored-by: Jacky Su <jacky_su@trendmicro.com>
This commit is contained in:
68
api/tests/unit_tests/libs/test_custom_inputs.py
Normal file
68
api/tests/unit_tests/libs/test_custom_inputs.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Unit tests for custom input types."""
|
||||
|
||||
import pytest
|
||||
|
||||
from libs.custom_inputs import time_duration
|
||||
|
||||
|
||||
class TestTimeDuration:
|
||||
"""Test time_duration input validator."""
|
||||
|
||||
def test_valid_days(self):
|
||||
"""Test valid days format."""
|
||||
result = time_duration("7d")
|
||||
assert result == "7d"
|
||||
|
||||
def test_valid_hours(self):
|
||||
"""Test valid hours format."""
|
||||
result = time_duration("4h")
|
||||
assert result == "4h"
|
||||
|
||||
def test_valid_minutes(self):
|
||||
"""Test valid minutes format."""
|
||||
result = time_duration("30m")
|
||||
assert result == "30m"
|
||||
|
||||
def test_valid_seconds(self):
|
||||
"""Test valid seconds format."""
|
||||
result = time_duration("30s")
|
||||
assert result == "30s"
|
||||
|
||||
def test_uppercase_conversion(self):
|
||||
"""Test uppercase units are converted to lowercase."""
|
||||
result = time_duration("7D")
|
||||
assert result == "7d"
|
||||
|
||||
result = time_duration("4H")
|
||||
assert result == "4h"
|
||||
|
||||
def test_invalid_format_no_unit(self):
|
||||
"""Test invalid format without unit."""
|
||||
with pytest.raises(ValueError, match="Invalid time duration format"):
|
||||
time_duration("7")
|
||||
|
||||
def test_invalid_format_wrong_unit(self):
|
||||
"""Test invalid format with wrong unit."""
|
||||
with pytest.raises(ValueError, match="Invalid time duration format"):
|
||||
time_duration("7days")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid time duration format"):
|
||||
time_duration("7x")
|
||||
|
||||
def test_invalid_format_no_number(self):
|
||||
"""Test invalid format without number."""
|
||||
with pytest.raises(ValueError, match="Invalid time duration format"):
|
||||
time_duration("d")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid time duration format"):
|
||||
time_duration("abc")
|
||||
|
||||
def test_empty_string(self):
|
||||
"""Test empty string."""
|
||||
with pytest.raises(ValueError, match="Time duration cannot be empty"):
|
||||
time_duration("")
|
||||
|
||||
def test_none(self):
|
||||
"""Test None value."""
|
||||
with pytest.raises(ValueError, match="Time duration cannot be empty"):
|
||||
time_duration(None)
|
||||
91
api/tests/unit_tests/libs/test_time_parser.py
Normal file
91
api/tests/unit_tests/libs/test_time_parser.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Unit tests for time parser utility."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from libs.time_parser import get_time_threshold, parse_time_duration
|
||||
|
||||
|
||||
class TestParseTimeDuration:
|
||||
"""Test parse_time_duration function."""
|
||||
|
||||
def test_parse_days(self):
|
||||
"""Test parsing days."""
|
||||
result = parse_time_duration("7d")
|
||||
assert result == timedelta(days=7)
|
||||
|
||||
def test_parse_hours(self):
|
||||
"""Test parsing hours."""
|
||||
result = parse_time_duration("4h")
|
||||
assert result == timedelta(hours=4)
|
||||
|
||||
def test_parse_minutes(self):
|
||||
"""Test parsing minutes."""
|
||||
result = parse_time_duration("30m")
|
||||
assert result == timedelta(minutes=30)
|
||||
|
||||
def test_parse_seconds(self):
|
||||
"""Test parsing seconds."""
|
||||
result = parse_time_duration("30s")
|
||||
assert result == timedelta(seconds=30)
|
||||
|
||||
def test_parse_uppercase(self):
|
||||
"""Test parsing uppercase units."""
|
||||
result = parse_time_duration("7D")
|
||||
assert result == timedelta(days=7)
|
||||
|
||||
def test_parse_invalid_format(self):
|
||||
"""Test parsing invalid format."""
|
||||
result = parse_time_duration("7days")
|
||||
assert result is None
|
||||
|
||||
result = parse_time_duration("abc")
|
||||
assert result is None
|
||||
|
||||
result = parse_time_duration("7")
|
||||
assert result is None
|
||||
|
||||
def test_parse_empty_string(self):
|
||||
"""Test parsing empty string."""
|
||||
result = parse_time_duration("")
|
||||
assert result is None
|
||||
|
||||
def test_parse_none(self):
|
||||
"""Test parsing None."""
|
||||
result = parse_time_duration(None)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGetTimeThreshold:
|
||||
"""Test get_time_threshold function."""
|
||||
|
||||
def test_get_threshold_days(self):
|
||||
"""Test getting threshold for days."""
|
||||
before = datetime.now(UTC)
|
||||
result = get_time_threshold("7d")
|
||||
after = datetime.now(UTC)
|
||||
|
||||
assert result is not None
|
||||
# Result should be approximately 7 days ago
|
||||
expected = before - timedelta(days=7)
|
||||
# Allow 1 second tolerance for test execution time
|
||||
assert abs((result - expected).total_seconds()) < 1
|
||||
|
||||
def test_get_threshold_hours(self):
|
||||
"""Test getting threshold for hours."""
|
||||
before = datetime.now(UTC)
|
||||
result = get_time_threshold("4h")
|
||||
after = datetime.now(UTC)
|
||||
|
||||
assert result is not None
|
||||
expected = before - timedelta(hours=4)
|
||||
assert abs((result - expected).total_seconds()) < 1
|
||||
|
||||
def test_get_threshold_invalid(self):
|
||||
"""Test getting threshold with invalid duration."""
|
||||
result = get_time_threshold("invalid")
|
||||
assert result is None
|
||||
|
||||
def test_get_threshold_none(self):
|
||||
"""Test getting threshold with None."""
|
||||
result = get_time_threshold(None)
|
||||
assert result is None
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Unit tests for workflow run repository with status filter."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from models import WorkflowRun, WorkflowRunTriggeredFrom
|
||||
from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository
|
||||
|
||||
|
||||
class TestDifyAPISQLAlchemyWorkflowRunRepository:
|
||||
"""Test workflow run repository with status filtering."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session_maker(self):
|
||||
"""Create a mock session maker."""
|
||||
return MagicMock(spec=sessionmaker)
|
||||
|
||||
@pytest.fixture
|
||||
def repository(self, mock_session_maker):
|
||||
"""Create repository instance with mock session."""
|
||||
return DifyAPISQLAlchemyWorkflowRunRepository(mock_session_maker)
|
||||
|
||||
def test_get_paginated_workflow_runs_without_status(self, repository, mock_session_maker):
|
||||
"""Test getting paginated workflow runs without status filter."""
|
||||
# Arrange
|
||||
tenant_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
|
||||
mock_runs = [MagicMock(spec=WorkflowRun) for _ in range(3)]
|
||||
mock_session.scalars.return_value.all.return_value = mock_runs
|
||||
|
||||
# Act
|
||||
result = repository.get_paginated_workflow_runs(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
|
||||
limit=20,
|
||||
last_id=None,
|
||||
status=None,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(result.data) == 3
|
||||
assert result.limit == 20
|
||||
assert result.has_more is False
|
||||
|
||||
def test_get_paginated_workflow_runs_with_status_filter(self, repository, mock_session_maker):
|
||||
"""Test getting paginated workflow runs with status filter."""
|
||||
# Arrange
|
||||
tenant_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
|
||||
mock_runs = [MagicMock(spec=WorkflowRun, status="succeeded") for _ in range(2)]
|
||||
mock_session.scalars.return_value.all.return_value = mock_runs
|
||||
|
||||
# Act
|
||||
result = repository.get_paginated_workflow_runs(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
|
||||
limit=20,
|
||||
last_id=None,
|
||||
status="succeeded",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(result.data) == 2
|
||||
assert all(run.status == "succeeded" for run in result.data)
|
||||
|
||||
def test_get_workflow_runs_count_without_status(self, repository, mock_session_maker):
|
||||
"""Test getting workflow runs count without status filter."""
|
||||
# Arrange
|
||||
tenant_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
|
||||
# Mock the GROUP BY query results
|
||||
mock_results = [
|
||||
("succeeded", 5),
|
||||
("failed", 2),
|
||||
("running", 1),
|
||||
]
|
||||
mock_session.execute.return_value.all.return_value = mock_results
|
||||
|
||||
# Act
|
||||
result = repository.get_workflow_runs_count(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
|
||||
status=None,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result["total"] == 8
|
||||
assert result["succeeded"] == 5
|
||||
assert result["failed"] == 2
|
||||
assert result["running"] == 1
|
||||
assert result["stopped"] == 0
|
||||
assert result["partial-succeeded"] == 0
|
||||
|
||||
def test_get_workflow_runs_count_with_status_filter(self, repository, mock_session_maker):
|
||||
"""Test getting workflow runs count with status filter."""
|
||||
# Arrange
|
||||
tenant_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
|
||||
# Mock the count query for succeeded status
|
||||
mock_session.scalar.return_value = 5
|
||||
|
||||
# Act
|
||||
result = repository.get_workflow_runs_count(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
|
||||
status="succeeded",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result["total"] == 5
|
||||
assert result["succeeded"] == 5
|
||||
assert result["running"] == 0
|
||||
assert result["failed"] == 0
|
||||
assert result["stopped"] == 0
|
||||
assert result["partial-succeeded"] == 0
|
||||
|
||||
def test_get_workflow_runs_count_with_invalid_status(self, repository, mock_session_maker):
|
||||
"""Test that invalid status is still counted in total but not in any specific status."""
|
||||
# Arrange
|
||||
tenant_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
|
||||
# Mock count query returning 0 for invalid status
|
||||
mock_session.scalar.return_value = 0
|
||||
|
||||
# Act
|
||||
result = repository.get_workflow_runs_count(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
|
||||
status="invalid_status",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result["total"] == 0
|
||||
assert all(result[status] == 0 for status in ["running", "succeeded", "failed", "stopped", "partial-succeeded"])
|
||||
|
||||
def test_get_workflow_runs_count_with_time_range(self, repository, mock_session_maker):
|
||||
"""Test getting workflow runs count with time range filter verifies SQL query construction."""
|
||||
# Arrange
|
||||
tenant_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
|
||||
# Mock the GROUP BY query results
|
||||
mock_results = [
|
||||
("succeeded", 3),
|
||||
("running", 2),
|
||||
]
|
||||
mock_session.execute.return_value.all.return_value = mock_results
|
||||
|
||||
# Act
|
||||
result = repository.get_workflow_runs_count(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
|
||||
status=None,
|
||||
time_range="1d",
|
||||
)
|
||||
|
||||
# Assert results
|
||||
assert result["total"] == 5
|
||||
assert result["succeeded"] == 3
|
||||
assert result["running"] == 2
|
||||
assert result["failed"] == 0
|
||||
|
||||
# Verify that execute was called (which means GROUP BY query was used)
|
||||
assert mock_session.execute.called, "execute should have been called for GROUP BY query"
|
||||
|
||||
# Verify SQL query includes time filter by checking the statement
|
||||
call_args = mock_session.execute.call_args
|
||||
assert call_args is not None, "execute should have been called with a statement"
|
||||
|
||||
# The first argument should be the SQL statement
|
||||
stmt = call_args[0][0]
|
||||
# Convert to string to inspect the query
|
||||
query_str = str(stmt.compile(compile_kwargs={"literal_binds": True}))
|
||||
|
||||
# Verify the query includes created_at filter
|
||||
# The query should have a WHERE clause with created_at comparison
|
||||
assert "created_at" in query_str.lower() or "workflow_runs.created_at" in query_str.lower(), (
|
||||
"Query should include created_at filter for time range"
|
||||
)
|
||||
|
||||
def test_get_workflow_runs_count_with_status_and_time_range(self, repository, mock_session_maker):
|
||||
"""Test getting workflow runs count with both status and time range filters verifies SQL query."""
|
||||
# Arrange
|
||||
tenant_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
|
||||
# Mock the count query for running status within time range
|
||||
mock_session.scalar.return_value = 2
|
||||
|
||||
# Act
|
||||
result = repository.get_workflow_runs_count(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
|
||||
status="running",
|
||||
time_range="1d",
|
||||
)
|
||||
|
||||
# Assert results
|
||||
assert result["total"] == 2
|
||||
assert result["running"] == 2
|
||||
assert result["succeeded"] == 0
|
||||
assert result["failed"] == 0
|
||||
|
||||
# Verify that scalar was called (which means COUNT query was used)
|
||||
assert mock_session.scalar.called, "scalar should have been called for count query"
|
||||
|
||||
# Verify SQL query includes both status and time filter
|
||||
call_args = mock_session.scalar.call_args
|
||||
assert call_args is not None, "scalar should have been called with a statement"
|
||||
|
||||
# The first argument should be the SQL statement
|
||||
stmt = call_args[0][0]
|
||||
# Convert to string to inspect the query
|
||||
query_str = str(stmt.compile(compile_kwargs={"literal_binds": True}))
|
||||
|
||||
# Verify the query includes both filters
|
||||
assert "created_at" in query_str.lower() or "workflow_runs.created_at" in query_str.lower(), (
|
||||
"Query should include created_at filter for time range"
|
||||
)
|
||||
assert "status" in query_str.lower() or "workflow_runs.status" in query_str.lower(), (
|
||||
"Query should include status filter"
|
||||
)
|
||||
Reference in New Issue
Block a user