Skip to content

Commit 0d5a9ea

Browse files
Merge pull request #143 from JohnsonChin1009/test/memo
test: added unit testing for memo.py
2 parents a0bf6be + e99c7e9 commit 0d5a9ea

1 file changed

Lines changed: 378 additions & 0 deletions

File tree

tests/unit/test_memo.py

Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
1+
"""
2+
Unit tests for virtuals_acp.memo module
3+
"""
4+
5+
import json
6+
import pytest
7+
from unittest.mock import MagicMock, patch
8+
from datetime import datetime, timezone
9+
10+
from virtuals_acp.memo import ACPMemo
11+
from virtuals_acp.models import (
12+
ACPJobPhase,
13+
MemoType,
14+
ACPMemoStatus,
15+
PayloadType,
16+
GenericPayload,
17+
OperationPayload,
18+
)
19+
20+
21+
class TestACPMemo:
22+
"""Test suite for ACPMemo class"""
23+
24+
@pytest.fixture
25+
def mock_contract_client(self):
26+
"""Create a mock contract client"""
27+
return MagicMock()
28+
29+
@pytest.fixture
30+
def sample_memo_data(self, mock_contract_client):
31+
"""Create sample memo data for testing"""
32+
return {
33+
"contract_client": mock_contract_client,
34+
"id": 1,
35+
"type": MemoType.MESSAGE,
36+
"content": "Test memo content",
37+
"next_phase": ACPJobPhase.NEGOTIATION,
38+
"status": ACPMemoStatus.PENDING,
39+
"signed_reason": None,
40+
"expiry": None,
41+
"payable_details": None,
42+
"txn_hash": None,
43+
"signed_txn_hash": None,
44+
}
45+
46+
@pytest.fixture
47+
def basic_memo(self, sample_memo_data):
48+
"""Create a basic ACPMemo instance for testing"""
49+
return ACPMemo.model_construct(**sample_memo_data)
50+
51+
class TestInitialization:
52+
"""Test memo initialization"""
53+
54+
def test_should_initialize_with_all_parameters(self, sample_memo_data):
55+
"""Should correctly initialize memo with all parameters"""
56+
memo = ACPMemo.model_construct(**sample_memo_data)
57+
58+
assert memo.id == 1
59+
assert memo.type == MemoType.MESSAGE
60+
assert memo.content == "Test memo content"
61+
assert memo.next_phase == ACPJobPhase.NEGOTIATION
62+
assert memo.status == ACPMemoStatus.PENDING
63+
assert memo.signed_reason is None
64+
assert memo.expiry is None
65+
assert memo.payable_details is None
66+
assert memo.txn_hash is None
67+
assert memo.signed_txn_hash is None
68+
69+
def test_should_initialize_with_optional_fields(self, sample_memo_data):
70+
"""Should initialize with optional fields"""
71+
expiry_time = datetime.now(timezone.utc)
72+
sample_memo_data["signed_reason"] = "Approved with conditions"
73+
sample_memo_data["expiry"] = expiry_time
74+
sample_memo_data["txn_hash"] = "0xabc123"
75+
sample_memo_data["signed_txn_hash"] = "0xdef456"
76+
77+
memo = ACPMemo.model_construct(**sample_memo_data)
78+
79+
assert memo.signed_reason == "Approved with conditions"
80+
assert memo.expiry == expiry_time
81+
assert memo.txn_hash == "0xabc123"
82+
assert memo.signed_txn_hash == "0xdef456"
83+
84+
def test_should_parse_structured_content_in_post_init(self, sample_memo_data):
85+
"""Should parse structured content from JSON in model_post_init"""
86+
content_data = {
87+
"type": "fund_response",
88+
"data": {"amount": 100.0, "status": "success"}
89+
}
90+
sample_memo_data["content"] = json.dumps(content_data)
91+
92+
with patch('virtuals_acp.memo.try_parse_json_model') as mock_parse:
93+
mock_payload = MagicMock(spec=GenericPayload)
94+
mock_payload.type = PayloadType.FUND_RESPONSE
95+
mock_parse.return_value = mock_payload
96+
97+
# model_construct automatically calls model_post_init
98+
memo = ACPMemo.model_construct(**sample_memo_data)
99+
100+
# Verify it was called with correct parameters
101+
mock_parse.assert_called_with(sample_memo_data["content"], GenericPayload)
102+
assert memo.structured_content == mock_payload
103+
104+
def test_should_handle_unparseable_content(self, sample_memo_data):
105+
"""Should handle content that cannot be parsed as structured content"""
106+
sample_memo_data["content"] = "Plain text content"
107+
108+
with patch('virtuals_acp.memo.try_parse_json_model', return_value=None):
109+
memo = ACPMemo.model_construct(**sample_memo_data)
110+
memo.model_post_init(None)
111+
112+
assert memo.structured_content is None
113+
114+
def test_should_convert_payable_details_amounts(self, sample_memo_data):
115+
"""Should convert amount and feeAmount to int in payable_details"""
116+
sample_memo_data["payable_details"] = {
117+
"amount": "1000000",
118+
"feeAmount": "50000",
119+
"token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
120+
}
121+
122+
memo = ACPMemo.model_construct(**sample_memo_data)
123+
memo.model_post_init(None)
124+
125+
assert memo.payable_details["amount"] == 1000000
126+
assert memo.payable_details["feeAmount"] == 50000
127+
assert isinstance(memo.payable_details["amount"], int)
128+
assert isinstance(memo.payable_details["feeAmount"], int)
129+
130+
def test_should_skip_payable_conversion_when_none(self, sample_memo_data):
131+
"""Should skip payable_details conversion when None"""
132+
sample_memo_data["payable_details"] = None
133+
134+
memo = ACPMemo.model_construct(**sample_memo_data)
135+
memo.model_post_init(None)
136+
137+
assert memo.payable_details is None
138+
139+
class TestStr:
140+
"""Test __str__ method"""
141+
142+
def test_should_return_formatted_string(self, basic_memo):
143+
"""Should return formatted string representation"""
144+
# Patch model_dump at class level
145+
with patch('virtuals_acp.memo.ACPMemo.model_dump') as mock_dump:
146+
mock_dump.return_value = {
147+
"id": 1,
148+
"type": MemoType.MESSAGE,
149+
"content": "Test memo content",
150+
"next_phase": ACPJobPhase.NEGOTIATION,
151+
"status": ACPMemoStatus.PENDING,
152+
}
153+
result = str(basic_memo)
154+
155+
# Verify model_dump was called with exclude
156+
mock_dump.assert_called_once_with(exclude={'payable_details'})
157+
assert result.startswith("AcpMemo(")
158+
159+
def test_should_exclude_payable_details_from_string(self, sample_memo_data):
160+
"""Should exclude payable_details from string representation"""
161+
sample_memo_data["payable_details"] = {
162+
"amount": 1000000,
163+
"feeAmount": 50000,
164+
"token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
165+
}
166+
167+
memo = ACPMemo.model_construct(**sample_memo_data)
168+
169+
# Patch model_dump at class level
170+
with patch('virtuals_acp.memo.ACPMemo.model_dump') as mock_dump:
171+
mock_dump.return_value = {"id": 1, "type": MemoType.MESSAGE}
172+
str(memo)
173+
174+
# Verify payable_details was excluded
175+
mock_dump.assert_called_once_with(exclude={'payable_details'})
176+
177+
class TestPayloadTypeProperty:
178+
"""Test payload_type property"""
179+
180+
def test_should_return_payload_type_when_structured_content_exists(
181+
self, sample_memo_data
182+
):
183+
"""Should return payload type from structured content"""
184+
mock_payload = MagicMock(spec=GenericPayload)
185+
mock_payload.type = PayloadType.FUND_RESPONSE
186+
187+
memo = ACPMemo.model_construct(**sample_memo_data)
188+
memo.structured_content = mock_payload
189+
190+
assert memo.payload_type == PayloadType.FUND_RESPONSE
191+
192+
def test_should_return_none_when_no_structured_content(self, basic_memo):
193+
"""Should return None when structured_content is None"""
194+
basic_memo.structured_content = None
195+
196+
assert basic_memo.payload_type is None
197+
198+
class TestCreate:
199+
"""Test create method"""
200+
201+
def test_should_call_contract_client_create_memo(
202+
self, basic_memo, mock_contract_client
203+
):
204+
"""Should call contract client's create_memo method"""
205+
mock_operation = MagicMock(spec=OperationPayload)
206+
mock_contract_client.create_memo.return_value = mock_operation
207+
208+
result = basic_memo.create(job_id=123, is_secured=True)
209+
210+
mock_contract_client.create_memo.assert_called_once_with(
211+
123, # job_id
212+
"Test memo content", # content
213+
MemoType.MESSAGE, # type
214+
True, # is_secured
215+
ACPJobPhase.NEGOTIATION, # next_phase
216+
)
217+
assert result == mock_operation
218+
219+
def test_should_pass_is_secured_false_when_specified(
220+
self, basic_memo, mock_contract_client
221+
):
222+
"""Should pass is_secured=False when specified"""
223+
mock_operation = MagicMock(spec=OperationPayload)
224+
mock_contract_client.create_memo.return_value = mock_operation
225+
226+
basic_memo.create(job_id=456, is_secured=False)
227+
228+
call_args = mock_contract_client.create_memo.call_args
229+
assert call_args[0][3] is False # is_secured parameter
230+
231+
def test_should_use_default_is_secured_true(
232+
self, basic_memo, mock_contract_client
233+
):
234+
"""Should default is_secured to True"""
235+
mock_operation = MagicMock(spec=OperationPayload)
236+
mock_contract_client.create_memo.return_value = mock_operation
237+
238+
basic_memo.create(job_id=789)
239+
240+
call_args = mock_contract_client.create_memo.call_args
241+
assert call_args[0][3] is True # is_secured parameter
242+
243+
class TestSign:
244+
"""Test sign method"""
245+
246+
def test_should_sign_memo_with_approval(
247+
self, basic_memo, mock_contract_client
248+
):
249+
"""Should sign memo with approved=True"""
250+
mock_operation = MagicMock(spec=OperationPayload)
251+
mock_contract_client.sign_memo.return_value = mock_operation
252+
mock_contract_client.handle_operation.return_value = {"hash": "0xsigned"}
253+
254+
with patch('virtuals_acp.memo.get_txn_hash_from_response', return_value="0xsigned"):
255+
result = basic_memo.sign(approved=True, reason="Looks good")
256+
257+
mock_contract_client.sign_memo.assert_called_once_with(
258+
1, # memo id
259+
True, # approved
260+
"Looks good" # reason
261+
)
262+
mock_contract_client.handle_operation.assert_called_once_with([mock_operation])
263+
assert result == "0xsigned"
264+
265+
def test_should_sign_memo_with_rejection(
266+
self, basic_memo, mock_contract_client
267+
):
268+
"""Should sign memo with approved=False"""
269+
mock_operation = MagicMock(spec=OperationPayload)
270+
mock_contract_client.sign_memo.return_value = mock_operation
271+
mock_contract_client.handle_operation.return_value = {"hash": "0xrejected"}
272+
273+
with patch('virtuals_acp.memo.get_txn_hash_from_response', return_value="0xrejected"):
274+
result = basic_memo.sign(approved=False, reason="Not acceptable")
275+
276+
mock_contract_client.sign_memo.assert_called_once_with(
277+
1, # memo id
278+
False, # approved
279+
"Not acceptable" # reason
280+
)
281+
assert result == "0xrejected"
282+
283+
def test_should_sign_without_reason(
284+
self, basic_memo, mock_contract_client
285+
):
286+
"""Should sign memo without providing a reason"""
287+
mock_operation = MagicMock(spec=OperationPayload)
288+
mock_contract_client.sign_memo.return_value = mock_operation
289+
mock_contract_client.handle_operation.return_value = {"hash": "0xsigned"}
290+
291+
with patch('virtuals_acp.memo.get_txn_hash_from_response', return_value="0xsigned"):
292+
result = basic_memo.sign(approved=True)
293+
294+
call_args = mock_contract_client.sign_memo.call_args
295+
assert call_args[0][2] is None # reason parameter
296+
297+
def test_should_return_none_when_no_hash_in_response(
298+
self, basic_memo, mock_contract_client
299+
):
300+
"""Should return None when response has no transaction hash"""
301+
mock_operation = MagicMock(spec=OperationPayload)
302+
mock_contract_client.sign_memo.return_value = mock_operation
303+
mock_contract_client.handle_operation.return_value = {}
304+
305+
with patch('virtuals_acp.memo.get_txn_hash_from_response', return_value=None):
306+
result = basic_memo.sign(approved=True, reason="Test")
307+
308+
assert result is None
309+
310+
class TestDifferentMemoTypes:
311+
"""Test different memo types"""
312+
313+
def test_should_handle_context_url_memo_type(self, sample_memo_data):
314+
"""Should handle CONTEXT_URL memo type"""
315+
sample_memo_data["type"] = MemoType.CONTEXT_URL
316+
sample_memo_data["content"] = "https://example.com/context"
317+
318+
memo = ACPMemo.model_construct(**sample_memo_data)
319+
320+
assert memo.type == MemoType.CONTEXT_URL
321+
assert memo.content == "https://example.com/context"
322+
323+
def test_should_handle_notification_memo_type(self, sample_memo_data):
324+
"""Should handle NOTIFICATION memo type"""
325+
sample_memo_data["type"] = MemoType.NOTIFICATION
326+
sample_memo_data["content"] = "Job started successfully"
327+
328+
memo = ACPMemo.model_construct(**sample_memo_data)
329+
330+
assert memo.type == MemoType.NOTIFICATION
331+
332+
def test_should_handle_payable_request_memo_type(self, sample_memo_data):
333+
"""Should handle PAYABLE_REQUEST memo type"""
334+
sample_memo_data["type"] = MemoType.PAYABLE_REQUEST
335+
sample_memo_data["payable_details"] = {
336+
"amount": "5000000",
337+
"feeAmount": "250000",
338+
"token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
339+
}
340+
341+
memo = ACPMemo.model_construct(**sample_memo_data)
342+
memo.model_post_init(None)
343+
344+
assert memo.type == MemoType.PAYABLE_REQUEST
345+
assert memo.payable_details["amount"] == 5000000
346+
assert memo.payable_details["feeAmount"] == 250000
347+
348+
class TestDifferentMemoStatuses:
349+
"""Test different memo statuses"""
350+
351+
def test_should_handle_approved_status(self, sample_memo_data):
352+
"""Should handle APPROVED status"""
353+
sample_memo_data["status"] = ACPMemoStatus.APPROVED
354+
sample_memo_data["signed_reason"] = "Approved by evaluator"
355+
356+
memo = ACPMemo.model_construct(**sample_memo_data)
357+
358+
assert memo.status == ACPMemoStatus.APPROVED
359+
assert memo.signed_reason == "Approved by evaluator"
360+
361+
def test_should_handle_rejected_status(self, sample_memo_data):
362+
"""Should handle REJECTED status"""
363+
sample_memo_data["status"] = ACPMemoStatus.REJECTED
364+
sample_memo_data["signed_reason"] = "Does not meet requirements"
365+
366+
memo = ACPMemo.model_construct(**sample_memo_data)
367+
368+
assert memo.status == ACPMemoStatus.REJECTED
369+
370+
def test_should_handle_expired_status(self, sample_memo_data):
371+
"""Should handle EXPIRED status"""
372+
sample_memo_data["status"] = ACPMemoStatus.EXPIRED
373+
sample_memo_data["expiry"] = datetime.now(timezone.utc)
374+
375+
memo = ACPMemo.model_construct(**sample_memo_data)
376+
377+
assert memo.status == ACPMemoStatus.EXPIRED
378+
assert memo.expiry is not None

0 commit comments

Comments
 (0)