"""Unit tests for the shared account-scoped batch /dataloader handler.""" from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest from abacus_contract.utils.dataloader import account_scoped_dataloader from core.config import Config def _call( test_app_in_context, resolve_accounts, fetch_records, key_field='id', as_list=False, profile_type='ContentProfile', ): with test_app_in_context.test_request_context(json=[1, 2]): from flask import g g.request_context = SimpleNamespace( profile_type=profile_type, profile_id='1234' ) return account_scoped_dataloader( entity_name='TestEntity', resolve_accounts=resolve_accounts, fetch_records=fetch_records, key_field=key_field, as_list=as_list, ) @patch('abacus_contract.utils.dataloader.get_optional_numeric_list_from_params') def test_invalid_body_returns_400_and_skips_fetch(mock_get_ids, test_app_in_context): """A body that fails id parsing short-circuits to a 400 without touching fetch.""" mock_get_ids.side_effect = ValueError() resolve_accounts = MagicMock() fetch_records = MagicMock() res = _call(test_app_in_context, resolve_accounts, fetch_records) assert res.status_code == 400 resolve_accounts.assert_not_called() fetch_records.assert_not_called() @patch('abacus_contract.utils.dataloader.get_optional_numeric_list_from_params') def test_over_cap_returns_400_and_skips_resolve_and_fetch( mock_get_ids, test_app_in_context ): """A batch over OWS_BATCH_LIMIT is rejected before resolve or fetch run.""" mock_get_ids.return_value = list(range(Config.OWS_BATCH_LIMIT + 1)) resolve_accounts = MagicMock() fetch_records = MagicMock() res = _call(test_app_in_context, resolve_accounts, fetch_records) assert res.status_code == 400 resolve_accounts.assert_not_called() fetch_records.assert_not_called() @patch('abacus_contract.utils.dataloader.permissions_authorize_many_accounts') @patch('abacus_contract.utils.dataloader.authorization') @patch('abacus_contract.utils.dataloader.flask_request') @patch('abacus_contract.utils.dataloader.get_optional_numeric_list_from_params') def test_no_accounts_resolved_skips_authz_and_fetches_empty_authorized_ids( mock_get_ids, mock_flask_request, mock_authorization, mock_permissions_authorize_many_accounts, test_app_in_context, ): """No resolvable accounts skips both authz checks; fetch gets an empty authorized set.""" mock_get_ids.return_value = [1, 2] resolve_accounts = MagicMock(return_value={}) fetch_records = MagicMock(return_value=[]) res = _call(test_app_in_context, resolve_accounts, fetch_records) assert res.status_code == 200 mock_flask_request.verify_rules_access_standalone.assert_not_called() mock_authorization.pdp_authorize_many_accounts.assert_not_called() mock_permissions_authorize_many_accounts.assert_not_called() fetch_records.assert_called_once_with([]) @patch('abacus_contract.utils.dataloader.permissions_authorize_many_accounts') @patch('abacus_contract.utils.dataloader.authorization') @patch('abacus_contract.utils.dataloader.flask_request') @patch('abacus_contract.utils.dataloader.get_optional_numeric_list_from_params') def test_standalone_access_granted_skips_pdp_but_still_checks_permissions( mock_get_ids, mock_flask_request, mock_authorization, mock_permissions_authorize_many_accounts, test_app_in_context, ): """Standalone access grants means the PDP account check is skipped.""" mock_get_ids.return_value = [1, 2] mock_flask_request.verify_rules_access_standalone.return_value = True mock_permissions_authorize_many_accounts.return_value = True resolve_accounts = MagicMock(return_value={1: 9, 2: 10}) fetch_records = MagicMock(return_value=[]) res = _call(test_app_in_context, resolve_accounts, fetch_records) assert res.status_code == 200 mock_authorization.pdp_authorize_many_accounts.assert_not_called() mock_permissions_authorize_many_accounts.assert_called_once() fetch_records.assert_called_once_with([1, 2]) @patch('abacus_contract.utils.dataloader.permissions_authorize_many_accounts') @patch('abacus_contract.utils.dataloader.authorization') @patch('abacus_contract.utils.dataloader.flask_request') @patch('abacus_contract.utils.dataloader.get_optional_numeric_list_from_params') def test_pdp_denied_returns_403( mock_get_ids, mock_flask_request, mock_authorization, mock_permissions_authorize_many_accounts, test_app_in_context, ): """Standalone access denied and PDP denied returns 403 without reaching fetch.""" mock_get_ids.return_value = [1, 2] mock_flask_request.verify_rules_access_standalone.return_value = False mock_authorization.pdp_authorize_many_accounts.return_value = False resolve_accounts = MagicMock(return_value={1: 9, 2: 10}) fetch_records = MagicMock() res = _call(test_app_in_context, resolve_accounts, fetch_records) assert res.status_code == 403 mock_permissions_authorize_many_accounts.assert_not_called() fetch_records.assert_not_called() @patch('abacus_contract.utils.dataloader.permissions_authorize_many_accounts') @patch('abacus_contract.utils.dataloader.authorization') @patch('abacus_contract.utils.dataloader.flask_request') @patch('abacus_contract.utils.dataloader.get_optional_numeric_list_from_params') def test_permissions_denied_returns_403( mock_get_ids, mock_flask_request, mock_authorization, mock_permissions_authorize_many_accounts, test_app_in_context, ): """Standalone/PDP access granted but the permissions check denies returns 403.""" mock_get_ids.return_value = [1, 2] mock_flask_request.verify_rules_access_standalone.return_value = True mock_permissions_authorize_many_accounts.return_value = False resolve_accounts = MagicMock(return_value={1: 9, 2: 10}) fetch_records = MagicMock() res = _call(test_app_in_context, resolve_accounts, fetch_records) assert res.status_code == 403 mock_authorization.pdp_authorize_many_accounts.assert_not_called() fetch_records.assert_not_called() @patch('abacus_contract.utils.dataloader.permissions_authorize_many_accounts') @patch('abacus_contract.utils.dataloader.authorization') @patch('abacus_contract.utils.dataloader.flask_request') @patch('abacus_contract.utils.dataloader.get_optional_numeric_list_from_params') def test_happy_path_fetches_authorized_ids_and_flaskifies_response( mock_get_ids, mock_flask_request, mock_authorization, mock_permissions_authorize_many_accounts, test_app_in_context, ): """A fully authorized batch calls fetch_records with only the authorized ids.""" mock_get_ids.return_value = [1, 2] mock_flask_request.verify_rules_access_standalone.return_value = False mock_authorization.pdp_authorize_many_accounts.return_value = True mock_permissions_authorize_many_accounts.return_value = True resolve_accounts = MagicMock(return_value={1: 9, 2: 10}) fetch_records = MagicMock(return_value=[]) res = _call(test_app_in_context, resolve_accounts, fetch_records) assert res.status_code == 200 fetch_records.assert_called_once_with([1, 2]) @pytest.mark.parametrize( 'accounts_by_id', [ pytest.param({1: 9}, id='unresolved id omitted from resolve_accounts'), pytest.param({1: 9, 2: None}, id='unresolved id mapped to None'), ], ) @patch('abacus_contract.utils.dataloader.permissions_authorize_many_accounts') @patch('abacus_contract.utils.dataloader.authorization') @patch('abacus_contract.utils.dataloader.flask_request') @patch('abacus_contract.utils.dataloader.get_optional_numeric_list_from_params') def test_partial_resolution_drops_unresolved_id_from_authz_and_fetch( mock_get_ids, mock_flask_request, mock_authorization, mock_permissions_authorize_many_accounts, accounts_by_id, test_app_in_context, ): """An id with no resolvable account never reaches authz or fetch_records. Whether the resolver omits the id entirely or maps it explicitly to None, it must be dropped before authorization runs and before fetch_records is called, and the response entry for that id must be data: None. This closes the gap where a copy in fetch could still see the full requested id list and leak a record for an id that never resolved to an account. """ mock_get_ids.return_value = [1, 2] mock_flask_request.verify_rules_access_standalone.return_value = True mock_permissions_authorize_many_accounts.return_value = True resolve_accounts = MagicMock(return_value=accounts_by_id) fetch_records = MagicMock(return_value=[{'id': 1, 'value': 'a'}]) with test_app_in_context.test_request_context(json=[1, 2]): from flask import g g.request_context = SimpleNamespace( profile_type='ContentProfile', profile_id='1234' ) g.log = MagicMock() res = account_scoped_dataloader( entity_name='TestEntity', resolve_accounts=resolve_accounts, fetch_records=fetch_records, key_field='id', ) mock_log = g.log assert res.status_code == 200 mock_permissions_authorize_many_accounts.assert_called_once() assert mock_permissions_authorize_many_accounts.call_args.args[3] == [9] fetch_records.assert_called_once_with([1]) body = res.get_json() assert body[0]['data'] == {'id': 1, 'value': 'a'} assert body[1]['data'] is None mock_log.info.assert_called_once() mock_log.warn.assert_not_called() @patch('abacus_contract.utils.dataloader.permissions_authorize_many_accounts') @patch('abacus_contract.utils.dataloader.authorization') @patch('abacus_contract.utils.dataloader.flask_request') @patch('abacus_contract.utils.dataloader.get_optional_numeric_list_from_params') def test_as_list_true_shapes_each_entrys_data_as_a_list( mock_get_ids, mock_flask_request, mock_authorization, mock_permissions_authorize_many_accounts, test_app_in_context, ): """as_list=True shapes data via prepare_dataload_with_data_as_list_response.""" mock_get_ids.return_value = [1, 2] mock_flask_request.verify_rules_access_standalone.return_value = True mock_permissions_authorize_many_accounts.return_value = True resolve_accounts = MagicMock(return_value={1: 9, 2: 10}) fetch_records = MagicMock( return_value=[{'id': 1, 'value': 'a'}, {'id': 1, 'value': 'b'}] ) res = _call( test_app_in_context, resolve_accounts, fetch_records, key_field='id', as_list=True, ) body = res.get_json() assert body[0]['data'] == [{'id': 1, 'value': 'a'}, {'id': 1, 'value': 'b'}] assert body[1]['data'] is None @patch('abacus_contract.utils.dataloader.permissions_authorize_many_accounts') @patch('abacus_contract.utils.dataloader.authorization') @patch('abacus_contract.utils.dataloader.flask_request') @patch('abacus_contract.utils.dataloader.get_optional_numeric_list_from_params') def test_as_list_false_shapes_each_entrys_data_as_a_single_record( mock_get_ids, mock_flask_request, mock_authorization, mock_permissions_authorize_many_accounts, test_app_in_context, ): """as_list=False (default) shapes data via prepare_dataload_response.""" mock_get_ids.return_value = [1, 2] mock_flask_request.verify_rules_access_standalone.return_value = True mock_permissions_authorize_many_accounts.return_value = True resolve_accounts = MagicMock(return_value={1: 9, 2: 10}) fetch_records = MagicMock(return_value=[{'id': 1, 'value': 'a'}]) res = _call( test_app_in_context, resolve_accounts, fetch_records, key_field='id', as_list=False, ) body = res.get_json() assert body[0]['data'] == {'id': 1, 'value': 'a'} assert body[1]['data'] is None @pytest.mark.parametrize( 'records', [ pytest.param( [{'wrong_id': 1, 'value': 'a'}, {'wrong_id': 2, 'value': 'b'}], id='key_field absent (wrong field name)', ), pytest.param( [{'id': None, 'value': 'a'}, {'id': None, 'value': 'b'}], id='key_field present but always None', ), ], ) @patch('abacus_contract.utils.dataloader.observability') @patch('abacus_contract.utils.dataloader.permissions_authorize_many_accounts') @patch('abacus_contract.utils.dataloader.authorization') @patch('abacus_contract.utils.dataloader.flask_request') @patch('abacus_contract.utils.dataloader.get_optional_numeric_list_from_params') def test_key_field_mismatch_logs_error_and_shapes_all_null( mock_get_ids, mock_flask_request, mock_authorization, mock_permissions_authorize_many_accounts, mock_observability, records, test_app_in_context, ): """A fetch with no usable key_field value logs an error AND emits the metric. A copy whose fetch_records returns records keyed under a different field name (typo or a renamed serializer field), or whose key_field is always None, would otherwise ship a 200 with data: null for every id and no signal. The guard checks the key's value (not just its presence, since the shaper groups on record.get(key_field)) so both shapes surface loudly, via both the error log and the durable key_field_mismatch counter. """ mock_get_ids.return_value = [1, 2] mock_flask_request.verify_rules_access_standalone.return_value = True mock_permissions_authorize_many_accounts.return_value = True resolve_accounts = MagicMock(return_value={1: 9, 2: 10}) fetch_records = MagicMock(return_value=records) with test_app_in_context.test_request_context(json=[1, 2]): from flask import g g.request_context = SimpleNamespace( profile_type='ContentProfile', profile_id='1234' ) g.log = MagicMock() res = account_scoped_dataloader( entity_name='TestEntity', resolve_accounts=resolve_accounts, fetch_records=fetch_records, key_field='id', ) mock_log = g.log assert res.status_code == 200 mock_log.error.assert_called_once() mock_observability.dataloader_key_field_mismatch.assert_called_once_with( 'TestEntity', 'unknown' ) body = res.get_json() assert body[0]['data'] is None assert body[1]['data'] is None @patch('abacus_contract.utils.dataloader.permissions_authorize_many_accounts') @patch('abacus_contract.utils.dataloader.authorization') @patch('abacus_contract.utils.dataloader.flask_request') @patch('abacus_contract.utils.dataloader.get_optional_numeric_list_from_params') def test_records_carrying_key_field_do_not_trigger_the_mismatch_guard( mock_get_ids, mock_flask_request, mock_authorization, mock_permissions_authorize_many_accounts, test_app_in_context, ): """A healthy fetch (records carry a real key_field value) logs no error.""" mock_get_ids.return_value = [1, 2] mock_flask_request.verify_rules_access_standalone.return_value = True mock_permissions_authorize_many_accounts.return_value = True resolve_accounts = MagicMock(return_value={1: 9, 2: 10}) fetch_records = MagicMock(return_value=[{'id': 1, 'value': 'a'}]) with test_app_in_context.test_request_context(json=[1, 2]): from flask import g g.request_context = SimpleNamespace( profile_type='ContentProfile', profile_id='1234' ) g.log = MagicMock() res = account_scoped_dataloader( entity_name='TestEntity', resolve_accounts=resolve_accounts, fetch_records=fetch_records, key_field='id', ) mock_log = g.log assert res.status_code == 200 mock_log.error.assert_not_called() body = res.get_json() assert body[0]['data'] == {'id': 1, 'value': 'a'} assert body[1]['data'] is None @patch('abacus_contract.utils.dataloader.get_optional_numeric_list_from_params') def test_malformed_body_type_error_returns_400_and_skips_fetch( mock_get_ids, test_app_in_context ): """A body element that isn't a scalar (int() -> TypeError) returns 400, not 500.""" mock_get_ids.side_effect = TypeError() resolve_accounts = MagicMock() fetch_records = MagicMock() res = _call(test_app_in_context, resolve_accounts, fetch_records) assert res.status_code == 400 resolve_accounts.assert_not_called() fetch_records.assert_not_called() @patch('abacus_contract.utils.dataloader.observability') @patch('abacus_contract.utils.dataloader.permissions_authorize_many_accounts') @patch('abacus_contract.utils.dataloader.authorization') @patch('abacus_contract.utils.dataloader.flask_request') @patch('abacus_contract.utils.dataloader.get_optional_numeric_list_from_params') def test_emits_batch_metric_with_requested_and_unresolved_counts( mock_get_ids, mock_flask_request, mock_authorization, mock_permissions_authorize_many_accounts, mock_observability, test_app_in_context, ): """Every batch emits the requested count and the unresolved count for ratio alerting.""" mock_get_ids.return_value = [1, 2] mock_flask_request.verify_rules_access_standalone.return_value = True mock_permissions_authorize_many_accounts.return_value = True resolve_accounts = MagicMock(return_value={1: 9}) # id 2 does not resolve fetch_records = MagicMock(return_value=[{'id': 1, 'value': 'a'}]) res = _call(test_app_in_context, resolve_accounts, fetch_records) assert res.status_code == 200 mock_observability.dataloader_batch.assert_called_once_with( 'TestEntity', 'unknown', 2, 1 ) @patch('abacus_contract.utils.dataloader.permissions_authorize_many_accounts') @patch('abacus_contract.utils.dataloader.authorization') @patch('abacus_contract.utils.dataloader.flask_request') def test_malformed_body_element_returns_400_via_real_parser( mock_flask_request, mock_authorization, mock_permissions_authorize_many_accounts, test_app_in_context, ): """A non-scalar body element hits the real int() TypeError path and returns 400. Exercises get_optional_numeric_list_from_params for real (not stubbed) so the justification for catching TypeError is covered end to end. """ resolve_accounts = MagicMock() fetch_records = MagicMock() with test_app_in_context.test_request_context(json=[1, [2]]): from flask import g g.request_context = SimpleNamespace( profile_type='ContentProfile', profile_id='1234' ) g.log = MagicMock() res = account_scoped_dataloader( entity_name='TestEntity', resolve_accounts=resolve_accounts, fetch_records=fetch_records, key_field='id', ) assert res.status_code == 400 resolve_accounts.assert_not_called() fetch_records.assert_not_called()