11import os
22import uuid
33from datetime import datetime , timedelta
4+ from io import BytesIO
45
56import pytest
67from dotenv import load_dotenv
@@ -129,6 +130,47 @@ async def test_list_virtual_cards(self, extend):
129130 for card in response ["virtualCards" ]:
130131 assert card ["status" ] == "CLOSED"
131132
133+ @pytest .mark .asyncio
134+ async def test_list_virtual_cards_with_sorting (self , extend ):
135+ """Test listing virtual cards with various sorting options"""
136+
137+ # Test sorting by display name ascending
138+ asc_response = await extend .virtual_cards .get_virtual_cards (
139+ sort_field = "displayName" ,
140+ sort_direction = "ASC" ,
141+ per_page = 50 # Ensure we get enough cards to compare
142+ )
143+
144+ # Test sorting by display name descending
145+ desc_response = await extend .virtual_cards .get_virtual_cards (
146+ sort_field = "displayName" ,
147+ sort_direction = "DESC" ,
148+ per_page = 50 # Ensure we get enough cards to compare
149+ )
150+
151+ # Verify responses contain cards
152+ assert "virtualCards" in asc_response
153+ assert "virtualCards" in desc_response
154+
155+ # If sufficient cards exist, just verify the orders are different
156+ # rather than trying to implement our own sorting logic
157+ if len (asc_response ["virtualCards" ]) > 1 and len (desc_response ["virtualCards" ]) > 1 :
158+ asc_ids = [card ["id" ] for card in asc_response ["virtualCards" ]]
159+ desc_ids = [card ["id" ] for card in desc_response ["virtualCards" ]]
160+
161+ # Verify the orders are different for different sort directions
162+ assert asc_ids != desc_ids , "ASC and DESC sorting should produce different results"
163+
164+ # Test other sort fields
165+ for field in ["createdAt" , "updatedAt" , "balanceCents" , "status" , "type" ]:
166+ # Test both directions for each field
167+ for direction in ["ASC" , "DESC" ]:
168+ response = await extend .virtual_cards .get_virtual_cards (
169+ sort_field = field ,
170+ sort_direction = direction
171+ )
172+ assert "virtualCards" in response , f"Sorting by { field } { direction } should return virtual cards"
173+
132174
133175@pytest .mark .integration
134176class TestTransactions :
@@ -152,6 +194,54 @@ async def test_list_transactions(self, extend):
152194 for field in required_fields :
153195 assert field in transaction , f"Transaction should contain '{ field } ' field"
154196
197+ @pytest .mark .asyncio
198+ async def test_list_transactions_with_sorting (self , extend ):
199+ """Test listing transactions with various sorting options"""
200+
201+ # Define sort fields - positive for ASC, negative (prefixed with -) for DESC
202+ sort_fields = [
203+ "recipientName" , "-recipientName" ,
204+ "merchantName" , "-merchantName" ,
205+ "amount" , "-amount" ,
206+ "date" , "-date"
207+ ]
208+
209+ # Test each sort field
210+ for sort_field in sort_fields :
211+ # Get transactions with this sort
212+ response = await extend .transactions .get_transactions (
213+ sort_field = sort_field ,
214+ per_page = 10
215+ )
216+
217+ # Verify response contains transactions and basic structure
218+ assert isinstance (response , dict ), f"Response for sort { sort_field } should be a dictionary"
219+ assert "transactions" in response , f"Response for sort { sort_field } should contain 'transactions' key"
220+
221+ # If we have enough data, test opposite sort direction for comparison
222+ if len (response ["transactions" ]) > 1 :
223+ # Determine the field name and opposite sort field
224+ is_desc = sort_field .startswith ("-" )
225+ field_name = sort_field [1 :] if is_desc else sort_field
226+ opposite_sort = field_name if is_desc else f"-{ field_name } "
227+
228+ # Get transactions with opposite sort
229+ opposite_response = await extend .transactions .get_transactions (
230+ sort_field = opposite_sort ,
231+ per_page = 10
232+ )
233+
234+ # Get IDs in both sort orders for comparison
235+ sorted_ids = [tx ["id" ] for tx in response ["transactions" ]]
236+ opposite_sorted_ids = [tx ["id" ] for tx in opposite_response ["transactions" ]]
237+
238+ # If we have the same set of transactions in both responses,
239+ # verify that different sort directions produce different orders
240+ if set (sorted_ids ) == set (opposite_sorted_ids ) and len (sorted_ids ) > 1 :
241+ assert sorted_ids != opposite_sorted_ids , (
242+ f"Different sort directions for { field_name } should produce different results"
243+ )
244+
155245
156246@pytest .mark .integration
157247class TestRecurringCards :
@@ -303,6 +393,98 @@ async def test_get_expense_categories_and_labels(self, extend):
303393 assert "expenseLabels" in labels
304394
305395
396+ @pytest .mark .integration
397+ class TestTransactionExpenseData :
398+ """Integration tests for updating transaction expense data using a specific expense category and label"""
399+
400+ @pytest .mark .asyncio
401+ async def test_update_transaction_expense_data_with_specific_category_and_label (self , extend ):
402+ """Test updating the expense data for a transaction using a specific expense category and label."""
403+ # Retrieve available expense categories (active ones)
404+ categories_response = await extend .expense_data .get_expense_categories (active = True )
405+ assert "expenseCategories" in categories_response , "Response should include 'expenseCategories'"
406+ expense_categories = categories_response ["expenseCategories" ]
407+ assert expense_categories , "No expense categories available for testing"
408+
409+ # For this test, pick the first expense category
410+ category = expense_categories [0 ]
411+ category_id = category ["id" ]
412+
413+ # Retrieve the labels for the chosen expense category
414+ labels_response = await extend .expense_data .get_expense_category_labels (
415+ category_id = category_id ,
416+ page = 0 ,
417+ per_page = 10
418+ )
419+ assert "expenseLabels" in labels_response , "Response should include 'expenseLabels'"
420+ expense_labels = labels_response ["expenseLabels" ]
421+ assert expense_labels , "No expense labels available for the selected category"
422+
423+ # Pick the first label from the list
424+ label = expense_labels [0 ]
425+ label_id = label ["id" ]
426+
427+ # Retrieve at least one transaction to update expense data
428+ transactions_response = await extend .transactions .get_transactions (per_page = 1 )
429+ assert transactions_response .get ("transactions" ), "No transactions available for testing expense data update"
430+ transaction = transactions_response ["transactions" ][0 ]
431+ transaction_id = transaction ["id" ]
432+
433+ # Prepare the expense data payload with the specific category and label
434+ update_payload = {
435+ "expenseDetails" : [
436+ {
437+ "categoryId" : category_id ,
438+ "labelId" : label_id
439+ }
440+ ]
441+ }
442+
443+ # Call the update_transaction_expense_data method
444+ response = await extend .transactions .update_transaction_expense_data (transaction_id , update_payload )
445+
446+ # Verify the response contains the transaction id and expected expense details
447+ assert "id" in response , "Response should include the transaction id"
448+ if "expenseDetails" in response :
449+ # Depending on the API response, the structure might vary; adjust assertions accordingly
450+ assert response ["expenseDetails" ] == update_payload ["expenseDetails" ], (
451+ "Expense details in the response should match the update payload"
452+ )
453+
454+
455+ @pytest .mark .integration
456+ class TestReceiptAttachments :
457+ """Integration tests for receipt attachment operations"""
458+
459+ @pytest .mark .asyncio
460+ async def test_create_receipt_attachment (self , extend ):
461+ """Test creating a receipt attachment via multipart upload."""
462+ # Create a dummy PNG file in memory
463+ # This is a minimal PNG header plus extra bytes to simulate file content.
464+ png_header = b'\x89 PNG\r \n \x1a \n '
465+ dummy_content = png_header + b'\x00 ' * 100
466+ file_obj = BytesIO (dummy_content )
467+ # Optionally set a name attribute for file identification in the upload
468+ file_obj .name = f"test_receipt_{ uuid .uuid4 ()} .png"
469+
470+ # Retrieve a valid transaction id from existing transactions
471+ transactions_response = await extend .transactions .get_transactions (page = 0 , per_page = 1 )
472+ assert transactions_response .get ("transactions" ), "No transactions available for testing receipt attachment"
473+ transaction_id = transactions_response ["transactions" ][0 ]["id" ]
474+
475+ # Call the receipt attachment upload method
476+ response = await extend .receipt_attachments .create_receipt_attachment (
477+ transaction_id = transaction_id ,
478+ file = file_obj
479+ )
480+
481+ # Assert that the response contains expected keys
482+ assert "id" in response , "Receipt attachment should have an id"
483+ assert "urls" in response , "Receipt attachment should include urls"
484+ assert "contentType" in response , "Receipt attachment should include a content type"
485+ assert response ["contentType" ] == "image/png" , "Content type should be 'image/png'"
486+
487+
306488def test_environment_variables ():
307489 """Test that required environment variables are set"""
308490 assert os .getenv ("EXTEND_API_KEY" ), "EXTEND_API_KEY environment variable is required"
0 commit comments