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

Co-authored-by: Jacky Su <jacky_su@trendmicro.com>
This commit is contained in:
Jacky Su
2025-10-18 12:15:29 +08:00
committed by GitHub
parent 1a37989769
commit ac79691d69
10 changed files with 851 additions and 19 deletions

View 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)

View 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

View File

@@ -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"
)