Skip to content

Commit 3a6a45b

Browse files
committed
test reorg
1 parent b008ff8 commit 3a6a45b

File tree

6 files changed

+897
-845
lines changed

6 files changed

+897
-845
lines changed
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""
2+
Tests for individual expense activities.
3+
Focuses on activity behavior, parameters, error handling, and HTTP interactions.
4+
"""
5+
6+
import uuid
7+
from unittest.mock import AsyncMock, MagicMock, patch
8+
9+
import httpx
10+
import pytest
11+
from temporalio import activity
12+
from temporalio.activity import _CompleteAsyncError
13+
from temporalio.testing import ActivityEnvironment
14+
15+
from expense import EXPENSE_SERVER_HOST_PORT
16+
from expense.activities import (
17+
create_expense_activity,
18+
payment_activity,
19+
wait_for_decision_activity,
20+
)
21+
22+
23+
class TestCreateExpenseActivity:
24+
"""Test create_expense_activity individual behavior"""
25+
26+
@pytest.fixture
27+
def activity_env(self):
28+
return ActivityEnvironment()
29+
30+
async def test_create_expense_activity_success(self, activity_env):
31+
"""Test successful expense creation"""
32+
with patch("httpx.AsyncClient") as mock_client:
33+
# Mock successful HTTP response
34+
mock_response = AsyncMock()
35+
mock_response.text = "SUCCEED"
36+
mock_response.raise_for_status = AsyncMock()
37+
38+
mock_client_instance = AsyncMock()
39+
mock_client_instance.get.return_value = mock_response
40+
mock_client.return_value.__aenter__.return_value = mock_client_instance
41+
42+
# Execute activity
43+
result = await activity_env.run(create_expense_activity, "test-expense-123")
44+
45+
# Verify HTTP call
46+
mock_client_instance.get.assert_called_once_with(
47+
f"{EXPENSE_SERVER_HOST_PORT}/create",
48+
params={"is_api_call": "true", "id": "test-expense-123"},
49+
)
50+
mock_response.raise_for_status.assert_called_once()
51+
52+
# Activity should return None on success
53+
assert result is None
54+
55+
async def test_create_expense_activity_empty_id(self, activity_env):
56+
"""Test create expense activity with empty expense ID"""
57+
with pytest.raises(ValueError, match="expense id is empty"):
58+
await activity_env.run(create_expense_activity, "")
59+
60+
async def test_create_expense_activity_http_error(self, activity_env):
61+
"""Test create expense activity with HTTP error"""
62+
with patch("httpx.AsyncClient") as mock_client:
63+
# Mock HTTP error - use MagicMock for raise_for_status to avoid async issues
64+
mock_response = MagicMock()
65+
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
66+
"Server Error", request=MagicMock(), response=MagicMock()
67+
)
68+
69+
mock_client_instance = AsyncMock()
70+
mock_client_instance.get.return_value = mock_response
71+
mock_client.return_value.__aenter__.return_value = mock_client_instance
72+
73+
with pytest.raises(httpx.HTTPStatusError):
74+
await activity_env.run(create_expense_activity, "test-expense-123")
75+
76+
async def test_create_expense_activity_server_error_response(self, activity_env):
77+
"""Test create expense activity with server error response"""
78+
with patch("httpx.AsyncClient") as mock_client:
79+
# Mock error response
80+
mock_response = AsyncMock()
81+
mock_response.text = "ERROR:ID_ALREADY_EXISTS"
82+
mock_response.raise_for_status = AsyncMock()
83+
84+
mock_client_instance = AsyncMock()
85+
mock_client_instance.get.return_value = mock_response
86+
mock_client.return_value.__aenter__.return_value = mock_client_instance
87+
88+
with pytest.raises(Exception, match="ERROR:ID_ALREADY_EXISTS"):
89+
await activity_env.run(create_expense_activity, "test-expense-123")
90+
91+
92+
class TestWaitForDecisionActivity:
93+
"""Test wait_for_decision_activity individual behavior"""
94+
95+
@pytest.fixture
96+
def activity_env(self):
97+
return ActivityEnvironment()
98+
99+
async def test_wait_for_decision_activity_empty_id(self, activity_env):
100+
"""Test wait for decision activity with empty expense ID"""
101+
with pytest.raises(ValueError, match="expense id is empty"):
102+
await activity_env.run(wait_for_decision_activity, "")
103+
104+
async def test_wait_for_decision_activity_callback_registration_success(
105+
self, activity_env
106+
):
107+
"""Test successful callback registration behavior"""
108+
with patch("httpx.AsyncClient") as mock_client:
109+
# Mock successful callback registration
110+
mock_response = AsyncMock()
111+
mock_response.text = "SUCCEED"
112+
mock_response.raise_for_status = AsyncMock()
113+
114+
mock_client_instance = AsyncMock()
115+
mock_client_instance.post.return_value = mock_response
116+
mock_client.return_value.__aenter__.return_value = mock_client_instance
117+
118+
# The activity should raise _CompleteAsyncError when it calls activity.raise_complete_async()
119+
# This is expected behavior - the activity registers the callback then signals async completion
120+
with pytest.raises(_CompleteAsyncError):
121+
await activity_env.run(wait_for_decision_activity, "test-expense-123")
122+
123+
# Verify callback registration call was made
124+
mock_client_instance.post.assert_called_once()
125+
call_args = mock_client_instance.post.call_args
126+
assert f"{EXPENSE_SERVER_HOST_PORT}/registerCallback" in call_args[0][0]
127+
128+
# Verify task token in form data
129+
assert "task_token" in call_args[1]["data"]
130+
131+
async def test_wait_for_decision_activity_callback_registration_failure(
132+
self, activity_env
133+
):
134+
"""Test callback registration failure"""
135+
with patch("httpx.AsyncClient") as mock_client:
136+
# Mock failed callback registration
137+
mock_response = AsyncMock()
138+
mock_response.text = "ERROR:INVALID_ID"
139+
mock_response.raise_for_status = AsyncMock()
140+
141+
mock_client_instance = AsyncMock()
142+
mock_client_instance.post.return_value = mock_response
143+
mock_client.return_value.__aenter__.return_value = mock_client_instance
144+
145+
with pytest.raises(
146+
Exception, match="register callback failed status: ERROR:INVALID_ID"
147+
):
148+
await activity_env.run(wait_for_decision_activity, "test-expense-123")
149+
150+
151+
class TestPaymentActivity:
152+
"""Test payment_activity individual behavior"""
153+
154+
@pytest.fixture
155+
def activity_env(self):
156+
return ActivityEnvironment()
157+
158+
async def test_payment_activity_success(self, activity_env):
159+
"""Test successful payment processing"""
160+
with patch("httpx.AsyncClient") as mock_client:
161+
# Mock successful payment response
162+
mock_response = AsyncMock()
163+
mock_response.text = "SUCCEED"
164+
mock_response.raise_for_status = AsyncMock()
165+
166+
mock_client_instance = AsyncMock()
167+
mock_client_instance.get.return_value = mock_response
168+
mock_client.return_value.__aenter__.return_value = mock_client_instance
169+
170+
# Execute activity
171+
result = await activity_env.run(payment_activity, "test-expense-123")
172+
173+
# Verify HTTP call
174+
mock_client_instance.get.assert_called_once_with(
175+
f"{EXPENSE_SERVER_HOST_PORT}/action",
176+
params={
177+
"is_api_call": "true",
178+
"type": "payment",
179+
"id": "test-expense-123",
180+
},
181+
)
182+
183+
# Activity should return None on success
184+
assert result is None
185+
186+
async def test_payment_activity_empty_id(self, activity_env):
187+
"""Test payment activity with empty expense ID"""
188+
with pytest.raises(ValueError, match="expense id is empty"):
189+
await activity_env.run(payment_activity, "")
190+
191+
async def test_payment_activity_payment_failure(self, activity_env):
192+
"""Test payment activity with payment failure"""
193+
with patch("httpx.AsyncClient") as mock_client:
194+
# Mock payment failure response
195+
mock_response = AsyncMock()
196+
mock_response.text = "ERROR:INSUFFICIENT_FUNDS"
197+
mock_response.raise_for_status = AsyncMock()
198+
199+
mock_client_instance = AsyncMock()
200+
mock_client_instance.get.return_value = mock_response
201+
mock_client.return_value.__aenter__.return_value = mock_client_instance
202+
203+
with pytest.raises(Exception, match="ERROR:INSUFFICIENT_FUNDS"):
204+
await activity_env.run(payment_activity, "test-expense-123")
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""
2+
Edge case tests for expense workflow and activities.
3+
Tests parameter validation, retries, error scenarios, and boundary conditions.
4+
"""
5+
6+
import uuid
7+
8+
import pytest
9+
from temporalio import activity
10+
from temporalio.client import Client, WorkflowFailureError
11+
from temporalio.exceptions import ApplicationError
12+
from temporalio.testing import WorkflowEnvironment
13+
from temporalio.worker import Worker
14+
15+
from expense.workflow import SampleExpenseWorkflow
16+
17+
18+
class TestWorkflowEdgeCases:
19+
"""Test edge cases in workflow behavior"""
20+
21+
async def test_workflow_with_retryable_activity_failures(
22+
self, client: Client, env: WorkflowEnvironment
23+
):
24+
"""Test workflow behavior with retryable activity failures"""
25+
task_queue = f"test-retryable-failures-{uuid.uuid4()}"
26+
create_call_count = 0
27+
payment_call_count = 0
28+
29+
@activity.defn(name="create_expense_activity")
30+
async def create_expense_retry(expense_id: str) -> None:
31+
nonlocal create_call_count
32+
create_call_count += 1
33+
if create_call_count == 1:
34+
# First call fails, but retryable
35+
raise Exception("Transient failure in create expense")
36+
return None # Second call succeeds
37+
38+
@activity.defn(name="wait_for_decision_activity")
39+
async def wait_for_decision_mock(expense_id: str) -> str:
40+
return "APPROVED"
41+
42+
@activity.defn(name="payment_activity")
43+
async def payment_retry(expense_id: str) -> None:
44+
nonlocal payment_call_count
45+
payment_call_count += 1
46+
if payment_call_count == 1:
47+
# First call fails, but retryable
48+
raise Exception("Transient failure in payment")
49+
return None # Second call succeeds
50+
51+
async with Worker(
52+
client,
53+
task_queue=task_queue,
54+
workflows=[SampleExpenseWorkflow],
55+
activities=[create_expense_retry, wait_for_decision_mock, payment_retry],
56+
):
57+
result = await client.execute_workflow(
58+
SampleExpenseWorkflow.run,
59+
"test-expense-retryable",
60+
id=f"test-workflow-retryable-{uuid.uuid4()}",
61+
task_queue=task_queue,
62+
)
63+
64+
# Should succeed after retries
65+
assert result == "COMPLETED"
66+
# Verify activities were retried
67+
assert create_call_count == 2
68+
assert payment_call_count == 2
69+
70+
async def test_workflow_logging_behavior(
71+
self, client: Client, env: WorkflowEnvironment
72+
):
73+
"""Test that workflow logging works correctly"""
74+
task_queue = f"test-logging-{uuid.uuid4()}"
75+
logged_messages = []
76+
77+
@activity.defn(name="create_expense_activity")
78+
async def create_expense_mock(expense_id: str) -> None:
79+
# Mock logging by capturing messages
80+
logged_messages.append(f"Creating expense: {expense_id}")
81+
return None
82+
83+
@activity.defn(name="wait_for_decision_activity")
84+
async def wait_for_decision_mock(expense_id: str) -> str:
85+
logged_messages.append(f"Waiting for decision: {expense_id}")
86+
return "APPROVED"
87+
88+
@activity.defn(name="payment_activity")
89+
async def payment_mock(expense_id: str) -> None:
90+
logged_messages.append(f"Processing payment: {expense_id}")
91+
return None
92+
93+
async with Worker(
94+
client,
95+
task_queue=task_queue,
96+
workflows=[SampleExpenseWorkflow],
97+
activities=[create_expense_mock, wait_for_decision_mock, payment_mock],
98+
):
99+
result = await client.execute_workflow(
100+
SampleExpenseWorkflow.run,
101+
"test-expense-logging",
102+
id=f"test-workflow-logging-{uuid.uuid4()}",
103+
task_queue=task_queue,
104+
)
105+
106+
assert result == "COMPLETED"
107+
# Verify logging occurred
108+
assert len(logged_messages) == 3
109+
assert "Creating expense: test-expense-logging" in logged_messages
110+
assert "Waiting for decision: test-expense-logging" in logged_messages
111+
assert "Processing payment: test-expense-logging" in logged_messages
112+
113+
async def test_workflow_parameter_validation(
114+
self, client: Client, env: WorkflowEnvironment
115+
):
116+
"""Test workflow with various parameter validation scenarios"""
117+
task_queue = f"test-param-validation-{uuid.uuid4()}"
118+
119+
@activity.defn(name="create_expense_activity")
120+
async def create_expense_validate(expense_id: str) -> None:
121+
if not expense_id or expense_id.strip() == "":
122+
raise ApplicationError(
123+
"expense id is empty or whitespace", non_retryable=True
124+
)
125+
return None
126+
127+
@activity.defn(name="wait_for_decision_activity")
128+
async def wait_for_decision_mock(expense_id: str) -> str:
129+
return "APPROVED"
130+
131+
@activity.defn(name="payment_activity")
132+
async def payment_mock(expense_id: str) -> None:
133+
return None
134+
135+
async with Worker(
136+
client,
137+
task_queue=task_queue,
138+
workflows=[SampleExpenseWorkflow],
139+
activities=[create_expense_validate, wait_for_decision_mock, payment_mock],
140+
):
141+
# Test with empty string
142+
with pytest.raises(WorkflowFailureError):
143+
await client.execute_workflow(
144+
SampleExpenseWorkflow.run,
145+
"", # Empty expense ID
146+
id=f"test-workflow-empty-id-{uuid.uuid4()}",
147+
task_queue=task_queue,
148+
)
149+
150+
# Test with whitespace-only string
151+
with pytest.raises(WorkflowFailureError):
152+
await client.execute_workflow(
153+
SampleExpenseWorkflow.run,
154+
" ", # Whitespace-only expense ID
155+
id=f"test-workflow-whitespace-id-{uuid.uuid4()}",
156+
task_queue=task_queue,
157+
)

0 commit comments

Comments
 (0)