"""Functional tests for replacing splits.""" import json from unittest.mock import ANY from oto import status import pytest from collaborator.constants.split import RateType from collaborator.models.ows import ows_account, ows_product, ows_track from collaborator.models.snowflake import split_persister as snowflake_split_persister from tests.testutils import db, mock_auth, snowflake_db def _mock_vendor_lookups(mocker, vendor_id: int): """Make ows_track + ows_product return a single ``vendor_id`` for any tuid.""" mocker.patch.object( ows_track, "get_tracks_batched", side_effect=lambda tuids: [ {"identifier": tuid, "split_type_id": 2, "upc": f"UPC-{tuid}"} for tuid in tuids ], ) mocker.patch.object( ows_product, "get_products_by_upc", side_effect=lambda upcs: { "items": [{"upc": upc, "vendor_id": vendor_id} for upc in upcs] }, ) previous_splits_mock = [ { "id": 1000, "identifier": "1", "split_rate": 0.25, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, }, { "id": 4, "identifier": "12341234", "split_rate": 0.1, "split_type_id": 2, "collaborator_id": 2, "rate_type": RateType.NET, }, { "id": 5, "identifier": "12341234", "split_rate": 0.1, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, }, ] @snowflake_db.test_schema_default_seed @db.test_schema_default_seed @pytest.mark.parametrize( ( "split_data", "expected_results", "splits_to_insert", "splits_to_update", "has_dp", ), [ ( # Not modifying any splits - With DP { "dp_splits_agreed": True, "replacements": [ { "identifier": "12341234", "split_type_id": 2, "splits": [ { "collaborator_id": 2, "split_rate": 0.1, "split_type_id": 2, "rate_type": RateType.NET, }, { "collaborator_id": 1, "split_rate": 0.1, "split_type_id": 2, "rate_type": RateType.NET, }, ], }, { "identifier": "1", "split_type_id": 2, "splits": [ { "id": 1000, "collaborator_id": 1, "split_rate": 0.25, "split_type_id": 2, "rate_type": RateType.NET, } ], }, ], }, [ { "id": ANY, "identifier": "1", "split_rate": 0.25, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": "2024-01-01T11:22:33", "updated_date": "2024-01-02T11:22:33", "source": None, }, { "id": ANY, "identifier": "12341234", "split_rate": 0.1, "split_type_id": 2, "collaborator_id": 2, "rate_type": RateType.NET, "created_date": "2024-01-01T11:22:33", "updated_date": "2024-01-02T11:22:33", "source": None, }, { "id": ANY, "identifier": "12341234", "split_rate": 0.1, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": "2024-01-01T11:22:33", "updated_date": "2024-01-02T11:22:33", "source": None, }, ], [], [ { "id": ANY, "identifier": "1", "split_rate": 0.25, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": ANY, "updated_date": ANY, "source": None, }, { "id": ANY, "identifier": "12341234", "split_rate": 0.1, "split_type_id": 2, "collaborator_id": 2, "rate_type": RateType.NET, "created_date": ANY, "updated_date": ANY, "source": None, }, { "id": ANY, "identifier": "12341234", "split_rate": 0.1, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": ANY, "updated_date": ANY, "source": None, }, ], True, ), ( # Not modifying any splits - Without DP { "replacements": [ { "identifier": "12341234", "split_type_id": 2, "splits": [ { "collaborator_id": 2, "split_rate": 0.1, "split_type_id": 2, "rate_type": RateType.NET, }, { "collaborator_id": 1, "split_rate": 0.1, "split_type_id": 2, "rate_type": RateType.NET, }, ], }, { "identifier": "1", "split_type_id": 2, "splits": [ { "collaborator_id": 1, "split_rate": 0.25, "split_type_id": 2, "rate_type": RateType.NET, } ], }, ], }, [ { "id": ANY, "identifier": "1", "split_rate": 0.25, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": "2024-01-01T11:22:33", "updated_date": "2024-01-02T11:22:33", "source": None, }, { "id": ANY, "identifier": "12341234", "split_rate": 0.1, "split_type_id": 2, "collaborator_id": 2, "rate_type": RateType.NET, "created_date": "2024-01-01T11:22:33", "updated_date": "2024-01-02T11:22:33", "source": None, }, { "id": ANY, "identifier": "12341234", "split_rate": 0.1, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": "2024-01-01T11:22:33", "updated_date": "2024-01-02T11:22:33", "source": None, }, ], [], [ { "id": ANY, "identifier": "1", "split_rate": 0.25, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": ANY, "updated_date": ANY, "source": None, }, { "id": ANY, "identifier": "12341234", "split_rate": 0.1, "split_type_id": 2, "collaborator_id": 2, "rate_type": RateType.NET, "created_date": ANY, "updated_date": ANY, "source": None, }, { "id": ANY, "identifier": "12341234", "split_rate": 0.1, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": ANY, "updated_date": ANY, "source": None, }, ], False, ), ( # Adding a split { "dp_splits_agreed": True, "replacements": [ { "identifier": "12341234", "split_type_id": 2, "splits": [ { "collaborator_id": 2, "split_rate": 0.1, "split_type_id": 2, "rate_type": RateType.NET, }, { "collaborator_id": 1, "split_rate": 0.1, "split_type_id": 2, "rate_type": RateType.NET, }, ], }, { "identifier": "1", "split_type_id": 2, "splits": [ { "collaborator_id": 1, "split_rate": 0.25, "split_type_id": 2, "rate_type": RateType.NET, }, { "collaborator_id": 2, "split_rate": 0.10, "split_type_id": 2, "rate_type": RateType.NET, }, ], }, ], }, [ { "id": ANY, "identifier": "1", "split_rate": 0.25, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": "2024-01-01T11:22:33", "updated_date": "2024-01-02T11:22:33", "source": None, }, { "id": ANY, "identifier": "1", "split_rate": 0.10, "split_type_id": 2, "collaborator_id": 2, "rate_type": RateType.NET, "created_date": "2024-01-01T11:22:33", "updated_date": "2024-01-02T11:22:33", "source": None, }, { "id": ANY, "identifier": "12341234", "split_rate": 0.1, "split_type_id": 2, "collaborator_id": 2, "rate_type": RateType.NET, "created_date": "2024-01-01T11:22:33", "updated_date": "2024-01-02T11:22:33", "source": None, }, { "id": ANY, "identifier": "12341234", "split_rate": 0.1, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": "2024-01-01T11:22:33", "updated_date": "2024-01-02T11:22:33", "source": None, }, ], [ { "id": ANY, "identifier": "1", "split_rate": 0.10, "split_type_id": 2, "collaborator_id": 2, "rate_type": RateType.NET, "created_date": ANY, "updated_date": ANY, "source": None, }, ], [ { "id": ANY, "identifier": "1", "split_rate": 0.25, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": ANY, "updated_date": ANY, "source": None, }, { "id": ANY, "identifier": "12341234", "split_rate": 0.1, "split_type_id": 2, "collaborator_id": 2, "rate_type": RateType.NET, "created_date": ANY, "updated_date": ANY, "source": None, }, { "id": ANY, "identifier": "12341234", "split_rate": 0.1, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": ANY, "updated_date": ANY, "source": None, }, ], True, ), ( # Updating a split { "dp_splits_agreed": True, "replacements": [ { "identifier": "12341234", "split_type_id": 2, "splits": [ { "collaborator_id": 2, "split_rate": 0.2, "split_type_id": 2, "rate_type": RateType.NET, }, { "collaborator_id": 1, "split_rate": 0.1, "split_type_id": 2, "rate_type": RateType.NET, }, ], }, { "identifier": "1", "split_type_id": 2, "splits": [ { "collaborator_id": 1, "split_rate": 0.25, "split_type_id": 2, "rate_type": RateType.NET, } ], }, ], }, [ { "id": ANY, "identifier": "1", "split_rate": 0.25, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": "2024-01-01T11:22:33", "updated_date": "2024-01-02T11:22:33", "source": None, }, { "id": ANY, "identifier": "12341234", "split_rate": 0.2, "split_type_id": 2, "collaborator_id": 2, "rate_type": RateType.NET, "created_date": "2024-01-01T11:22:33", "updated_date": "2024-01-02T11:22:33", "source": None, }, { "id": ANY, "identifier": "12341234", "split_rate": 0.1, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": "2024-01-01T11:22:33", "updated_date": "2024-01-02T11:22:33", "source": None, }, ], [], [ { "id": ANY, "identifier": "1", "split_rate": 0.25, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": ANY, "updated_date": ANY, "source": None, }, { "id": ANY, "identifier": "12341234", "split_rate": 0.2, "split_type_id": 2, "collaborator_id": 2, "rate_type": RateType.NET, "created_date": ANY, "updated_date": ANY, "source": None, }, { "id": ANY, "identifier": "12341234", "split_rate": 0.1, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": ANY, "updated_date": ANY, "source": None, }, ], True, ), ( # Removing all splits { "dp_splits_agreed": True, "replacements": [ { "identifier": "12341234", "split_type_id": 2, "splits": [], }, { "identifier": "1", "split_type_id": 2, "splits": [], }, ], }, [], [], [], True, ), ( # Updating, creating and deleting splits { "dp_splits_agreed": True, "replacements": [ { "identifier": "12341234", "split_type_id": 2, "splits": [ { "collaborator_id": 2, "split_rate": 0.2, "split_type_id": 2, "rate_type": RateType.NET, }, ], }, { "identifier": "1", "split_type_id": 2, "splits": [ { "collaborator_id": 1, "split_rate": 0.25, "split_type_id": 2, "rate_type": RateType.NET, }, { "collaborator_id": 2, "split_rate": 0.10, "split_type_id": 2, "rate_type": RateType.NET, }, ], }, ], }, [ { "id": ANY, "identifier": "1", "split_rate": 0.25, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": "2024-01-01T11:22:33", "updated_date": "2024-01-02T11:22:33", "source": None, }, { "id": ANY, "identifier": "1", "split_rate": 0.10, "split_type_id": 2, "collaborator_id": 2, "rate_type": RateType.NET, "created_date": "2024-01-01T11:22:33", "updated_date": "2024-01-02T11:22:33", "source": None, }, { "id": ANY, "identifier": "12341234", "split_rate": 0.2, "split_type_id": 2, "collaborator_id": 2, "rate_type": RateType.NET, "created_date": "2024-01-01T11:22:33", "updated_date": "2024-01-02T11:22:33", "source": None, }, ], [ { "collaborator_id": 2, "created_date": ANY, "id": ANY, "identifier": "1", "rate_type": RateType.NET, "source": None, "split_rate": 0.1, "split_type_id": 2, "updated_date": None, }, ], [ { "collaborator_id": 1, "created_date": ANY, "id": ANY, "identifier": "1", "rate_type": RateType.NET, "source": None, "split_rate": 0.25, "split_type_id": 2, "updated_date": None, }, { "collaborator_id": 2, "created_date": ANY, "id": ANY, "identifier": "12341234", "rate_type": RateType.NET, "source": None, "split_rate": 0.2, "split_type_id": 2, "updated_date": None, }, ], True, ), ], ) def test_replace_splits_success( auth_client, mocker, split_data, expected_results, splits_to_insert, splits_to_update, has_dp, ): """Test successfully replacing splits with a CollaboratorProfile.""" mock_auth(mocker, 24601) _mock_vendor_lookups(mocker, 24601) split_data = {"vendor_id": 24601, **split_data} has_dp_mock = mocker.patch.object( ows_account, "has_direct_payments", return_value=has_dp ) # Mock the uwsgi_spool_task function to execute the task synchronously def mock_uwsgi_spool_task(func, *args, **kwargs): return func(*args, **kwargs) mocker.patch( "collaborator.logic.split.uwsgi_spool_task", side_effect=mock_uwsgi_spool_task ) mocker.patch.object( snowflake_split_persister, "_get_splits", return_value=previous_splits_mock ) sf_insert_splits_mock = mocker.patch.object( snowflake_split_persister, "_insert_splits" ) sf_update_splits_mock = mocker.patch.object( snowflake_split_persister, "_update_splits" ) sf_delete_splits_mock = mocker.patch.object( snowflake_split_persister, "_delete_splits" ) replace_response = auth_client.put( "/splits", data=json.dumps(split_data), content_type="application/json" ) assert replace_response.status_code == status.OK result = [ {**item, "created_date": ANY, "updated_date": ANY} for item in replace_response.get_json() ] # Order from the persister's get_for_identifiers is DB-defined; sort # everything by (identifier, collaborator_id) before comparing. sort_key = lambda s: (s["identifier"], s["collaborator_id"]) # noqa: E731 assert has_dp_mock.call_count == 1 assert sf_insert_splits_mock.call_count == 1 insert_splits_args = sf_insert_splits_mock.call_args[0] assert sorted(insert_splits_args[0], key=sort_key) == sorted( splits_to_insert, key=sort_key ) assert sf_update_splits_mock.call_count == 1 update_splits_args = sf_update_splits_mock.call_args[0] assert sorted(update_splits_args[0], key=sort_key) == sorted( splits_to_update, key=sort_key ) # Splits are upserted now, so only the pre-existing splits (within the # replaced identifier/type scope) whose collaborator is no longer present # are deleted. The seed splits in scope are 4=(12341234, collab 2), # 5=(12341234, collab 1) and 1000=("1", collab 1). seed_splits_in_scope = { 4: ("12341234", 2), 5: ("12341234", 1), 1000: ("1", 1), } final_keys = {(r["identifier"], r["collaborator_id"]) for r in expected_results} expected_deleted = sorted( split_id for split_id, key in seed_splits_in_scope.items() if key not in final_keys ) assert sf_delete_splits_mock.call_count == 1 delete_splits_args = sf_delete_splits_mock.call_args[0] assert sorted(delete_splits_args[0]) == expected_deleted assert sorted(result, key=sort_key) == sorted(expected_results, key=sort_key) @db.test_schema_default_seed @pytest.mark.parametrize( "user_vendor_id, body_vendor_id, split_data, expected_status", [ # Empty replacements — no vendor can be derived, so it is rejected. ( 24601, 24601, {"dp_splits_agreed": True, "replacements": []}, status.BAD_REQUEST, ), # DP collaborator without dp_splits_agreed ( 24601, 24601, { "replacements": [ { "identifier": "12341234", "split_type_id": 2, "splits": [ { "collaborator_id": 2, "split_rate": 0.2, "split_type_id": 2, "rate_type": RateType.NET, }, ], }, ], }, status.BAD_REQUEST, ), # Cross-vendor body: track resolves to vendor 90210 but collab 1 is on # vendor 24601 — must reject as split_vendor_mismatch. ( 24601, 90210, { "replacements": [ { "identifier": "12341234", "split_type_id": 2, "splits": [ { "collaborator_id": 1, "split_rate": 0.5, "split_type_id": 2, "rate_type": RateType.NET, }, ], }, ], }, status.FORBIDDEN, ), # Caller is on vendor 25153 but the body resolves to vendor 24601 — # must reject as forbidden (caller not authorised for the body's vendor). ( 25153, 24601, { "replacements": [ { "identifier": "12341234", "split_type_id": 2, "splits": [ { "collaborator_id": 1, "split_rate": 0.5, "split_type_id": 2, "rate_type": RateType.NET, }, ], }, ], }, status.FORBIDDEN, ), ], ) def test_replace_splits_fail( auth_client, mocker, user_vendor_id, body_vendor_id, split_data, expected_status ): """Test failing to replace splits with a CollaboratorProfile.""" mock_auth(mocker, user_vendor_id) _mock_vendor_lookups(mocker, body_vendor_id) split_data = {"vendor_id": body_vendor_id, **split_data} mocker.patch.object(ows_account, "has_direct_payments", return_value=True) result = auth_client.put( "/splits", data=json.dumps(split_data), content_type="application/json" ) assert result.status_code == expected_status def _mock_snowflake_persister(mocker, existing_splits=None): """Mock the Snowflake persister leaf functions, returning the call mocks.""" mocker.patch.object( snowflake_split_persister, "_get_splits", return_value=existing_splits or [] ) return ( mocker.patch.object(snowflake_split_persister, "_insert_splits"), mocker.patch.object(snowflake_split_persister, "_update_splits"), mocker.patch.object(snowflake_split_persister, "_delete_splits"), ) def _run_spool_task_synchronously(mocker): """Make uwsgi_spool_task execute its task immediately.""" mocker.patch( "collaborator.logic.split.uwsgi_spool_task", side_effect=lambda func, *args, **kwargs: func(*args, **kwargs), ) @snowflake_db.test_schema_default_seed @db.test_schema_default_seed def test_replace_subaccount_splits_success(auth_client, mocker): """Replacing SUBACCOUNT splits creates them and spools to Snowflake.""" mock_auth(mocker, 24601) _mock_vendor_lookups(mocker, 24601) mocker.patch.object( ows_account, "get_subaccount", return_value={"vendor_id": 24601} ) mocker.patch.object(ows_account, "has_direct_payments", return_value=False) _run_spool_task_synchronously(mocker) sf_insert_mock, sf_update_mock, sf_delete_mock = _mock_snowflake_persister(mocker) split_data = { "vendor_id": 24601, "replacements": [ { "identifier": "555", "split_type_id": 3, "splits": [ { "collaborator_id": 1, "split_rate": 0.5, "rate_type": RateType.NET, }, { "collaborator_id": 2, "split_rate": 0.5, "rate_type": RateType.NET, }, ], }, ], } response = auth_client.put( "/splits", data=json.dumps(split_data), content_type="application/json" ) assert response.status_code == status.OK result = [ {**item, "created_date": ANY, "updated_date": ANY} for item in response.get_json() ] sort_key = lambda s: (s["identifier"], s["collaborator_id"]) # noqa: E731 expected = [ { "id": ANY, "identifier": "555", "split_rate": 0.5, "split_type_id": 3, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": ANY, "updated_date": ANY, "source": None, }, { "id": ANY, "identifier": "555", "split_rate": 0.5, "split_type_id": 3, "collaborator_id": 2, "rate_type": RateType.NET, "created_date": ANY, "updated_date": ANY, "source": None, }, ] assert sorted(result, key=sort_key) == sorted(expected, key=sort_key) # No subaccount splits previously existed, so everything is an insert. assert sf_insert_mock.call_count == 1 assert len(sf_insert_mock.call_args[0][0]) == 2 assert sf_update_mock.call_count == 1 assert sf_update_mock.call_args[0][0] == [] assert sf_delete_mock.call_count == 1 assert sf_delete_mock.call_args[0][0] == [] @snowflake_db.test_schema_default_seed @db.test_schema_default_seed def test_replace_mixed_track_and_subaccount_splits_success(auth_client, mocker): """A single request can replace both TRACK and SUBACCOUNT splits.""" mock_auth(mocker, 24601) _mock_vendor_lookups(mocker, 24601) mocker.patch.object( ows_account, "get_subaccount", return_value={"vendor_id": 24601} ) mocker.patch.object(ows_account, "has_direct_payments", return_value=False) _run_spool_task_synchronously(mocker) sf_insert_mock, _, sf_delete_mock = _mock_snowflake_persister(mocker) split_data = { "vendor_id": 24601, "replacements": [ { "identifier": "1", "split_type_id": 2, "splits": [ { "collaborator_id": 1, "split_rate": 0.4, "rate_type": RateType.NET, } ], }, { "identifier": "555", "split_type_id": 3, "splits": [ { "collaborator_id": 1, "split_rate": 1.0, "rate_type": RateType.NET, } ], }, ], } response = auth_client.put( "/splits", data=json.dumps(split_data), content_type="application/json" ) assert response.status_code == status.OK result = [ {**item, "created_date": ANY, "updated_date": ANY} for item in response.get_json() ] sort_key = lambda s: (s["identifier"], s["collaborator_id"]) # noqa: E731 expected = [ { "id": ANY, "identifier": "1", "split_rate": 0.4, "split_type_id": 2, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": ANY, "updated_date": ANY, "source": None, }, { "id": ANY, "identifier": "555", "split_rate": 1.0, "split_type_id": 3, "collaborator_id": 1, "rate_type": RateType.NET, "created_date": ANY, "updated_date": ANY, "source": None, }, ] assert sorted(result, key=sort_key) == sorted(expected, key=sort_key) # The pre-existing track split (id 1000) for identifier "1" is collaborator 1's, # which is still present in the replacement, so it is upserted, not deleted. assert sf_delete_mock.call_count == 1 assert sf_delete_mock.call_args[0][0] == [] assert sf_insert_mock.call_count == 1 @db.test_schema_default_seed def test_replace_subaccount_splits_over_100_percent_fails(auth_client, mocker): """A subaccount split over 100% is rejected for a DP vendor.""" mock_auth(mocker, 24601) mocker.patch.object( ows_account, "get_subaccount", return_value={"vendor_id": 24601} ) mocker.patch.object(ows_account, "has_direct_payments", return_value=True) split_data = { "vendor_id": 24601, "dp_splits_agreed": True, "replacements": [ { "identifier": "555", "split_type_id": 3, "splits": [ { "collaborator_id": 1, "split_rate": 1.5, "rate_type": RateType.NET, } ], }, ], } response = auth_client.put( "/splits", data=json.dumps(split_data), content_type="application/json" ) assert response.status_code == status.BAD_REQUEST @db.test_schema_default_seed def test_replace_subaccount_splits_vendor_mismatch_fails(auth_client, mocker): """Subaccounts resolving to different vendors are rejected.""" mock_auth(mocker, 24601) mocker.patch.object( ows_account, "get_subaccount", side_effect=[{"vendor_id": 24601}, {"vendor_id": 90210}], ) mocker.patch.object(ows_account, "has_direct_payments", return_value=False) split_data = { "vendor_id": 24601, "replacements": [ { "identifier": "555", "split_type_id": 3, "splits": [ { "collaborator_id": 1, "split_rate": 0.5, "rate_type": RateType.NET, } ], }, { "identifier": "666", "split_type_id": 3, "splits": [ { "collaborator_id": 3, "split_rate": 0.5, "rate_type": RateType.NET, } ], }, ], } response = auth_client.put( "/splits", data=json.dumps(split_data), content_type="application/json" ) assert response.status_code == status.BAD_REQUEST