"""Tests for transaction logic.""" from decimal import Decimal from unittest.mock import ANY, patch from oto import response, status import pytest from collaborator.constants import error from collaborator.constants import transaction as constants from collaborator.constants.header import LABEL_RESOURCE from collaborator.handlers.transaction import ParticipationDataloaderEntry from collaborator.logic import transaction from collaborator.models.rds.statement_period import StatementPeriod from collaborator.utils.error import OwsError from collaborator.utils.typing import AuthorizedResources, Resource from tests.testutils import statement_period_fixtures, transaction_fixtures @patch("collaborator.logic.transaction.ReportPersister") @patch("collaborator.logic.transaction.CollaboratorPersister") @patch("collaborator.logic.transaction.TransactionPersister") @patch("collaborator.logic.transaction.StatementPeriodPersister") def test_create_transactions( StatementPeriodPersister_mock, TransactionPersister_mock, CollaboratorPersister_mock, ReportPer_mock, mock_account, mock_user, ): """Test creating transactions for a collaborator.""" mock_collaborators_result = [ { "id": 1, "name": "test", "vendor_id": mock_account.id, "performance_rights": True, "currency": "JPY", }, { "id": 2, "name": "test", "vendor_id": mock_account.id, "performance_rights": True, "currency": "USD", }, ] mock_collabs_by_id = {c["id"]: c for c in mock_collaborators_result} data = [ { "date": "2020-05-04", "collaborator_id": 1, "type": constants.TYPE_PAYMENT, "description": "batch_name", "currency": "JPY", "original_amount": Decimal("50.001"), "transferwise_transaction_id": 11112222, }, { "date": "2020-05-04", "collaborator_id": 2, "type": constants.TYPE_REVENUE, "description": "batch_name", "currency": "USD", "original_amount": Decimal("200.555"), "collaborator_share": 0.5, "report_id": 1, }, ] CollaboratorPersister_mock.get_by_ids.return_value = mock_collaborators_result ReportPer_mock.update_reports_with_transactions.return_value = dict( updated=[{"id": 1}], reports=[{"id": 1}] ) TransactionPersister_mock.create_transactions.return_value = [ {"id": 12}, {"id": 34, "report_id": 1}, ] sp_fixture = statement_period_fixtures.OPEN_PERIOD sp_fixture["statement_period_id"] = sp_fixture.pop("id") StatementPeriodPersister_mock.get_open_statement_periods.return_value = { mock_account.id: StatementPeriod(**sp_fixture) } result = transaction.create_transactions( mock_user, data, collabs_by_id=mock_collabs_by_id ) assert result == [{"id": 12}, {"id": 34, "report_id": 1}] ReportPer_mock.update_reports_with_transactions.assert_called_once_with( [{"report_id": 1, "transaction_id": 34}], mock_user ) TransactionPersister_mock.create_transactions.assert_called_with( [ { "collaborator_id": 1, "date": ANY, "type": constants.TYPE_PAYMENT, "description": "batch_name", "currency": "JPY", "original_amount": Decimal("-50.00"), "chargeable_amount": Decimal("-50.00"), "collaborator_share": None, "transferwise_transaction_id": 11112222, "report_id": None, "statement_period_id": 2, "creation_batch_uuid": ANY, }, { "collaborator_id": 2, "date": ANY, "type": constants.TYPE_REVENUE, "description": "batch_name", "original_amount": Decimal("200.56"), "collaborator_share": 0.5, "currency": "USD", "chargeable_amount": Decimal("100.28"), "report_id": 1, "statement_period_id": 2, "creation_batch_uuid": ANY, }, ], creation_batch_uuid=ANY, ) @patch("collaborator.logic.transaction.ReportPersister") @patch("collaborator.logic.transaction.CollaboratorPersister") @patch("collaborator.logic.transaction.TransactionPersister") @patch("collaborator.logic.transaction.StatementPeriodPersister") def test_create_transactions_existing_transactions_with_report( StatementPeriodPersister_mock, TransactionPersister_mock, CollaboratorPersister_mock, ReportPer_mock, mock_account, mock_user, ): """Test create transactions with report id and transactions found.""" mock_collaborator_result = [ { "id": 1, "name": "test", "vendor_id": mock_account.id, "performance_rights": True, "currency": "JPY", }, { "id": 2, "name": "test", "vendor_id": mock_account.id, "performance_rights": True, "currency": "JPY", }, ] mock_collabs_by_id = {c["id"]: c for c in mock_collaborator_result} mock_transactions_with_reports = [{"id": 5, "report_id": 1}] CollaboratorPersister_mock.get_by_ids.return_value = mock_collaborator_result TransactionPersister_mock.get_transactions_by_report_ids.return_value = ( mock_transactions_with_reports ) data = [ { "date": "2020-05-04", "type": constants.TYPE_PAYMENT, "description": "batch_name", "original_amount": Decimal("50.0"), "currency": "JPY", "transferwise_transaction_id": 11112222, "collaborator_id": 1, }, { "date": "2020-05-04", "type": constants.TYPE_REVENUE, "description": "batch_name", "original_amount": Decimal("200.0"), "currency": "NOK", "collaborator_share": 0.5, "report_id": 1, "collaborator_id": 2, }, ] with pytest.raises(Exception) as err: transaction.create_transactions( mock_user, data, collabs_by_id=mock_collabs_by_id ) assert err.value.code == "transaction_exists_for_report" assert err.value.message == "Transaction exists for report." ReportPer_mock.update_reports_with_transactions.assert_not_called() TransactionPersister_mock.create_transactions.assert_not_called() CollaboratorPersister_mock.update_collaborator.assert_not_called() @patch("collaborator.logic.transaction.CollaboratorPersister") @patch("collaborator.logic.transaction.TransactionPersister") def test_get_transactions( TransactionPersister_mock, CollaboratorPersister_mock, mock_account ): """Test get transactions for a collaborator.""" collaborator_id = 1 transactions_count = 52 CollaboratorPersister_mock.get_by_id_and_account.return_value = response.Response( { "id": 1, "name": "Person", "vendor_id": 24601, "performance_rights": False, "subaccount_id": None, "participant_id": "12345", "recipient_id": None, "balance": 0.0, "balance_last_modified": "2019-09-08T07:06:05", "currency": "USD", } ) TransactionPersister_mock.get_transactions.return_value = ( transaction_fixtures.TRANSACTIONS, transactions_count, ) result = transaction.get_transactions(collaborator_id, mock_account) assert result.message == { "items": transaction_fixtures.TRANSACTIONS, "pagination": {"total_records": transactions_count, "type": "standard"}, } @patch("collaborator.logic.transaction.CollaboratorPersister") @patch("collaborator.logic.transaction.TransactionPersister") def test_get_transactions_collaborator_not_found_for_account( TransactionPersister_mock, CollaboratorPersister_mock, mock_account ): """Test get transactions for a collaborator that doesn't exist.""" collaborator_id = 1 TransactionPersister_mock.get_transactions.return_value = ( transaction_fixtures.TRANSACTIONS ) CollaboratorPersister_mock.get_by_id_and_account.return_value = ( response.create_error_response( code="not_found", message="not found", status=status.NOT_FOUND ) ) result = transaction.get_transactions(collaborator_id, mock_account) assert result.status == status.NOT_FOUND @patch("collaborator.logic.transaction.ReportPersister") @patch("collaborator.logic.transaction.TransactionPersister") def test_delete_transactions( TransactionPersister_mock, ReportPersister_mock, mock_account ): """Test deleting multiple transactions.""" authorized_resources = AuthorizedResources( [ Resource(type=LABEL_RESOURCE, id="101"), Resource(type=LABEL_RESOURCE, id="102"), ] ) transaction_ids = [1, 2, 3] vendor_ids = [101, 102] TransactionPersister_mock.get_vendor_ids_for_transactions.return_value = vendor_ids result = transaction.delete_transactions(transaction_ids, authorized_resources) TransactionPersister_mock.get_vendor_ids_for_transactions.assert_called_once_with( transaction_ids ) TransactionPersister_mock.delete_by_ids.assert_called_once_with(transaction_ids) ReportPersister_mock.remove_transactions_from_reports.assert_called_once_with( transaction_ids ) assert result is None @patch("collaborator.logic.transaction.TransactionPersister") def test_delete_transactions_unauthorized(TransactionPersister_mock): """Test deleting multiple transactions with unauthorized access.""" authorized_resources = AuthorizedResources( [ Resource(type=LABEL_RESOURCE, id="101"), Resource(type=LABEL_RESOURCE, id="102"), ] ) transaction_ids = [1, 2, 3] vendor_ids = [101, 102] TransactionPersister_mock.get_vendor_ids_for_transactions.return_value = vendor_ids with patch( "collaborator.logic.transaction.check_vendors_authorization" ) as mock_check_auth: mock_check_auth.side_effect = OwsError.forbidden( code=error.ERROR_CODE_AUTHORIZATION, message=error.ERROR_MESSAGE_FORBIDDEN_USER, ) with pytest.raises(OwsError) as err: transaction.delete_transactions(transaction_ids, authorized_resources) assert err.value.code == error.ERROR_CODE_AUTHORIZATION assert err.value.message == error.ERROR_MESSAGE_FORBIDDEN_USER TransactionPersister_mock.get_vendor_ids_for_transactions.assert_called_once_with( transaction_ids ) TransactionPersister_mock.delete_by_ids.assert_not_called() @patch("collaborator.logic.transaction.TransactionPersister") def test_transactions_dataloader(TransactionPersister_mock, mock_account): """Test transactions dataloader.""" authorized_resources = AuthorizedResources( [ Resource(type=LABEL_RESOURCE, id="101"), Resource(type=LABEL_RESOURCE, id="102"), ] ) transaction_ids = [1, 2, 3] vendor_ids = [101, 102] TransactionPersister_mock.get_vendor_ids_for_transactions.return_value = vendor_ids TransactionPersister_mock.get_transactions.return_value = ( transaction_fixtures.TRANSACTIONS, len(transaction_fixtures.TRANSACTIONS), ) result = transaction.transactions_dataloader(transaction_ids, authorized_resources) TransactionPersister_mock.get_vendor_ids_for_transactions.assert_called_once_with( transaction_ids ) TransactionPersister_mock.get_transactions.assert_called_once_with( transaction_ids=transaction_ids ) transactions_by_transaction_id = { txn["id"]: txn for txn in transaction_fixtures.TRANSACTIONS } expected_message = [ {"data": transactions_by_transaction_id.get(transaction_id)} for transaction_id in transaction_ids ] assert result == expected_message @patch("collaborator.logic.transaction.TransactionPersister") def test_transactions_dataloader_unauthorized(TransactionPersister_mock): """Test transactions dataloader returns data even when vendor auth fails.""" authorized_resources = AuthorizedResources([]) transaction_ids = [1, 2, 3] vendor_ids = [101, 102] TransactionPersister_mock.get_vendor_ids_for_transactions.return_value = vendor_ids TransactionPersister_mock.get_transactions.return_value = ( transaction_fixtures.TRANSACTIONS, len(transaction_fixtures.TRANSACTIONS), ) result = transaction.transactions_dataloader(transaction_ids, authorized_resources) TransactionPersister_mock.get_vendor_ids_for_transactions.assert_called_once_with( transaction_ids ) TransactionPersister_mock.get_transactions.assert_called_once_with( transaction_ids=transaction_ids ) transactions_by_transaction_id = { txn["id"]: txn for txn in transaction_fixtures.TRANSACTIONS } expected_message = [ {"data": transactions_by_transaction_id.get(transaction_id)} for transaction_id in transaction_ids ] assert result == expected_message @patch("collaborator.logic.transaction.TransactionPersister") @patch("collaborator.logic.transaction.check_collaborators_authorization") def test_transactions_participation_dataloader( mock_check_auth, TransactionPersister_mock ): """Test transactions participation dataloader returns ordered results.""" authorized_resources = AuthorizedResources( [ Resource(type=LABEL_RESOURCE, id="101"), ] ) participations = [ ParticipationDataloaderEntry(collaborator_id=1, statement_period_id=1), ParticipationDataloaderEntry(collaborator_id=2, statement_period_id=3), ] mock_check_auth.return_value = {1: {}, 2: {}} expected_txns = transaction_fixtures.TRANSACTIONS[:2] TransactionPersister_mock.get_transactions_for_participations.return_value = { (1, 1): {"transactions": expected_txns, "total_count": len(expected_txns)}, } result = transaction.transactions_participation_dataloader( participations, authorized_resources ) mock_check_auth.assert_called_once_with( authorized_resources, [1, 2], throw_if_unauthorized=False ) TransactionPersister_mock.get_transactions_for_participations.assert_called_once_with( [(1, 1), (2, 3)] ) assert result == [ {"data": {"transactions": expected_txns, "total_count": len(expected_txns)}}, {"data": {"transactions": [], "total_count": 0}}, ] @patch("collaborator.logic.transaction.TransactionPersister") @patch("collaborator.logic.transaction.check_collaborators_authorization") def test_transactions_participation_dataloader_unauthorized( mock_check_auth, TransactionPersister_mock ): """Test transactions participation dataloader returns empty data when unauthorized.""" authorized_resources = AuthorizedResources([]) participations = [ ParticipationDataloaderEntry(collaborator_id=99, statement_period_id=1), ] mock_check_auth.return_value = {} TransactionPersister_mock.get_transactions_for_participations.return_value = {} result = transaction.transactions_participation_dataloader( participations, authorized_resources ) TransactionPersister_mock.get_transactions_for_participations.assert_called_once_with( [] ) assert result == [{"data": {"transactions": [], "total_count": 0}}] @patch("collaborator.logic.transaction.TransactionPersister") @patch("collaborator.logic.transaction.check_collaborators_authorization") def test_transactions_participation_dataloader_with_pagination( mock_check_auth, TransactionPersister_mock ): """Test that limit/offset slices transactions while preserving total_count.""" authorized_resources = AuthorizedResources( [Resource(type=LABEL_RESOURCE, id="101")] ) all_txns = transaction_fixtures.TRANSACTIONS[:3] participations = [ ParticipationDataloaderEntry( collaborator_id=1, statement_period_id=1, limit=1, offset=1 ), ] mock_check_auth.return_value = {1: {}} TransactionPersister_mock.get_transactions_for_participations.return_value = { (1, 1): {"transactions": all_txns, "total_count": 3}, } result = transaction.transactions_participation_dataloader( participations, authorized_resources ) # Only the second transaction is returned (offset=1, limit=1), total_count unchanged assert result == [ {"data": {"transactions": [all_txns[1]], "total_count": 3}}, ] @patch("collaborator.logic.transaction.TransactionPersister") @patch("collaborator.logic.transaction.check_collaborators_authorization") def test_transactions_participation_dataloader_mixed_pagination( mock_check_auth, TransactionPersister_mock ): """Test mixed request: one participation paginated, one not.""" authorized_resources = AuthorizedResources( [Resource(type=LABEL_RESOURCE, id="101")] ) all_txns = transaction_fixtures.TRANSACTIONS[:3] participations = [ ParticipationDataloaderEntry( collaborator_id=1, statement_period_id=1, limit=1, offset=0 ), ParticipationDataloaderEntry(collaborator_id=2, statement_period_id=3), ] mock_check_auth.return_value = {1: {}, 2: {}} TransactionPersister_mock.get_transactions_for_participations.return_value = { (1, 1): {"transactions": all_txns, "total_count": 3}, (2, 3): {"transactions": [all_txns[0]], "total_count": 1}, } result = transaction.transactions_participation_dataloader( participations, authorized_resources ) assert result == [ # Paginated: first transaction only, total_count unchanged {"data": {"transactions": [all_txns[0]], "total_count": 3}}, # Unpaginated: all transactions returned {"data": {"transactions": [all_txns[0]], "total_count": 1}}, ] @patch("collaborator.logic.transaction.TransactionPersister") @patch("collaborator.logic.transaction.check_collaborators_authorization") def test_transactions_participation_dataloader_offset_without_limit( mock_check_auth, TransactionPersister_mock ): """Test that offset is applied even when limit is not provided.""" authorized_resources = AuthorizedResources( [Resource(type=LABEL_RESOURCE, id="101")] ) all_txns = transaction_fixtures.TRANSACTIONS[:3] participations = [ ParticipationDataloaderEntry( collaborator_id=1, statement_period_id=1, offset=1 ), ] mock_check_auth.return_value = {1: {}} TransactionPersister_mock.get_transactions_for_participations.return_value = { (1, 1): {"transactions": all_txns, "total_count": 3}, } result = transaction.transactions_participation_dataloader( participations, authorized_resources ) # offset=1 skips the first transaction; no limit means the rest are returned assert result == [ {"data": {"transactions": all_txns[1:], "total_count": 3}}, ] @patch("collaborator.logic.transaction.TransactionPersister") @patch("collaborator.logic.transaction.check_collaborators_authorization") def test_transactions_participation_dataloader_limit_zero_means_no_limit( mock_check_auth, TransactionPersister_mock ): """Test that limit=0 means no limit, following the codebase convention.""" authorized_resources = AuthorizedResources( [Resource(type=LABEL_RESOURCE, id="101")] ) all_txns = transaction_fixtures.TRANSACTIONS[:3] participations = [ ParticipationDataloaderEntry( collaborator_id=1, statement_period_id=1, limit=0, offset=1 ), ] mock_check_auth.return_value = {1: {}} TransactionPersister_mock.get_transactions_for_participations.return_value = { (1, 1): {"transactions": all_txns, "total_count": 3}, } result = transaction.transactions_participation_dataloader( participations, authorized_resources ) # limit=0 means no limit; offset=1 still applies assert result == [ {"data": {"transactions": all_txns[1:], "total_count": 3}}, ]