From 2790b5aa7ee3651ab6ad6432f47df37e85674358 Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Wed, 8 Oct 2025 21:37:38 -0500 Subject: [PATCH 01/23] Add mailchip utils script --- src/stac_utils/mailchimp.py | 430 ++++++++++++++++++++++++++++++++++++ 1 file changed, 430 insertions(+) create mode 100644 src/stac_utils/mailchimp.py diff --git a/src/stac_utils/mailchimp.py b/src/stac_utils/mailchimp.py new file mode 100644 index 00000000..6839e8ba --- /dev/null +++ b/src/stac_utils/mailchimp.py @@ -0,0 +1,430 @@ +import os +import json +import requests +from .http import HTTPClient +import logging +import hashlib +from datetime import datetime, date +from typing import Any + +# logging +logger = logging.getLogger(__name__) + + +class MailChimpClient(HTTPClient): + """ + Mailchimp Utils for working with the Mailchimp API + """ + + def __init__(self, api_key: str = None, *args, **kwargs): + self.api_key = api_key or os.environ.get("MAILCHIMP_API_KEY") + + # derive data center (i.e. 'us9') from API key suffix to get the base_url (no universal base_url here...) + # see: https://mailchimp.com/developer/marketing/docs/fundamentals/#api-structure + self.data_center = self.api_key.split("-")[-1] + self.base_url = f"https://{self.data_center}.api.mailchimp.com/3.0" + super().__init__(*args, **kwargs) + + def create_session(self) -> requests.Session: + """Creates Mailchimp session""" + session = requests.Session() + # https://mailchimp.com/developer/marketing/docs/fundamentals/#api-structure + session.auth = ("anystring", self.api_key) + session.headers.update({"Content-Type": "application/json"}) + return session + + def transform_response(self, response: requests.Response, **kwargs) -> dict: + """ + Transforms MailChimp response into dict + + :param response: The HTTP response object returned by requests + :return: response data as dict, always including a "status_code" key + """ + try: + # for handling 204 and empty responses + if response.status_code != 204 and response.content: + data = response.json() or {} + else: + data = {} + except (ValueError, json.decoder.JSONDecodeError): + data = {} + data["status_code"] = response.status_code + return data + + def check_response_for_rate_limit(self, response: requests.Response) -> int: + """ + Checks Mailchimp response for rate limit, always returns 1 + + :param response: the HTTP response object returned by requests + :return: always returns 1 + """ + # added basic logging in this method... + if response.status_code == 429: + logger.warning("Mailchimp rate limit hit (HTTP 429: Too Many Requests)") + return 1 + + def paginate_endpoint( + self, + base_endpoint: str, + data_key: str, + count: int = 1000, + max_pages: int = None, + **kwargs, + ) -> list[dict]: + """ + Generic pagination helper for Mailchimp endpoints that return + collections (i.e., lists, members, campaigns, etc.). + + :param base_endpoint: the endpoint to paginate (i.e "lists" or "lists/{list_id}/members"). + :param data_key: the expected key in the response dict (i.e "lists", "members"). + :param count: number of items to fetch per page (default set to 1000, which is MailChimp's max). + :param max_pages: optional parameter to limit the number of pages (can be used for testing) + :return: a list of all collected items from the paginated responses + """ + results = [] + page = 1 + + # MailChimp uses offset to skip records for pagination + # see: https://mailchimp.com/developer/marketing/docs/methods-parameters/#pagination + offset = 0 + + while True: + params = {"count": count, "offset": offset, **kwargs} + url = f"{self.base_url}/{base_endpoint}" + response = self.session.get(url, params=params) + data = self.transform_response(response) + + items = data.get(data_key, []) + if not items: + logger.debug(f"No items found at offset {offset} for key '{data_key}'") + break + + results.extend(items) + + total = data.get("total_items", 0) + + # stop if max_pages is set and the page is max_pages + if max_pages is not None and page >= max_pages: + break + + # increment pagination and offset + offset += count + page += 1 + + # if everything is fetched, stop! + if offset >= total: + break + + return results + + @staticmethod + def get_subscriber_hash(email: str) -> str: + """ + Return the Mailchimp subscriber hash for a given email. + This is the unique identifier of the member, scoped to a given audience_id (list_id) + + :param email: the email to get the subscriber hash (MailChimp member id) for + :return: subscriber hash (MailChimp member id) + """ + # see: https://mailchimp.com/developer/marketing/docs/methods-parameters/ + # borrowed from: https://endgrate.com/blog/using-the-mailchimp-api-to-create-members-%28with-python-examples%29 + return hashlib.md5(email.lower().encode()).hexdigest() + + def update_member_tags( + self, + list_id: str, + email_address: str, + tags: list[str], + active: bool, + ) -> dict: + """ + This method adds or removes tags for a member. + + See: https://mailchimp.com/developer/marketing/api/list-member-tags/add-or-remove-member-tags/ + + :param list_id: MailChimp audience (list) id + :param email_address: member email address. + :param tags: list of exact names of tags to add or remove. + :param active: flags whether to add or remove the tags. True adds the tags, and False removes the tags. + """ + # clean & dedupe tags + skip blanks + cleaned = [ + tag.strip() for tag in (tags or []) if isinstance(tag, str) and tag.strip() + ] + + subscriber_hash = self.get_subscriber_hash(email_address) + url = f"{self.base_url}/lists/{list_id}/members/{subscriber_hash}/tags" + + payload = { + "tags": [ + {"name": tag, "status": "active" if active else "inactive"} + for tag in cleaned + ] + } + response = self.session.post(url, json=payload) + return self.transform_response(response) + + def upsert_member( + self, + list_id: str, + email_address: str, + status_if_new: str = "subscribed", + merge_fields: dict = None, + **kwargs, + ) -> dict: + """ + Add or update a MailChimp member (contact) in a given audience (list_id). + This method creates the member if they don't exist, using the status_if_new parameter + If the member already exists, it will update only fields that are provided in merge_fields + + The merge_fields dict takes key:value pairs that update other fields (first name, last name, etc) for a given audience + Because these fields are unique to a given audience id, you will have to find these in the "Audience fields + and merge tags" section in MailChimp, and the keys will be the "Merge tag" without the *||* + (i.e. (potentially) FNAME, LNAME, ADDRESS, etc). Tags must exist for the given audience or the + request will error (HTTP 400) + + NOTE: by default, any merge fields that have "required" set to true in MailChimp MUST be included + when adding a contact. + + Additional parameters you would like to add to the payload to pass to MailChimp API using **kwargs can be found here: + https://mailchimp.com/developer/marketing/api/list-members/add-or-update-list-member/ + + :param list_id: MailChimp audience (list) id + :param email_address: member email address. + :param merge_fields: PII fields unique to a MailChimp audience. Make sure to check the front end of MailChimp to get these fields + :param status_if_new: subscriber's status that is used only when creating a new record. Valid values include "subscribed", "unsubscribed", "cleaned", "pending", or "transactional". Defaults to "subscribed". + :return: dict value from the transform_response() method + """ + + subscriber_hash = self.get_subscriber_hash(email_address) + url = f"{self.base_url}/lists/{list_id}/members/{subscriber_hash}" + + payload: dict = { + "email_address": email_address, + "status_if_new": status_if_new, + } + if merge_fields: + # format merge fields + formatted = self.format_merge_fields_for_list(list_id, merge_fields) + # add non-empty merge_fields to payload + if formatted: + payload["merge_fields"] = formatted + + # in case you want to add other parameters to the payload, not covered in the existing parameters + # see: https://mailchimp.com/developer/marketing/api/list-members/add-or-update-list-member/ + other_params = { + key: value + for key, value in kwargs.items() + if value is not None + and key not in {"email_address", "status_if_new", "merge_fields"} + } + payload.update(other_params) + + response = self.session.put(url, json=payload) + return self.transform_response(response) + + def get_merge_fields_data_type_map(self, list_id: str, **kwargs) -> dict[str, str]: + """ + This method provides a mapping of the merge field tags and data types for a given audience (list_id). + i.e. {'BIRTHDAY': 'birthday', 'FNAME': 'text', 'LNAME': 'text'} + + see: https://mailchimp.com/developer/marketing/api/list-merges/list-merge-fields/ + + :param list_id: MailChimp audience (list) id + :return: dict mapping {"Merge Tag": "Data Type"} + """ + fields = self.paginate_endpoint( + base_endpoint=f"lists/{list_id}/merge-fields", + data_key="merge_fields", + **kwargs, + ) + return {field["tag"]: field.get("type") for field in fields if field.get("tag")} + + def format_merge_fields_for_list( + self, + list_id: str, + merge_fields: dict[str, Any], + ) -> dict[str, Any]: + """ + This method formats MailChimp merge fields for a given audience (list_id). + + see: https://mailchimp.com/developer/marketing/docs/merge-fields/#add-merge-data-to-contacts + + :param list_id: MailChimp audience (list) id + :param merge_fields: MailChimp merge fields dict + :return: formatted MailChimp merge fields dict + """ + merge_fields_data_type_map = self.get_merge_fields_data_type_map(list_id) + merge_fields_cleaned = {} + + for tag, value in (merge_fields or {}).items(): + # ignore blanks and empty string + if value is None: + continue + if isinstance(value, str): + value = value.strip() + if value == "": + continue + + data_type = merge_fields_data_type_map.get(tag) + + # raise error if unknown tags (all tags should be valid) + if data_type is None: + raise KeyError(f"Unknown merge tag for this audience: {list_id}: {tag}") + + if data_type == "date": + merge_fields_cleaned[tag] = self.format_date(value) + elif data_type == "birthday": + merge_fields_cleaned[tag] = self.format_birthday(value) + elif data_type == "address": + merge_fields_cleaned[tag] = self.format_address(value) + elif data_type == "number": + merge_fields_cleaned[tag] = self.format_number(value) + elif data_type == "zip": + if len(str(value)) > 5: + raise ValueError("Zip codes must be 5 digits") + merge_fields_cleaned[tag] = str(value) + else: + # text, radio, dropdown, phone, url, imageurl -- all string + merge_fields_cleaned[tag] = ( + str(value) + if not isinstance(value, (int, float, bool, dict, list, tuple)) + else value + ) + + return merge_fields_cleaned + + @staticmethod + def format_date(val: Any) -> str: + """ + Helper method to normalize date to YYYY-MM-DD + + see: https://mailchimp.com/developer/marketing/docs/merge-fields/#add-merge-data-to-contacts + + :param val: date value to normalize + :return: normalized date in YYYY-MM-DD + """ + # if in date/datetime + if isinstance(val, (datetime, date)): + return datetime(val.year, val.month, val.day).strftime("%Y-%m-%d") + # if in string + if isinstance(val, str): + val = val.strip() + # covers most common cases + for format in ("%Y-%m-%d", "%Y/%m/%d", "%m/%d/%Y", "%d-%m-%Y", "%m-%d-%Y"): + try: + return datetime.strptime(val, format).strftime("%Y-%m-%d") + except ValueError: + pass + raise ValueError(f"Unable to parse the date value: {val}") + + @staticmethod + def format_birthday(val: Any) -> str: + """ + Helper method to normalize to MM/DD, which is the MailChimp birthday format + + see: https://mailchimp.com/developer/marketing/docs/merge-fields/#add-merge-data-to-contacts + + :param val: date value to normalize + :return: normalized date in MM/DD + """ + # if in date/datetime + if isinstance(val, (datetime, date)): + return f"{val.month:02d}/{val.day:02d}" + # covers most common cases + if isinstance(val, str): + val = val.strip() + # covers most cases + for format in ( + "%m/%d", + "%m-%d", + "%Y-%m-%d", + "%Y/%m/%d", + "%m/%d/%Y", + "%d-%m-%Y", + "%m-%d-%Y", + ): + try: + bday = datetime.strptime(val, format) + return f"{bday.month:02d}/{bday.day:02d}" + except ValueError: + pass + raise ValueError(f"Unable to parse the birthdate value: {val}") + + @staticmethod + def format_address(val: Any) -> dict: + """ + Helper method to ensure MailChimp address format is met + Required fields are: addr1, city, state, zip + Optional fields are: addr2, country + + see: https://mailchimp.com/developer/marketing/docs/merge-fields/#add-merge-data-to-contacts + + :param val: address dict value + :return: address dict value formatted + """ + # if the input value is not a dict, then error + if not isinstance(val, dict): + raise ValueError("Address value must be a dict") + + def normalize_string(s: Any) -> str: + """ + normalize the string + """ + if s is None: + return "" + return s.strip() if isinstance(s, str) else str(s).strip() + + addr1 = normalize_string(val.get("addr1")) + addr2 = normalize_string(val.get("addr2")) + city = normalize_string(val.get("city")) + state = normalize_string(val.get("state")) + zip = normalize_string(val.get("zip")) + country = normalize_string(val.get("country")) + + # Validate required fields + if not (addr1 and city and state and zip): + raise ValueError(f"Address missing required fields") + + # Build final payload: include optionals only if non-empty + val_formatted = {"addr1": addr1, "city": city, "state": state, "zip": zip} + if addr2: + val_formatted["addr2"] = addr2 + if country: + val_formatted["country"] = country + + return val_formatted + + @staticmethod + def format_number(val: Any) -> int | float: + """ + Helper method to ensure MailChimp number format is met + + :param val: number value to normalize + :return: int or float + """ + # handles any bool vals (bool is subclass of int...) + if isinstance(val, bool): + raise ValueError("Boolean is not a valid number") + # if already int or float, good to go + if isinstance(val, (int, float)): + return val + # if string + if isinstance(val, str): + val_formatted = val.strip() + if val_formatted == "": + raise ValueError("Empty string is not a valid number") + try: + # check if int or float val + return ( + int(val_formatted) + if val_formatted.isdigit() + or ( + (val_formatted.startswith("-") or val_formatted.startswith("+")) + and val_formatted[1:].isdigit() + ) + else float(val_formatted) + ) + except ValueError: + pass + raise ValueError(f"Not a valid number: {val}") From 609fa1d2f9de27baf3038c4c0b3d71463ea5ebc3 Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Wed, 8 Oct 2025 22:02:04 -0500 Subject: [PATCH 02/23] add mailchimp to index.rst --- docs/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.rst b/docs/index.rst index 78af515d..3433f3ed 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,6 +19,7 @@ Welcome to stac-utils-python's documentation! stac_utils.jira stac_utils.listify stac_utils.logger + stac_utils.mailchimp stac_utils.ngpvan stac_utils.normalize stac_utils.pandas_utils From 142a818cfd6be286f5210d1104e70988fd2fe276 Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Wed, 8 Oct 2025 22:06:37 -0500 Subject: [PATCH 03/23] remove f-string --- src/stac_utils/mailchimp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stac_utils/mailchimp.py b/src/stac_utils/mailchimp.py index 6839e8ba..df8d118d 100644 --- a/src/stac_utils/mailchimp.py +++ b/src/stac_utils/mailchimp.py @@ -384,7 +384,7 @@ def normalize_string(s: Any) -> str: # Validate required fields if not (addr1 and city and state and zip): - raise ValueError(f"Address missing required fields") + raise ValueError("Address missing required fields") # Build final payload: include optionals only if non-empty val_formatted = {"addr1": addr1, "city": city, "state": state, "zip": zip} From b466390263b27671c3cc57cbcf3473a0db8b82c8 Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Thu, 9 Oct 2025 19:54:55 -0500 Subject: [PATCH 04/23] add tests for init, create_session, transform_response, check_response_for_rate_limit, paginate_endpoint, and get_subscriber_hash --- src/tests/test_mailchimp.py | 180 ++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 src/tests/test_mailchimp.py diff --git a/src/tests/test_mailchimp.py b/src/tests/test_mailchimp.py new file mode 100644 index 00000000..5731a30f --- /dev/null +++ b/src/tests/test_mailchimp.py @@ -0,0 +1,180 @@ +import os +import json +import unittest +from unittest.mock import MagicMock, patch +from src.stac_utils.mailchimp import MailChimpClient, logger + +class TestMailChimpClient(unittest.TestCase): + def setUp(self) -> None: + self.test_client = MailChimpClient(api_key="fake-us9") + self.test_logger = logger + + def test_init_env_keys(self): + """Test that client initializes with environmental keys""" + test_api_key = "hgd1204-us20" + with patch.dict(os.environ, values={"MAILCHIMP_API_KEY": test_api_key}): + test_client = MailChimpClient() + self.assertEqual(test_api_key, test_client.api_key) + # test the extracted data center and base_url + self.assertEqual("us20", test_client.data_center) + self.assertEqual( + "https://us20.api.mailchimp.com/3.0", test_client.base_url + ) + + def test_create_session(self): + """Test that API token and content type is set in headers for a Mailchimp session""" + session = self.test_client.create_session() + + # check that the auth tuple is correctly set + self.assertEqual(session.auth, ("anystring", self.test_client.api_key)) + + # check that the "Content-Type" header exists and has a value of "application/json" + self.assertIn("Content-Type", session.headers) + self.assertEqual(session.headers["Content-Type"], "application/json") + + def test_transform_response_valid_json(self): + """Test that response is transformed and includes status code""" + mock_data = {"foo_bar": "spam", "email_address": "fake@none.com"} + mock_response = MagicMock() + mock_response.status_code = 200 + # http response body contains bytes + mock_response.content = json.dumps(mock_data).encode() + mock_response.json.return_value = mock_data + result = self.test_client.transform_response(mock_response) + # make sure output matches expected dict + self.assertEqual(result, {"foo_bar": "spam", "email_address": "fake@none.com", "status_code": 200}) + + def test_transform_response_empty_content(self): + """Test that empty content or a 204 response returns only the status code""" + mock_response = MagicMock() + mock_response.status_code = 204 + # no data returned in 204 response + mock_response.content = b"" + result = self.test_client.transform_response(mock_response) + self.assertEqual(result, {"status_code": 204}) + + def test_transform_response_invalid_json(self): + """Test that response for invalid JSON returns an empty dict with the status code""" + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.content = b"not a valid json" + # bad json + mock_response.json.side_effect = json.decoder.JSONDecodeError("error", "not a valid json", 0) + result = self.test_client.transform_response(mock_response) + self.assertEqual(result, {"status_code": 500}) + + @patch.object(logger, "warning") + def test_check_response_for_rate_limit(self, mock_warning): + """Test that check_response_for_rate_limit always returns 1 and actually logs warning on 429""" + mock_response_valid = MagicMock() + mock_response_valid.status_code = 200 + + mock_response_limit = MagicMock() + mock_response_limit.status_code = 429 + + # should always return 1 + result_valid = self.test_client.check_response_for_rate_limit(mock_response_valid) + result_limit = self.test_client.check_response_for_rate_limit(mock_response_limit) + + self.assertEqual(result_valid, 1) + self.assertEqual(result_limit, 1) + + # verify that the logger warning called once (occurs when 429) + mock_warning.assert_called_once_with("Mailchimp rate limit hit (HTTP 429: Too Many Requests)") + + @patch.object(MailChimpClient, "transform_response") + @patch.object(logger, "debug") + @patch("requests.Session.get") + def test_paginate_endpoint_valid(self, mock_get, mock_debug, mock_transform): + """Test that paginate_endpoint correctly aggregates results across multiple pages""" + # set pages + mock_transform.side_effect = [ + {"members": [{"id": "1"}, {"id": "2"}], "total_items": 4}, + {"members": [{"id": "3"}, {"id": "4"}], "total_items": 4}, + ] + + # set http calls + fake_response_1 = MagicMock() + fake_response_2 = MagicMock() + mock_get.side_effect = [fake_response_1, fake_response_2] + + results = self.test_client.paginate_endpoint( + base_endpoint="lists/898/members", + data_key="members", + count=2, + max_pages=2, + ) + # all data in list + self.assertEqual(results, [{"id": "1"}, {"id": "2"}, {"id": "3"}, {"id": "4"}]) + # two calls + self.assertEqual(mock_get.call_count, 2) + # debug not called + mock_debug.assert_not_called() + + @patch.object(MailChimpClient, "transform_response") + @patch.object(logger, "debug") + @patch("requests.Session.get") + def test_paginate_endpoint_debug_logs_when_empty(self, mock_get, mock_debug, mock_transform): + """Test that paginate_endpoint logs debug message and stops when no items are found""" + # mock first page has data, second page empty (triggers debug...) + mock_transform.side_effect = [ + {"members": [{"id": "1"}, {"id": "2"}], "total_items": 4}, + {"members": []}, + ] + + # set HTTP calls + fake_response_1 = MagicMock() + fake_response_2 = MagicMock() + mock_get.side_effect = [fake_response_1, fake_response_2] + + + self.test_client.paginate_endpoint( + base_endpoint="lists/898/members", + data_key="members", + count=2, + ) + + # should log debug once when second page is empty + mock_debug.assert_called_once_with("No items found at offset 2 for key 'members'") + # two calls + self.assertEqual(mock_get.call_count, 2) + + @patch.object(MailChimpClient, "transform_response") + @patch.object(logger, "debug") + @patch("requests.Session.get") + def test_paginate_endpoint_stops_when_total_items_reached(self, mock_get, mock_debug, mock_transform): + """Test that paginate_endpoint stops paginating when offset >= total_items""" + mock_transform.side_effect = [ + {"members": [{"id": "1"}, {"id": "2"}], "total_items": 3}, + {"members": [{"id": "3"}], "total_items": 3}, + ] + + # set HTTP calls + fake_response_1 = MagicMock() + fake_response_2 = MagicMock() + mock_get.side_effect = [fake_response_1, fake_response_2] + + results = self.test_client.paginate_endpoint( + base_endpoint="lists/010/members", + data_key="members", + count=2, + ) + + # 3 total members + self.assertEqual(results, [{"id": "1"}, {"id": "2"}, {"id": "3"}]) + # two calls + self.assertEqual(mock_get.call_count, 2) + # debug not called + mock_debug.assert_not_called() + + def test_get_subscriber_hash(self): + """Test that get_subscriber_hash returns correct md5 hash and normalizes to lowercase""" + email = "wEiRd@staclabs.coM" + expected_subscriber_hash = "67441e845c03ceda61635c3263393515" + + result = MailChimpClient.get_subscriber_hash(email) + self.assertEqual(result, expected_subscriber_hash) + + # lowercase version + self.assertEqual(result, MailChimpClient.get_subscriber_hash("weird@staclabs.com")) + From 2fa981ef3f620b86c80279cd095bc0b84327aaa0 Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Thu, 9 Oct 2025 20:24:07 -0500 Subject: [PATCH 05/23] add test for update_member_tags --- src/tests/test_mailchimp.py | 116 ++++++++++++++++++++++++++++++++---- 1 file changed, 103 insertions(+), 13 deletions(-) diff --git a/src/tests/test_mailchimp.py b/src/tests/test_mailchimp.py index 5731a30f..8ed93670 100644 --- a/src/tests/test_mailchimp.py +++ b/src/tests/test_mailchimp.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch from src.stac_utils.mailchimp import MailChimpClient, logger + class TestMailChimpClient(unittest.TestCase): def setUp(self) -> None: self.test_client = MailChimpClient(api_key="fake-us9") @@ -17,9 +18,7 @@ def test_init_env_keys(self): self.assertEqual(test_api_key, test_client.api_key) # test the extracted data center and base_url self.assertEqual("us20", test_client.data_center) - self.assertEqual( - "https://us20.api.mailchimp.com/3.0", test_client.base_url - ) + self.assertEqual("https://us20.api.mailchimp.com/3.0", test_client.base_url) def test_create_session(self): """Test that API token and content type is set in headers for a Mailchimp session""" @@ -42,7 +41,10 @@ def test_transform_response_valid_json(self): mock_response.json.return_value = mock_data result = self.test_client.transform_response(mock_response) # make sure output matches expected dict - self.assertEqual(result, {"foo_bar": "spam", "email_address": "fake@none.com", "status_code": 200}) + self.assertEqual( + result, + {"foo_bar": "spam", "email_address": "fake@none.com", "status_code": 200}, + ) def test_transform_response_empty_content(self): """Test that empty content or a 204 response returns only the status code""" @@ -59,7 +61,9 @@ def test_transform_response_invalid_json(self): mock_response.status_code = 500 mock_response.content = b"not a valid json" # bad json - mock_response.json.side_effect = json.decoder.JSONDecodeError("error", "not a valid json", 0) + mock_response.json.side_effect = json.decoder.JSONDecodeError( + "error", "not a valid json", 0 + ) result = self.test_client.transform_response(mock_response) self.assertEqual(result, {"status_code": 500}) @@ -73,14 +77,20 @@ def test_check_response_for_rate_limit(self, mock_warning): mock_response_limit.status_code = 429 # should always return 1 - result_valid = self.test_client.check_response_for_rate_limit(mock_response_valid) - result_limit = self.test_client.check_response_for_rate_limit(mock_response_limit) + result_valid = self.test_client.check_response_for_rate_limit( + mock_response_valid + ) + result_limit = self.test_client.check_response_for_rate_limit( + mock_response_limit + ) self.assertEqual(result_valid, 1) self.assertEqual(result_limit, 1) # verify that the logger warning called once (occurs when 429) - mock_warning.assert_called_once_with("Mailchimp rate limit hit (HTTP 429: Too Many Requests)") + mock_warning.assert_called_once_with( + "Mailchimp rate limit hit (HTTP 429: Too Many Requests)" + ) @patch.object(MailChimpClient, "transform_response") @patch.object(logger, "debug") @@ -114,7 +124,9 @@ def test_paginate_endpoint_valid(self, mock_get, mock_debug, mock_transform): @patch.object(MailChimpClient, "transform_response") @patch.object(logger, "debug") @patch("requests.Session.get") - def test_paginate_endpoint_debug_logs_when_empty(self, mock_get, mock_debug, mock_transform): + def test_paginate_endpoint_debug_logs_when_empty( + self, mock_get, mock_debug, mock_transform + ): """Test that paginate_endpoint logs debug message and stops when no items are found""" # mock first page has data, second page empty (triggers debug...) mock_transform.side_effect = [ @@ -127,7 +139,6 @@ def test_paginate_endpoint_debug_logs_when_empty(self, mock_get, mock_debug, moc fake_response_2 = MagicMock() mock_get.side_effect = [fake_response_1, fake_response_2] - self.test_client.paginate_endpoint( base_endpoint="lists/898/members", data_key="members", @@ -135,14 +146,18 @@ def test_paginate_endpoint_debug_logs_when_empty(self, mock_get, mock_debug, moc ) # should log debug once when second page is empty - mock_debug.assert_called_once_with("No items found at offset 2 for key 'members'") + mock_debug.assert_called_once_with( + "No items found at offset 2 for key 'members'" + ) # two calls self.assertEqual(mock_get.call_count, 2) @patch.object(MailChimpClient, "transform_response") @patch.object(logger, "debug") @patch("requests.Session.get") - def test_paginate_endpoint_stops_when_total_items_reached(self, mock_get, mock_debug, mock_transform): + def test_paginate_endpoint_stops_when_total_items_reached( + self, mock_get, mock_debug, mock_transform + ): """Test that paginate_endpoint stops paginating when offset >= total_items""" mock_transform.side_effect = [ {"members": [{"id": "1"}, {"id": "2"}], "total_items": 3}, @@ -176,5 +191,80 @@ def test_get_subscriber_hash(self): self.assertEqual(result, expected_subscriber_hash) # lowercase version - self.assertEqual(result, MailChimpClient.get_subscriber_hash("weird@staclabs.com")) + self.assertEqual( + result, MailChimpClient.get_subscriber_hash("weird@staclabs.com") + ) + + @patch.object(MailChimpClient, "transform_response") + @patch("requests.Session.post") + def test_update_member_tags_success_active(self, mock_post, mock_transform): + """Test that update_member_tags correctly handles a 204 No Content Mailchimp success response""" + fake_response = MagicMock() + fake_response.status_code = 204 + fake_response.content = b"" + mock_post.return_value = fake_response + + mock_transform.return_value = {"status_code": 204} + + result = self.test_client.update_member_tags( + list_id="102930al", + email_address="fake@none.com", + tags=["NEWS ", "DONOR", " "], + active=True, + ) + + expected_hash = self.test_client.get_subscriber_hash("fake@none.com") + expected_url = f"https://us9.api.mailchimp.com/3.0/lists/102930al/members/{expected_hash}/tags" + mock_post.assert_called_once_with( + expected_url, + json={ + "tags": [ + {"name": "NEWS", "status": "active"}, + {"name": "DONOR", "status": "active"}, + ] + }, + ) + + # transform_response called once + mock_transform.assert_called_once_with(fake_response) + + # no body in this call, just the status code + self.assertEqual(result, {"status_code": 204}) + + @patch.object(MailChimpClient, "transform_response") + @patch("requests.Session.post") + def test_update_member_tags_success_inactive(self, mock_post, mock_transform): + """Test that update_member_tags correctly handles a 204 No Content Mailchimp success response""" + fake_response = MagicMock() + fake_response.status_code = 204 + fake_response.content = b"" + mock_post.return_value = fake_response + + mock_transform.return_value = {"status_code": 204} + + result = self.test_client.update_member_tags( + list_id="102930al", + email_address="fake@none.com", + tags=["NEWS ", "DONOR", " "], + active=False, + ) + + expected_hash = self.test_client.get_subscriber_hash("fake@none.com") + expected_url = f"https://us9.api.mailchimp.com/3.0/lists/102930al/members/{expected_hash}/tags" + + mock_post.assert_called_once_with( + expected_url, + json={ + "tags": [ + {"name": "NEWS", "status": "inactive"}, + {"name": "DONOR", "status": "inactive"}, + ] + }, + ) + + # transform_response called once + mock_transform.assert_called_once_with(fake_response) + + # no body in this call, just the status code + self.assertEqual(result, {"status_code": 204}) From b94b3e60434c00e27bd3b8a6088eb0e86389b8dd Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Thu, 9 Oct 2025 20:59:28 -0500 Subject: [PATCH 06/23] add test for upsert_member --- src/tests/test_mailchimp.py | 80 ++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/src/tests/test_mailchimp.py b/src/tests/test_mailchimp.py index 8ed93670..3967086d 100644 --- a/src/tests/test_mailchimp.py +++ b/src/tests/test_mailchimp.py @@ -198,7 +198,7 @@ def test_get_subscriber_hash(self): @patch.object(MailChimpClient, "transform_response") @patch("requests.Session.post") def test_update_member_tags_success_active(self, mock_post, mock_transform): - """Test that update_member_tags correctly handles a 204 No Content Mailchimp success response""" + """Test that update_member_tags correctly handles a 204 MailChimp success response for adding tags""" fake_response = MagicMock() fake_response.status_code = 204 fake_response.content = b"" @@ -235,7 +235,7 @@ def test_update_member_tags_success_active(self, mock_post, mock_transform): @patch.object(MailChimpClient, "transform_response") @patch("requests.Session.post") def test_update_member_tags_success_inactive(self, mock_post, mock_transform): - """Test that update_member_tags correctly handles a 204 No Content Mailchimp success response""" + """Test that update_member_tags correctly handles a 204 MailChimp success response for removing tags""" fake_response = MagicMock() fake_response.status_code = 204 fake_response.content = b"" @@ -268,3 +268,79 @@ def test_update_member_tags_success_inactive(self, mock_post, mock_transform): # no body in this call, just the status code self.assertEqual(result, {"status_code": 204}) + + # have to include this, as otherwise tests will fail as no datatype mapping + @patch.object( + MailChimpClient, + "get_merge_fields_data_type_map", + return_value={"FNAME": "text", "LNAME": "text"}, + ) + @patch.object(MailChimpClient, "transform_response") + @patch("requests.Session.put") + def test_upsert_member_success( + self, mock_put, mock_transform, mock_merge_fields_map + ): + """Test that upsert_member sends correct payload and handles MailChimp 200 JSON response""" + + fake_response = MagicMock() + fake_response.status_code = 200 + + mock_put.return_value = fake_response + mock_transform.return_value = { + "id": "8121stac", + "email_address": "fake@none.com", + "status_code": 200, + } + + list_id = "102930al" + email_address = "fake@none.com" + merge_fields = {"FNAME": "Fake", "LNAME": "Dude"} + + result = self.test_client.upsert_member( + list_id=list_id, email_address=email_address, merge_fields=merge_fields + ) + + expected_hash = self.test_client.get_subscriber_hash(email_address) + expected_url = ( + f"https://us9.api.mailchimp.com/3.0/lists/{list_id}/members/{expected_hash}" + ) + + expected_payload = { + "email_address": email_address, + "status_if_new": "subscribed", + "merge_fields": merge_fields, + } + # put called once + mock_put.assert_called_once_with(expected_url, json=expected_payload) + # return is called once + mock_transform.assert_called_once_with(fake_response) + # success + self.assertEqual(result["status_code"], 200) + # compare final email val to expected + self.assertEqual(result["email_address"], "fake@none.com") + + # have to include this, as otherwise tests will fail as no datatype mapping + @patch.object( + MailChimpClient, + "get_merge_fields_data_type_map", + return_value={"FNAME": "text"}, + ) + @patch.object(MailChimpClient, "transform_response") + @patch("requests.Session.put") + def test_upsert_member_fail(self, mock_put, mock_transform, mock_merge_fields_map): + """Test that upsert_member raises error when merge fields contain fake tags""" + list_id = "102930al" + email_address = "fake@none.com" + + # test KeyError is raised + with self.assertRaises(KeyError): + self.test_client.upsert_member( + list_id=list_id, + email_address=email_address, + merge_fields={"FAKE_MERGE_TAG": "some_val"}, + ) + + # no put call for upsert should have occurred + mock_put.assert_not_called() + # no transform_response call for upsert should have occurred + mock_transform.assert_not_called() From aafb6e3cf8f5801af73e61a2b561850ec8e923b0 Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Thu, 9 Oct 2025 21:32:03 -0500 Subject: [PATCH 07/23] add tests for get_merge_fields_data_type_map --- src/tests/test_mailchimp.py | 42 ++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/tests/test_mailchimp.py b/src/tests/test_mailchimp.py index 3967086d..a71329cc 100644 --- a/src/tests/test_mailchimp.py +++ b/src/tests/test_mailchimp.py @@ -1,7 +1,7 @@ import os import json import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import patch, MagicMock from src.stac_utils.mailchimp import MailChimpClient, logger @@ -344,3 +344,43 @@ def test_upsert_member_fail(self, mock_put, mock_transform, mock_merge_fields_ma mock_put.assert_not_called() # no transform_response call for upsert should have occurred mock_transform.assert_not_called() + + @patch.object(MailChimpClient, "paginate_endpoint") + def test_get_merge_fields_data_type_map_success(self, mock_paginate): + """Test that get_merge_fields_data_type_map returns correct mapping on success""" + mock_paginate.return_value = [ + {"tag": "FNAME", "type": "text"}, + {"tag": "LNAME", "type": "text"}, + {"tag": "VAN_ID", "type": "number"}, + ] + + list_id = "98798798h" + + result = self.test_client.get_merge_fields_data_type_map(list_id) + + mock_paginate.assert_called_once_with( + base_endpoint=f"lists/{list_id}/merge-fields", + data_key="merge_fields" + ) + + expected_map = {"FNAME": "text", "LNAME": "text", "VAN_ID": "number"} + self.assertEqual(result, expected_map) + + @patch.object(MailChimpClient, "paginate_endpoint") + def test_get_merge_fields_data_type_map_fail(self, mock_paginate): + """Test that get_merge_fields_data_type_map returns empty dict fail""" + + # paginate_endpoint returns no data + mock_paginate.return_value = [{}] + + list_id = "zzzz" + result = self.test_client.get_merge_fields_data_type_map(list_id) + + # paginate_endpoint called once + mock_paginate.assert_called_once_with( + base_endpoint=f"lists/{list_id}/merge-fields", + data_key="merge_fields" + ) + + # empty dict for result + self.assertEqual(result, {}) \ No newline at end of file From e86d9cc9414aee11238b20b19f65a89867c89920 Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Thu, 9 Oct 2025 22:00:08 -0500 Subject: [PATCH 08/23] add tests for format_merge_fields_for_list --- src/tests/test_mailchimp.py | 74 ++++++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/src/tests/test_mailchimp.py b/src/tests/test_mailchimp.py index a71329cc..23947e31 100644 --- a/src/tests/test_mailchimp.py +++ b/src/tests/test_mailchimp.py @@ -359,8 +359,7 @@ def test_get_merge_fields_data_type_map_success(self, mock_paginate): result = self.test_client.get_merge_fields_data_type_map(list_id) mock_paginate.assert_called_once_with( - base_endpoint=f"lists/{list_id}/merge-fields", - data_key="merge_fields" + base_endpoint=f"lists/{list_id}/merge-fields", data_key="merge_fields" ) expected_map = {"FNAME": "text", "LNAME": "text", "VAN_ID": "number"} @@ -378,9 +377,74 @@ def test_get_merge_fields_data_type_map_fail(self, mock_paginate): # paginate_endpoint called once mock_paginate.assert_called_once_with( - base_endpoint=f"lists/{list_id}/merge-fields", - data_key="merge_fields" + base_endpoint=f"lists/{list_id}/merge-fields", data_key="merge_fields" ) # empty dict for result - self.assertEqual(result, {}) \ No newline at end of file + self.assertEqual(result, {}) + + @patch.object(MailChimpClient, "get_merge_fields_data_type_map") + def test_format_merge_fields_for_list_skips_blank_and_none(self, mock_get_types): + """Test that None and blank strings are ignored""" + mock_get_types.return_value = {"FNAME": "text", "LNAME": "text"} + + merge_fields = {"FNAME": None, "LNAME": " "} + result = self.test_client.format_merge_fields_for_list("llll", merge_fields) + # should be an empty dict + self.assertEqual(result, {}) + + @patch.object(MailChimpClient, "get_merge_fields_data_type_map") + def test_format_merge_fields_for_list_text_success(self, mock_get_types): + """Test that text fields are handled correctly.""" + mock_get_types.return_value = {"FNAME": "text", "LNAME": "text"} + + merge_fields = {"FNAME": " John ", "LNAME": "Doe"} + result = self.test_client.format_merge_fields_for_list("oapao1", merge_fields) + + mock_get_types.assert_called_once_with("oapao1") + self.assertEqual(result, {"FNAME": "John", "LNAME": "Doe"}) + + @patch.object(MailChimpClient, "get_merge_fields_data_type_map") + def test_format_merge_fields_for_list_zip_fail(self, mock_get_types): + """Test that zip type over 5 digits raise error""" + mock_get_types.return_value = {"ZIP": "zip"} + + merge_fields = {"ZIP": "1111122"} + + with self.assertRaises(ValueError): + self.test_client.format_merge_fields_for_list("some_id", merge_fields) + + @patch.object(MailChimpClient, "get_merge_fields_data_type_map") + def test_format_merge_fields_for_list_with_helpers(self, mock_get_types): + """Test that date, birthday, address, and number fields are pushed to the correct helper function""" + mock_get_types.return_value = { + "DATE": "date", + "BIRTHDAY": "birthday", + "ADDRESS": "address", + "NUMBER": "number", + } + + # replace methods with mocks + with patch.object(self.test_client, "format_date") as mock_date, patch.object( + self.test_client, "format_birthday" + ) as mock_bday, patch.object( + self.test_client, + "format_address", + ) as mock_addr, patch.object( + self.test_client, + "format_number", + ) as mock_num: + # placeholder fake data + merge_fields = { + "DATE": "2022-10-09", + "BIRTHDAY": "10/09", + "ADDRESS": {}, + "NUMBER": 0, + } + + self.test_client.format_merge_fields_for_list("some_id", merge_fields) + # just test if helper methods were called + mock_date.assert_called_once() + mock_bday.assert_called_once() + mock_addr.assert_called_once() + mock_num.assert_called_once() From 81867b0271f596ebedefe461c2979348225eedcf Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Thu, 9 Oct 2025 22:07:45 -0500 Subject: [PATCH 09/23] add success zip test for format_merge_fields_for_list --- src/tests/test_mailchimp.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/tests/test_mailchimp.py b/src/tests/test_mailchimp.py index 23947e31..10a4b931 100644 --- a/src/tests/test_mailchimp.py +++ b/src/tests/test_mailchimp.py @@ -414,6 +414,18 @@ def test_format_merge_fields_for_list_zip_fail(self, mock_get_types): with self.assertRaises(ValueError): self.test_client.format_merge_fields_for_list("some_id", merge_fields) + @patch.object(MailChimpClient, "get_merge_fields_data_type_map") + def test_format_merge_fields_for_list_zip_success(self, mock_get_types): + """Test that zip with exactly 5 digits succeeds""" + mock_get_types.return_value = {"ZIP": "zip"} + + merge_fields = {"ZIP": "11111"} + + result = self.test_client.format_merge_fields_for_list("oapao1", merge_fields) + + mock_get_types.assert_called_once_with("oapao1") + self.assertEqual(result, {"ZIP": "11111"}) + @patch.object(MailChimpClient, "get_merge_fields_data_type_map") def test_format_merge_fields_for_list_with_helpers(self, mock_get_types): """Test that date, birthday, address, and number fields are pushed to the correct helper function""" From 153be04cc38bcc3c1a6610b17d2873bef37641a9 Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Fri, 10 Oct 2025 08:54:21 -0500 Subject: [PATCH 10/23] add return for empty tag list in update_member_tags --- src/stac_utils/mailchimp.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/stac_utils/mailchimp.py b/src/stac_utils/mailchimp.py index df8d118d..024c3496 100644 --- a/src/stac_utils/mailchimp.py +++ b/src/stac_utils/mailchimp.py @@ -152,6 +152,11 @@ def update_member_tags( tag.strip() for tag in (tags or []) if isinstance(tag, str) and tag.strip() ] + # return for empty tag list (MailChimp returns 204 even with an empty tag payload, so this mimics that) + if not cleaned: + logger.info(f"No valid tags provided for email: {email_address}") + return {"status_code": 204, "info": "No valid tags provided"} + subscriber_hash = self.get_subscriber_hash(email_address) url = f"{self.base_url}/lists/{list_id}/members/{subscriber_hash}/tags" From d8022718bbe21045e5de502023473ba8a2e3453b Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Fri, 10 Oct 2025 09:00:50 -0500 Subject: [PATCH 11/23] update docstring for update_member_tags --- src/stac_utils/mailchimp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/stac_utils/mailchimp.py b/src/stac_utils/mailchimp.py index 024c3496..7213df2c 100644 --- a/src/stac_utils/mailchimp.py +++ b/src/stac_utils/mailchimp.py @@ -146,6 +146,7 @@ def update_member_tags( :param email_address: member email address. :param tags: list of exact names of tags to add or remove. :param active: flags whether to add or remove the tags. True adds the tags, and False removes the tags. + :return: dict with the API response. Always includes status_code, and on an empty tag list will include info """ # clean & dedupe tags + skip blanks cleaned = [ From 40c253c005e609f1e6284b6d3636853a6f520fe6 Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Fri, 10 Oct 2025 10:49:28 -0500 Subject: [PATCH 12/23] add logging for pagination completion --- src/stac_utils/mailchimp.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/stac_utils/mailchimp.py b/src/stac_utils/mailchimp.py index 7213df2c..544008cc 100644 --- a/src/stac_utils/mailchimp.py +++ b/src/stac_utils/mailchimp.py @@ -79,7 +79,7 @@ def paginate_endpoint( :param data_key: the expected key in the response dict (i.e "lists", "members"). :param count: number of items to fetch per page (default set to 1000, which is MailChimp's max). :param max_pages: optional parameter to limit the number of pages (can be used for testing) - :return: a list of all collected items from the paginated responses + :return: a list of all collected items from the paginated responses """ results = [] page = 1 @@ -115,6 +115,12 @@ def paginate_endpoint( if offset >= total: break + # include logging flagging completion of pagination and how many total records were fetched + logger.info( + f"Pagination complete for endpoint {base_endpoint}, spanning {page} pages. " + f"Fetched a total of {len(results)} total {data_key} records" + ) + return results @staticmethod From 4ec8a1fb185dd01c4432e696bcb2c511cb395bd5 Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Fri, 10 Oct 2025 13:11:48 -0500 Subject: [PATCH 13/23] add request_with_retry method and remove check_response_for_rate_limit --- src/stac_utils/mailchimp.py | 87 +++- src/tests/test_mailchimp.py | 924 ++++++++++++++++++------------------ 2 files changed, 538 insertions(+), 473 deletions(-) diff --git a/src/stac_utils/mailchimp.py b/src/stac_utils/mailchimp.py index 544008cc..89292f71 100644 --- a/src/stac_utils/mailchimp.py +++ b/src/stac_utils/mailchimp.py @@ -6,6 +6,8 @@ import hashlib from datetime import datetime, date from typing import Any +import time +import random # logging logger = logging.getLogger(__name__) @@ -23,6 +25,8 @@ def __init__(self, api_key: str = None, *args, **kwargs): # see: https://mailchimp.com/developer/marketing/docs/fundamentals/#api-structure self.data_center = self.api_key.split("-")[-1] self.base_url = f"https://{self.data_center}.api.mailchimp.com/3.0" + # set total amount of retries for endpoints + self.max_retries = 3 super().__init__(*args, **kwargs) def create_session(self) -> requests.Session: @@ -51,17 +55,62 @@ def transform_response(self, response: requests.Response, **kwargs) -> dict: data["status_code"] = response.status_code return data - def check_response_for_rate_limit(self, response: requests.Response) -> int: + def request_with_retry( + self, + method: str, + endpoint_url: str, + **kwargs, + ) -> requests.Response: """ - Checks Mailchimp response for rate limit, always returns 1 + This method handles Mailchimp 429 (rate limit / Too Many Requests) responses with binary exponential backoff + + Retries up to max_retries times, applying a random delay between 0 and (2^(i+1)-1) seconds on each attempt - :param response: the HTTP response object returned by requests - :return: always returns 1 + see: https://en.wikipedia.org/wiki/Exponential_backoff + see: https://staclabs.atlassian.net/browse/DATA-4171 + + :param method: HTTP method used ('GET', 'POST', 'PUT',etc) + :param endpoint_url: full API endpoint URL + :param kwargs: any other params accepted by requests (json, etc) + :return: requests response object """ - # added basic logging in this method... - if response.status_code == 429: - logger.warning("Mailchimp rate limit hit (HTTP 429: Too Many Requests)") - return 1 + method = method.upper().strip() + response = None + + for attempt in range(1, self.max_retries + 1): + try: + response = self.session.request( + method=method, url=endpoint_url, **kwargs + ) + except requests.exceptions.RequestException: + continue + + # return response if not 429 + if response.status_code != 429: + return response + + # exponential backoff + delay = random.randint(0, (2 ** (attempt + 1)) - 1) + logger.warning( + f"MailChimp rate limit hit (HTTP 429) for HTTP {method} for endpoint: {endpoint_url}. " + f"Attempt: {attempt}" + ) + + # delay! + time.sleep(delay) + + # exhausted retries (returned 429 every time) + logger.error( + f"All {self.max_retries} retries for HTTP {method} for endpoint {endpoint_url} failed " + ) + + # specific handling if response is still None + if response is None: + raise requests.exceptions.RequestException( + f"All {self.max_retries} retries for HTTP {method} for endpoint {endpoint_url} failed" + ) + + return response def paginate_endpoint( self, @@ -91,7 +140,13 @@ def paginate_endpoint( while True: params = {"count": count, "offset": offset, **kwargs} url = f"{self.base_url}/{base_endpoint}" - response = self.session.get(url, params=params) + + # use request_with_retry + response = self.request_with_retry( + method="GET", + endpoint_url=url, + params=params, + ) data = self.transform_response(response) items = data.get(data_key, []) @@ -173,7 +228,12 @@ def update_member_tags( for tag in cleaned ] } - response = self.session.post(url, json=payload) + # use request_with_retry + response = self.request_with_retry( + method="POST", + endpoint_url=url, + json=payload, + ) return self.transform_response(response) def upsert_member( @@ -232,7 +292,12 @@ def upsert_member( } payload.update(other_params) - response = self.session.put(url, json=payload) + # use request_with_retry + response = self.request_with_retry( + method="PUT", + endpoint_url=url, + json=payload, + ) return self.transform_response(response) def get_merge_fields_data_type_map(self, list_id: str, **kwargs) -> dict[str, str]: diff --git a/src/tests/test_mailchimp.py b/src/tests/test_mailchimp.py index 10a4b931..2ca03faf 100644 --- a/src/tests/test_mailchimp.py +++ b/src/tests/test_mailchimp.py @@ -1,462 +1,462 @@ -import os -import json -import unittest -from unittest.mock import patch, MagicMock -from src.stac_utils.mailchimp import MailChimpClient, logger - - -class TestMailChimpClient(unittest.TestCase): - def setUp(self) -> None: - self.test_client = MailChimpClient(api_key="fake-us9") - self.test_logger = logger - - def test_init_env_keys(self): - """Test that client initializes with environmental keys""" - test_api_key = "hgd1204-us20" - with patch.dict(os.environ, values={"MAILCHIMP_API_KEY": test_api_key}): - test_client = MailChimpClient() - self.assertEqual(test_api_key, test_client.api_key) - # test the extracted data center and base_url - self.assertEqual("us20", test_client.data_center) - self.assertEqual("https://us20.api.mailchimp.com/3.0", test_client.base_url) - - def test_create_session(self): - """Test that API token and content type is set in headers for a Mailchimp session""" - session = self.test_client.create_session() - - # check that the auth tuple is correctly set - self.assertEqual(session.auth, ("anystring", self.test_client.api_key)) - - # check that the "Content-Type" header exists and has a value of "application/json" - self.assertIn("Content-Type", session.headers) - self.assertEqual(session.headers["Content-Type"], "application/json") - - def test_transform_response_valid_json(self): - """Test that response is transformed and includes status code""" - mock_data = {"foo_bar": "spam", "email_address": "fake@none.com"} - mock_response = MagicMock() - mock_response.status_code = 200 - # http response body contains bytes - mock_response.content = json.dumps(mock_data).encode() - mock_response.json.return_value = mock_data - result = self.test_client.transform_response(mock_response) - # make sure output matches expected dict - self.assertEqual( - result, - {"foo_bar": "spam", "email_address": "fake@none.com", "status_code": 200}, - ) - - def test_transform_response_empty_content(self): - """Test that empty content or a 204 response returns only the status code""" - mock_response = MagicMock() - mock_response.status_code = 204 - # no data returned in 204 response - mock_response.content = b"" - result = self.test_client.transform_response(mock_response) - self.assertEqual(result, {"status_code": 204}) - - def test_transform_response_invalid_json(self): - """Test that response for invalid JSON returns an empty dict with the status code""" - mock_response = MagicMock() - mock_response.status_code = 500 - mock_response.content = b"not a valid json" - # bad json - mock_response.json.side_effect = json.decoder.JSONDecodeError( - "error", "not a valid json", 0 - ) - result = self.test_client.transform_response(mock_response) - self.assertEqual(result, {"status_code": 500}) - - @patch.object(logger, "warning") - def test_check_response_for_rate_limit(self, mock_warning): - """Test that check_response_for_rate_limit always returns 1 and actually logs warning on 429""" - mock_response_valid = MagicMock() - mock_response_valid.status_code = 200 - - mock_response_limit = MagicMock() - mock_response_limit.status_code = 429 - - # should always return 1 - result_valid = self.test_client.check_response_for_rate_limit( - mock_response_valid - ) - result_limit = self.test_client.check_response_for_rate_limit( - mock_response_limit - ) - - self.assertEqual(result_valid, 1) - self.assertEqual(result_limit, 1) - - # verify that the logger warning called once (occurs when 429) - mock_warning.assert_called_once_with( - "Mailchimp rate limit hit (HTTP 429: Too Many Requests)" - ) - - @patch.object(MailChimpClient, "transform_response") - @patch.object(logger, "debug") - @patch("requests.Session.get") - def test_paginate_endpoint_valid(self, mock_get, mock_debug, mock_transform): - """Test that paginate_endpoint correctly aggregates results across multiple pages""" - # set pages - mock_transform.side_effect = [ - {"members": [{"id": "1"}, {"id": "2"}], "total_items": 4}, - {"members": [{"id": "3"}, {"id": "4"}], "total_items": 4}, - ] - - # set http calls - fake_response_1 = MagicMock() - fake_response_2 = MagicMock() - mock_get.side_effect = [fake_response_1, fake_response_2] - - results = self.test_client.paginate_endpoint( - base_endpoint="lists/898/members", - data_key="members", - count=2, - max_pages=2, - ) - # all data in list - self.assertEqual(results, [{"id": "1"}, {"id": "2"}, {"id": "3"}, {"id": "4"}]) - # two calls - self.assertEqual(mock_get.call_count, 2) - # debug not called - mock_debug.assert_not_called() - - @patch.object(MailChimpClient, "transform_response") - @patch.object(logger, "debug") - @patch("requests.Session.get") - def test_paginate_endpoint_debug_logs_when_empty( - self, mock_get, mock_debug, mock_transform - ): - """Test that paginate_endpoint logs debug message and stops when no items are found""" - # mock first page has data, second page empty (triggers debug...) - mock_transform.side_effect = [ - {"members": [{"id": "1"}, {"id": "2"}], "total_items": 4}, - {"members": []}, - ] - - # set HTTP calls - fake_response_1 = MagicMock() - fake_response_2 = MagicMock() - mock_get.side_effect = [fake_response_1, fake_response_2] - - self.test_client.paginate_endpoint( - base_endpoint="lists/898/members", - data_key="members", - count=2, - ) - - # should log debug once when second page is empty - mock_debug.assert_called_once_with( - "No items found at offset 2 for key 'members'" - ) - # two calls - self.assertEqual(mock_get.call_count, 2) - - @patch.object(MailChimpClient, "transform_response") - @patch.object(logger, "debug") - @patch("requests.Session.get") - def test_paginate_endpoint_stops_when_total_items_reached( - self, mock_get, mock_debug, mock_transform - ): - """Test that paginate_endpoint stops paginating when offset >= total_items""" - mock_transform.side_effect = [ - {"members": [{"id": "1"}, {"id": "2"}], "total_items": 3}, - {"members": [{"id": "3"}], "total_items": 3}, - ] - - # set HTTP calls - fake_response_1 = MagicMock() - fake_response_2 = MagicMock() - mock_get.side_effect = [fake_response_1, fake_response_2] - - results = self.test_client.paginate_endpoint( - base_endpoint="lists/010/members", - data_key="members", - count=2, - ) - - # 3 total members - self.assertEqual(results, [{"id": "1"}, {"id": "2"}, {"id": "3"}]) - # two calls - self.assertEqual(mock_get.call_count, 2) - # debug not called - mock_debug.assert_not_called() - - def test_get_subscriber_hash(self): - """Test that get_subscriber_hash returns correct md5 hash and normalizes to lowercase""" - email = "wEiRd@staclabs.coM" - expected_subscriber_hash = "67441e845c03ceda61635c3263393515" - - result = MailChimpClient.get_subscriber_hash(email) - self.assertEqual(result, expected_subscriber_hash) - - # lowercase version - self.assertEqual( - result, MailChimpClient.get_subscriber_hash("weird@staclabs.com") - ) - - @patch.object(MailChimpClient, "transform_response") - @patch("requests.Session.post") - def test_update_member_tags_success_active(self, mock_post, mock_transform): - """Test that update_member_tags correctly handles a 204 MailChimp success response for adding tags""" - fake_response = MagicMock() - fake_response.status_code = 204 - fake_response.content = b"" - mock_post.return_value = fake_response - - mock_transform.return_value = {"status_code": 204} - - result = self.test_client.update_member_tags( - list_id="102930al", - email_address="fake@none.com", - tags=["NEWS ", "DONOR", " "], - active=True, - ) - - expected_hash = self.test_client.get_subscriber_hash("fake@none.com") - expected_url = f"https://us9.api.mailchimp.com/3.0/lists/102930al/members/{expected_hash}/tags" - - mock_post.assert_called_once_with( - expected_url, - json={ - "tags": [ - {"name": "NEWS", "status": "active"}, - {"name": "DONOR", "status": "active"}, - ] - }, - ) - - # transform_response called once - mock_transform.assert_called_once_with(fake_response) - - # no body in this call, just the status code - self.assertEqual(result, {"status_code": 204}) - - @patch.object(MailChimpClient, "transform_response") - @patch("requests.Session.post") - def test_update_member_tags_success_inactive(self, mock_post, mock_transform): - """Test that update_member_tags correctly handles a 204 MailChimp success response for removing tags""" - fake_response = MagicMock() - fake_response.status_code = 204 - fake_response.content = b"" - mock_post.return_value = fake_response - - mock_transform.return_value = {"status_code": 204} - - result = self.test_client.update_member_tags( - list_id="102930al", - email_address="fake@none.com", - tags=["NEWS ", "DONOR", " "], - active=False, - ) - - expected_hash = self.test_client.get_subscriber_hash("fake@none.com") - expected_url = f"https://us9.api.mailchimp.com/3.0/lists/102930al/members/{expected_hash}/tags" - - mock_post.assert_called_once_with( - expected_url, - json={ - "tags": [ - {"name": "NEWS", "status": "inactive"}, - {"name": "DONOR", "status": "inactive"}, - ] - }, - ) - - # transform_response called once - mock_transform.assert_called_once_with(fake_response) - - # no body in this call, just the status code - self.assertEqual(result, {"status_code": 204}) - - # have to include this, as otherwise tests will fail as no datatype mapping - @patch.object( - MailChimpClient, - "get_merge_fields_data_type_map", - return_value={"FNAME": "text", "LNAME": "text"}, - ) - @patch.object(MailChimpClient, "transform_response") - @patch("requests.Session.put") - def test_upsert_member_success( - self, mock_put, mock_transform, mock_merge_fields_map - ): - """Test that upsert_member sends correct payload and handles MailChimp 200 JSON response""" - - fake_response = MagicMock() - fake_response.status_code = 200 - - mock_put.return_value = fake_response - mock_transform.return_value = { - "id": "8121stac", - "email_address": "fake@none.com", - "status_code": 200, - } - - list_id = "102930al" - email_address = "fake@none.com" - merge_fields = {"FNAME": "Fake", "LNAME": "Dude"} - - result = self.test_client.upsert_member( - list_id=list_id, email_address=email_address, merge_fields=merge_fields - ) - - expected_hash = self.test_client.get_subscriber_hash(email_address) - expected_url = ( - f"https://us9.api.mailchimp.com/3.0/lists/{list_id}/members/{expected_hash}" - ) - - expected_payload = { - "email_address": email_address, - "status_if_new": "subscribed", - "merge_fields": merge_fields, - } - # put called once - mock_put.assert_called_once_with(expected_url, json=expected_payload) - # return is called once - mock_transform.assert_called_once_with(fake_response) - # success - self.assertEqual(result["status_code"], 200) - # compare final email val to expected - self.assertEqual(result["email_address"], "fake@none.com") - - # have to include this, as otherwise tests will fail as no datatype mapping - @patch.object( - MailChimpClient, - "get_merge_fields_data_type_map", - return_value={"FNAME": "text"}, - ) - @patch.object(MailChimpClient, "transform_response") - @patch("requests.Session.put") - def test_upsert_member_fail(self, mock_put, mock_transform, mock_merge_fields_map): - """Test that upsert_member raises error when merge fields contain fake tags""" - list_id = "102930al" - email_address = "fake@none.com" - - # test KeyError is raised - with self.assertRaises(KeyError): - self.test_client.upsert_member( - list_id=list_id, - email_address=email_address, - merge_fields={"FAKE_MERGE_TAG": "some_val"}, - ) - - # no put call for upsert should have occurred - mock_put.assert_not_called() - # no transform_response call for upsert should have occurred - mock_transform.assert_not_called() - - @patch.object(MailChimpClient, "paginate_endpoint") - def test_get_merge_fields_data_type_map_success(self, mock_paginate): - """Test that get_merge_fields_data_type_map returns correct mapping on success""" - mock_paginate.return_value = [ - {"tag": "FNAME", "type": "text"}, - {"tag": "LNAME", "type": "text"}, - {"tag": "VAN_ID", "type": "number"}, - ] - - list_id = "98798798h" - - result = self.test_client.get_merge_fields_data_type_map(list_id) - - mock_paginate.assert_called_once_with( - base_endpoint=f"lists/{list_id}/merge-fields", data_key="merge_fields" - ) - - expected_map = {"FNAME": "text", "LNAME": "text", "VAN_ID": "number"} - self.assertEqual(result, expected_map) - - @patch.object(MailChimpClient, "paginate_endpoint") - def test_get_merge_fields_data_type_map_fail(self, mock_paginate): - """Test that get_merge_fields_data_type_map returns empty dict fail""" - - # paginate_endpoint returns no data - mock_paginate.return_value = [{}] - - list_id = "zzzz" - result = self.test_client.get_merge_fields_data_type_map(list_id) - - # paginate_endpoint called once - mock_paginate.assert_called_once_with( - base_endpoint=f"lists/{list_id}/merge-fields", data_key="merge_fields" - ) - - # empty dict for result - self.assertEqual(result, {}) - - @patch.object(MailChimpClient, "get_merge_fields_data_type_map") - def test_format_merge_fields_for_list_skips_blank_and_none(self, mock_get_types): - """Test that None and blank strings are ignored""" - mock_get_types.return_value = {"FNAME": "text", "LNAME": "text"} - - merge_fields = {"FNAME": None, "LNAME": " "} - result = self.test_client.format_merge_fields_for_list("llll", merge_fields) - # should be an empty dict - self.assertEqual(result, {}) - - @patch.object(MailChimpClient, "get_merge_fields_data_type_map") - def test_format_merge_fields_for_list_text_success(self, mock_get_types): - """Test that text fields are handled correctly.""" - mock_get_types.return_value = {"FNAME": "text", "LNAME": "text"} - - merge_fields = {"FNAME": " John ", "LNAME": "Doe"} - result = self.test_client.format_merge_fields_for_list("oapao1", merge_fields) - - mock_get_types.assert_called_once_with("oapao1") - self.assertEqual(result, {"FNAME": "John", "LNAME": "Doe"}) - - @patch.object(MailChimpClient, "get_merge_fields_data_type_map") - def test_format_merge_fields_for_list_zip_fail(self, mock_get_types): - """Test that zip type over 5 digits raise error""" - mock_get_types.return_value = {"ZIP": "zip"} - - merge_fields = {"ZIP": "1111122"} - - with self.assertRaises(ValueError): - self.test_client.format_merge_fields_for_list("some_id", merge_fields) - - @patch.object(MailChimpClient, "get_merge_fields_data_type_map") - def test_format_merge_fields_for_list_zip_success(self, mock_get_types): - """Test that zip with exactly 5 digits succeeds""" - mock_get_types.return_value = {"ZIP": "zip"} - - merge_fields = {"ZIP": "11111"} - - result = self.test_client.format_merge_fields_for_list("oapao1", merge_fields) - - mock_get_types.assert_called_once_with("oapao1") - self.assertEqual(result, {"ZIP": "11111"}) - - @patch.object(MailChimpClient, "get_merge_fields_data_type_map") - def test_format_merge_fields_for_list_with_helpers(self, mock_get_types): - """Test that date, birthday, address, and number fields are pushed to the correct helper function""" - mock_get_types.return_value = { - "DATE": "date", - "BIRTHDAY": "birthday", - "ADDRESS": "address", - "NUMBER": "number", - } - - # replace methods with mocks - with patch.object(self.test_client, "format_date") as mock_date, patch.object( - self.test_client, "format_birthday" - ) as mock_bday, patch.object( - self.test_client, - "format_address", - ) as mock_addr, patch.object( - self.test_client, - "format_number", - ) as mock_num: - # placeholder fake data - merge_fields = { - "DATE": "2022-10-09", - "BIRTHDAY": "10/09", - "ADDRESS": {}, - "NUMBER": 0, - } - - self.test_client.format_merge_fields_for_list("some_id", merge_fields) - # just test if helper methods were called - mock_date.assert_called_once() - mock_bday.assert_called_once() - mock_addr.assert_called_once() - mock_num.assert_called_once() +# import os +# import json +# import unittest +# from unittest.mock import patch, MagicMock +# from src.stac_utils.mailchimp import MailChimpClient, logger +# +# +# class TestMailChimpClient(unittest.TestCase): +# def setUp(self) -> None: +# self.test_client = MailChimpClient(api_key="fake-us9") +# self.test_logger = logger +# +# def test_init_env_keys(self): +# """Test that client initializes with environmental keys""" +# test_api_key = "hgd1204-us20" +# with patch.dict(os.environ, values={"MAILCHIMP_API_KEY": test_api_key}): +# test_client = MailChimpClient() +# self.assertEqual(test_api_key, test_client.api_key) +# # test the extracted data center and base_url +# self.assertEqual("us20", test_client.data_center) +# self.assertEqual("https://us20.api.mailchimp.com/3.0", test_client.base_url) +# +# def test_create_session(self): +# """Test that API token and content type is set in headers for a Mailchimp session""" +# session = self.test_client.create_session() +# +# # check that the auth tuple is correctly set +# self.assertEqual(session.auth, ("anystring", self.test_client.api_key)) +# +# # check that the "Content-Type" header exists and has a value of "application/json" +# self.assertIn("Content-Type", session.headers) +# self.assertEqual(session.headers["Content-Type"], "application/json") +# +# def test_transform_response_valid_json(self): +# """Test that response is transformed and includes status code""" +# mock_data = {"foo_bar": "spam", "email_address": "fake@none.com"} +# mock_response = MagicMock() +# mock_response.status_code = 200 +# # http response body contains bytes +# mock_response.content = json.dumps(mock_data).encode() +# mock_response.json.return_value = mock_data +# result = self.test_client.transform_response(mock_response) +# # make sure output matches expected dict +# self.assertEqual( +# result, +# {"foo_bar": "spam", "email_address": "fake@none.com", "status_code": 200}, +# ) +# +# def test_transform_response_empty_content(self): +# """Test that empty content or a 204 response returns only the status code""" +# mock_response = MagicMock() +# mock_response.status_code = 204 +# # no data returned in 204 response +# mock_response.content = b"" +# result = self.test_client.transform_response(mock_response) +# self.assertEqual(result, {"status_code": 204}) +# +# def test_transform_response_invalid_json(self): +# """Test that response for invalid JSON returns an empty dict with the status code""" +# mock_response = MagicMock() +# mock_response.status_code = 500 +# mock_response.content = b"not a valid json" +# # bad json +# mock_response.json.side_effect = json.decoder.JSONDecodeError( +# "error", "not a valid json", 0 +# ) +# result = self.test_client.transform_response(mock_response) +# self.assertEqual(result, {"status_code": 500}) +# +# # @patch.object(logger, "warning") +# # def test_check_response_for_rate_limit(self, mock_warning): +# # """Test that check_response_for_rate_limit always returns 1 and actually logs warning on 429""" +# # mock_response_valid = MagicMock() +# # mock_response_valid.status_code = 200 +# # +# # mock_response_limit = MagicMock() +# # mock_response_limit.status_code = 429 +# # +# # # should always return 1 +# # result_valid = self.test_client.check_response_for_rate_limit( +# # mock_response_valid +# # ) +# # result_limit = self.test_client.check_response_for_rate_limit( +# # mock_response_limit +# # ) +# # +# # self.assertEqual(result_valid, 1) +# # self.assertEqual(result_limit, 1) +# # +# # # verify that the logger warning called once (occurs when 429) +# # mock_warning.assert_called_once_with( +# # "Mailchimp rate limit hit (HTTP 429: Too Many Requests)" +# # ) +# +# @patch.object(MailChimpClient, "transform_response") +# @patch.object(logger, "debug") +# @patch("requests.Session.get") +# def test_paginate_endpoint_valid(self, mock_get, mock_debug, mock_transform): +# """Test that paginate_endpoint correctly aggregates results across multiple pages""" +# # set pages +# mock_transform.side_effect = [ +# {"members": [{"id": "1"}, {"id": "2"}], "total_items": 4}, +# {"members": [{"id": "3"}, {"id": "4"}], "total_items": 4}, +# ] +# +# # set http calls +# fake_response_1 = MagicMock() +# fake_response_2 = MagicMock() +# mock_get.side_effect = [fake_response_1, fake_response_2] +# +# results = self.test_client.paginate_endpoint( +# base_endpoint="lists/898/members", +# data_key="members", +# count=2, +# max_pages=2, +# ) +# # all data in list +# self.assertEqual(results, [{"id": "1"}, {"id": "2"}, {"id": "3"}, {"id": "4"}]) +# # two calls +# self.assertEqual(mock_get.call_count, 2) +# # debug not called +# mock_debug.assert_not_called() +# +# @patch.object(MailChimpClient, "transform_response") +# @patch.object(logger, "debug") +# @patch("requests.Session.get") +# def test_paginate_endpoint_debug_logs_when_empty( +# self, mock_get, mock_debug, mock_transform +# ): +# """Test that paginate_endpoint logs debug message and stops when no items are found""" +# # mock first page has data, second page empty (triggers debug...) +# mock_transform.side_effect = [ +# {"members": [{"id": "1"}, {"id": "2"}], "total_items": 4}, +# {"members": []}, +# ] +# +# # set HTTP calls +# fake_response_1 = MagicMock() +# fake_response_2 = MagicMock() +# mock_get.side_effect = [fake_response_1, fake_response_2] +# +# self.test_client.paginate_endpoint( +# base_endpoint="lists/898/members", +# data_key="members", +# count=2, +# ) +# +# # should log debug once when second page is empty +# mock_debug.assert_called_once_with( +# "No items found at offset 2 for key 'members'" +# ) +# # two calls +# self.assertEqual(mock_get.call_count, 2) +# +# @patch.object(MailChimpClient, "transform_response") +# @patch.object(logger, "debug") +# @patch("requests.Session.get") +# def test_paginate_endpoint_stops_when_total_items_reached( +# self, mock_get, mock_debug, mock_transform +# ): +# """Test that paginate_endpoint stops paginating when offset >= total_items""" +# mock_transform.side_effect = [ +# {"members": [{"id": "1"}, {"id": "2"}], "total_items": 3}, +# {"members": [{"id": "3"}], "total_items": 3}, +# ] +# +# # set HTTP calls +# fake_response_1 = MagicMock() +# fake_response_2 = MagicMock() +# mock_get.side_effect = [fake_response_1, fake_response_2] +# +# results = self.test_client.paginate_endpoint( +# base_endpoint="lists/010/members", +# data_key="members", +# count=2, +# ) +# +# # 3 total members +# self.assertEqual(results, [{"id": "1"}, {"id": "2"}, {"id": "3"}]) +# # two calls +# self.assertEqual(mock_get.call_count, 2) +# # debug not called +# mock_debug.assert_not_called() +# +# def test_get_subscriber_hash(self): +# """Test that get_subscriber_hash returns correct md5 hash and normalizes to lowercase""" +# email = "wEiRd@staclabs.coM" +# expected_subscriber_hash = "67441e845c03ceda61635c3263393515" +# +# result = MailChimpClient.get_subscriber_hash(email) +# self.assertEqual(result, expected_subscriber_hash) +# +# # lowercase version +# self.assertEqual( +# result, MailChimpClient.get_subscriber_hash("weird@staclabs.com") +# ) +# +# @patch.object(MailChimpClient, "transform_response") +# @patch("requests.Session.post") +# def test_update_member_tags_success_active(self, mock_post, mock_transform): +# """Test that update_member_tags correctly handles a 204 MailChimp success response for adding tags""" +# fake_response = MagicMock() +# fake_response.status_code = 204 +# fake_response.content = b"" +# mock_post.return_value = fake_response +# +# mock_transform.return_value = {"status_code": 204} +# +# result = self.test_client.update_member_tags( +# list_id="102930al", +# email_address="fake@none.com", +# tags=["NEWS ", "DONOR", " "], +# active=True, +# ) +# +# expected_hash = self.test_client.get_subscriber_hash("fake@none.com") +# expected_url = f"https://us9.api.mailchimp.com/3.0/lists/102930al/members/{expected_hash}/tags" +# +# mock_post.assert_called_once_with( +# expected_url, +# json={ +# "tags": [ +# {"name": "NEWS", "status": "active"}, +# {"name": "DONOR", "status": "active"}, +# ] +# }, +# ) +# +# # transform_response called once +# mock_transform.assert_called_once_with(fake_response) +# +# # no body in this call, just the status code +# self.assertEqual(result, {"status_code": 204}) +# +# @patch.object(MailChimpClient, "transform_response") +# @patch("requests.Session.post") +# def test_update_member_tags_success_inactive(self, mock_post, mock_transform): +# """Test that update_member_tags correctly handles a 204 MailChimp success response for removing tags""" +# fake_response = MagicMock() +# fake_response.status_code = 204 +# fake_response.content = b"" +# mock_post.return_value = fake_response +# +# mock_transform.return_value = {"status_code": 204} +# +# result = self.test_client.update_member_tags( +# list_id="102930al", +# email_address="fake@none.com", +# tags=["NEWS ", "DONOR", " "], +# active=False, +# ) +# +# expected_hash = self.test_client.get_subscriber_hash("fake@none.com") +# expected_url = f"https://us9.api.mailchimp.com/3.0/lists/102930al/members/{expected_hash}/tags" +# +# mock_post.assert_called_once_with( +# expected_url, +# json={ +# "tags": [ +# {"name": "NEWS", "status": "inactive"}, +# {"name": "DONOR", "status": "inactive"}, +# ] +# }, +# ) +# +# # transform_response called once +# mock_transform.assert_called_once_with(fake_response) +# +# # no body in this call, just the status code +# self.assertEqual(result, {"status_code": 204}) +# +# # have to include this, as otherwise tests will fail as no datatype mapping +# @patch.object( +# MailChimpClient, +# "get_merge_fields_data_type_map", +# return_value={"FNAME": "text", "LNAME": "text"}, +# ) +# @patch.object(MailChimpClient, "transform_response") +# @patch("requests.Session.put") +# def test_upsert_member_success( +# self, mock_put, mock_transform, mock_merge_fields_map +# ): +# """Test that upsert_member sends correct payload and handles MailChimp 200 JSON response""" +# +# fake_response = MagicMock() +# fake_response.status_code = 200 +# +# mock_put.return_value = fake_response +# mock_transform.return_value = { +# "id": "8121stac", +# "email_address": "fake@none.com", +# "status_code": 200, +# } +# +# list_id = "102930al" +# email_address = "fake@none.com" +# merge_fields = {"FNAME": "Fake", "LNAME": "Dude"} +# +# result = self.test_client.upsert_member( +# list_id=list_id, email_address=email_address, merge_fields=merge_fields +# ) +# +# expected_hash = self.test_client.get_subscriber_hash(email_address) +# expected_url = ( +# f"https://us9.api.mailchimp.com/3.0/lists/{list_id}/members/{expected_hash}" +# ) +# +# expected_payload = { +# "email_address": email_address, +# "status_if_new": "subscribed", +# "merge_fields": merge_fields, +# } +# # put called once +# mock_put.assert_called_once_with(expected_url, json=expected_payload) +# # return is called once +# mock_transform.assert_called_once_with(fake_response) +# # success +# self.assertEqual(result["status_code"], 200) +# # compare final email val to expected +# self.assertEqual(result["email_address"], "fake@none.com") +# +# # have to include this, as otherwise tests will fail as no datatype mapping +# @patch.object( +# MailChimpClient, +# "get_merge_fields_data_type_map", +# return_value={"FNAME": "text"}, +# ) +# @patch.object(MailChimpClient, "transform_response") +# @patch("requests.Session.put") +# def test_upsert_member_fail(self, mock_put, mock_transform, mock_merge_fields_map): +# """Test that upsert_member raises error when merge fields contain fake tags""" +# list_id = "102930al" +# email_address = "fake@none.com" +# +# # test KeyError is raised +# with self.assertRaises(KeyError): +# self.test_client.upsert_member( +# list_id=list_id, +# email_address=email_address, +# merge_fields={"FAKE_MERGE_TAG": "some_val"}, +# ) +# +# # no put call for upsert should have occurred +# mock_put.assert_not_called() +# # no transform_response call for upsert should have occurred +# mock_transform.assert_not_called() +# +# @patch.object(MailChimpClient, "paginate_endpoint") +# def test_get_merge_fields_data_type_map_success(self, mock_paginate): +# """Test that get_merge_fields_data_type_map returns correct mapping on success""" +# mock_paginate.return_value = [ +# {"tag": "FNAME", "type": "text"}, +# {"tag": "LNAME", "type": "text"}, +# {"tag": "VAN_ID", "type": "number"}, +# ] +# +# list_id = "98798798h" +# +# result = self.test_client.get_merge_fields_data_type_map(list_id) +# +# mock_paginate.assert_called_once_with( +# base_endpoint=f"lists/{list_id}/merge-fields", data_key="merge_fields" +# ) +# +# expected_map = {"FNAME": "text", "LNAME": "text", "VAN_ID": "number"} +# self.assertEqual(result, expected_map) +# +# @patch.object(MailChimpClient, "paginate_endpoint") +# def test_get_merge_fields_data_type_map_fail(self, mock_paginate): +# """Test that get_merge_fields_data_type_map returns empty dict fail""" +# +# # paginate_endpoint returns no data +# mock_paginate.return_value = [{}] +# +# list_id = "zzzz" +# result = self.test_client.get_merge_fields_data_type_map(list_id) +# +# # paginate_endpoint called once +# mock_paginate.assert_called_once_with( +# base_endpoint=f"lists/{list_id}/merge-fields", data_key="merge_fields" +# ) +# +# # empty dict for result +# self.assertEqual(result, {}) +# +# @patch.object(MailChimpClient, "get_merge_fields_data_type_map") +# def test_format_merge_fields_for_list_skips_blank_and_none(self, mock_get_types): +# """Test that None and blank strings are ignored""" +# mock_get_types.return_value = {"FNAME": "text", "LNAME": "text"} +# +# merge_fields = {"FNAME": None, "LNAME": " "} +# result = self.test_client.format_merge_fields_for_list("llll", merge_fields) +# # should be an empty dict +# self.assertEqual(result, {}) +# +# @patch.object(MailChimpClient, "get_merge_fields_data_type_map") +# def test_format_merge_fields_for_list_text_success(self, mock_get_types): +# """Test that text fields are handled correctly.""" +# mock_get_types.return_value = {"FNAME": "text", "LNAME": "text"} +# +# merge_fields = {"FNAME": " John ", "LNAME": "Doe"} +# result = self.test_client.format_merge_fields_for_list("oapao1", merge_fields) +# +# mock_get_types.assert_called_once_with("oapao1") +# self.assertEqual(result, {"FNAME": "John", "LNAME": "Doe"}) +# +# @patch.object(MailChimpClient, "get_merge_fields_data_type_map") +# def test_format_merge_fields_for_list_zip_fail(self, mock_get_types): +# """Test that zip type over 5 digits raise error""" +# mock_get_types.return_value = {"ZIP": "zip"} +# +# merge_fields = {"ZIP": "1111122"} +# +# with self.assertRaises(ValueError): +# self.test_client.format_merge_fields_for_list("some_id", merge_fields) +# +# @patch.object(MailChimpClient, "get_merge_fields_data_type_map") +# def test_format_merge_fields_for_list_zip_success(self, mock_get_types): +# """Test that zip with exactly 5 digits succeeds""" +# mock_get_types.return_value = {"ZIP": "zip"} +# +# merge_fields = {"ZIP": "11111"} +# +# result = self.test_client.format_merge_fields_for_list("oapao1", merge_fields) +# +# mock_get_types.assert_called_once_with("oapao1") +# self.assertEqual(result, {"ZIP": "11111"}) +# +# @patch.object(MailChimpClient, "get_merge_fields_data_type_map") +# def test_format_merge_fields_for_list_with_helpers(self, mock_get_types): +# """Test that date, birthday, address, and number fields are pushed to the correct helper function""" +# mock_get_types.return_value = { +# "DATE": "date", +# "BIRTHDAY": "birthday", +# "ADDRESS": "address", +# "NUMBER": "number", +# } +# +# # replace methods with mocks +# with patch.object(self.test_client, "format_date") as mock_date, patch.object( +# self.test_client, "format_birthday" +# ) as mock_bday, patch.object( +# self.test_client, +# "format_address", +# ) as mock_addr, patch.object( +# self.test_client, +# "format_number", +# ) as mock_num: +# # placeholder fake data +# merge_fields = { +# "DATE": "2022-10-09", +# "BIRTHDAY": "10/09", +# "ADDRESS": {}, +# "NUMBER": 0, +# } +# +# self.test_client.format_merge_fields_for_list("some_id", merge_fields) +# # just test if helper methods were called +# mock_date.assert_called_once() +# mock_bday.assert_called_once() +# mock_addr.assert_called_once() +# mock_num.assert_called_once() From ee73132fc84cc70e63548afbe0746e4202ce4894 Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Fri, 10 Oct 2025 14:07:55 -0500 Subject: [PATCH 14/23] formatting mailchimp.py --- src/stac_utils/mailchimp.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/stac_utils/mailchimp.py b/src/stac_utils/mailchimp.py index 89292f71..be186d8d 100644 --- a/src/stac_utils/mailchimp.py +++ b/src/stac_utils/mailchimp.py @@ -15,7 +15,7 @@ class MailChimpClient(HTTPClient): """ - Mailchimp Utils for working with the Mailchimp API + MailChimp Utils for working with the MailChimp API """ def __init__(self, api_key: str = None, *args, **kwargs): @@ -30,7 +30,7 @@ def __init__(self, api_key: str = None, *args, **kwargs): super().__init__(*args, **kwargs) def create_session(self) -> requests.Session: - """Creates Mailchimp session""" + """Creates MailChimp session""" session = requests.Session() # https://mailchimp.com/developer/marketing/docs/fundamentals/#api-structure session.auth = ("anystring", self.api_key) @@ -62,7 +62,7 @@ def request_with_retry( **kwargs, ) -> requests.Response: """ - This method handles Mailchimp 429 (rate limit / Too Many Requests) responses with binary exponential backoff + This method handles MailChimp 429 (rate limit / Too Many Requests) responses with binary exponential backoff Retries up to max_retries times, applying a random delay between 0 and (2^(i+1)-1) seconds on each attempt @@ -121,7 +121,7 @@ def paginate_endpoint( **kwargs, ) -> list[dict]: """ - Generic pagination helper for Mailchimp endpoints that return + Generic pagination helper for MailChimp endpoints that return collections (i.e., lists, members, campaigns, etc.). :param base_endpoint: the endpoint to paginate (i.e "lists" or "lists/{list_id}/members"). @@ -181,7 +181,7 @@ def paginate_endpoint( @staticmethod def get_subscriber_hash(email: str) -> str: """ - Return the Mailchimp subscriber hash for a given email. + Return the MailChimp subscriber hash for a given email. This is the unique identifier of the member, scoped to a given audience_id (list_id) :param email: the email to get the subscriber hash (MailChimp member id) for @@ -345,7 +345,7 @@ def format_merge_fields_for_list( data_type = merge_fields_data_type_map.get(tag) - # raise error if unknown tags (all tags should be valid) + # raise error if unknown merge tags (all merge tags should be valid) if data_type is None: raise KeyError(f"Unknown merge tag for this audience: {list_id}: {tag}") From 35f4cc39ad8268cb6e841fef90f84ef186d6e2b2 Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Sat, 11 Oct 2025 11:35:40 -0500 Subject: [PATCH 15/23] add tests for request_with_retry --- src/tests/test_mailchimp.py | 229 +++++++++++++++++++++--------------- 1 file changed, 135 insertions(+), 94 deletions(-) diff --git a/src/tests/test_mailchimp.py b/src/tests/test_mailchimp.py index 2ca03faf..c29fd1f8 100644 --- a/src/tests/test_mailchimp.py +++ b/src/tests/test_mailchimp.py @@ -1,97 +1,138 @@ -# import os -# import json -# import unittest -# from unittest.mock import patch, MagicMock -# from src.stac_utils.mailchimp import MailChimpClient, logger -# -# -# class TestMailChimpClient(unittest.TestCase): -# def setUp(self) -> None: -# self.test_client = MailChimpClient(api_key="fake-us9") -# self.test_logger = logger -# -# def test_init_env_keys(self): -# """Test that client initializes with environmental keys""" -# test_api_key = "hgd1204-us20" -# with patch.dict(os.environ, values={"MAILCHIMP_API_KEY": test_api_key}): -# test_client = MailChimpClient() -# self.assertEqual(test_api_key, test_client.api_key) -# # test the extracted data center and base_url -# self.assertEqual("us20", test_client.data_center) -# self.assertEqual("https://us20.api.mailchimp.com/3.0", test_client.base_url) -# -# def test_create_session(self): -# """Test that API token and content type is set in headers for a Mailchimp session""" -# session = self.test_client.create_session() -# -# # check that the auth tuple is correctly set -# self.assertEqual(session.auth, ("anystring", self.test_client.api_key)) -# -# # check that the "Content-Type" header exists and has a value of "application/json" -# self.assertIn("Content-Type", session.headers) -# self.assertEqual(session.headers["Content-Type"], "application/json") -# -# def test_transform_response_valid_json(self): -# """Test that response is transformed and includes status code""" -# mock_data = {"foo_bar": "spam", "email_address": "fake@none.com"} -# mock_response = MagicMock() -# mock_response.status_code = 200 -# # http response body contains bytes -# mock_response.content = json.dumps(mock_data).encode() -# mock_response.json.return_value = mock_data -# result = self.test_client.transform_response(mock_response) -# # make sure output matches expected dict -# self.assertEqual( -# result, -# {"foo_bar": "spam", "email_address": "fake@none.com", "status_code": 200}, -# ) -# -# def test_transform_response_empty_content(self): -# """Test that empty content or a 204 response returns only the status code""" -# mock_response = MagicMock() -# mock_response.status_code = 204 -# # no data returned in 204 response -# mock_response.content = b"" -# result = self.test_client.transform_response(mock_response) -# self.assertEqual(result, {"status_code": 204}) -# -# def test_transform_response_invalid_json(self): -# """Test that response for invalid JSON returns an empty dict with the status code""" -# mock_response = MagicMock() -# mock_response.status_code = 500 -# mock_response.content = b"not a valid json" -# # bad json -# mock_response.json.side_effect = json.decoder.JSONDecodeError( -# "error", "not a valid json", 0 -# ) -# result = self.test_client.transform_response(mock_response) -# self.assertEqual(result, {"status_code": 500}) -# -# # @patch.object(logger, "warning") -# # def test_check_response_for_rate_limit(self, mock_warning): -# # """Test that check_response_for_rate_limit always returns 1 and actually logs warning on 429""" -# # mock_response_valid = MagicMock() -# # mock_response_valid.status_code = 200 -# # -# # mock_response_limit = MagicMock() -# # mock_response_limit.status_code = 429 -# # -# # # should always return 1 -# # result_valid = self.test_client.check_response_for_rate_limit( -# # mock_response_valid -# # ) -# # result_limit = self.test_client.check_response_for_rate_limit( -# # mock_response_limit -# # ) -# # -# # self.assertEqual(result_valid, 1) -# # self.assertEqual(result_limit, 1) -# # -# # # verify that the logger warning called once (occurs when 429) -# # mock_warning.assert_called_once_with( -# # "Mailchimp rate limit hit (HTTP 429: Too Many Requests)" -# # ) -# +import os +import json +import unittest +from unittest.mock import patch, MagicMock, PropertyMock +from src.stac_utils.mailchimp import MailChimpClient, logger + + +class TestMailChimpClient(unittest.TestCase): + def setUp(self) -> None: + self.test_client = MailChimpClient(api_key="fake-us9") + self.test_logger = logger + + def test_init_env_keys(self): + """Test that client initializes with environmental keys""" + test_api_key = "hgd1204-us20" + with patch.dict(os.environ, values={"MAILCHIMP_API_KEY": test_api_key}): + test_client = MailChimpClient() + self.assertEqual(test_api_key, test_client.api_key) + # test the extracted data center and base_url + self.assertEqual("us20", test_client.data_center) + self.assertEqual("https://us20.api.mailchimp.com/3.0", test_client.base_url) + + def test_create_session(self): + """Test that API token and content type is set in headers for a Mailchimp session""" + session = self.test_client.create_session() + + # check that the auth tuple is correctly set + self.assertEqual(session.auth, ("anystring", self.test_client.api_key)) + + # check that the "Content-Type" header exists and has a value of "application/json" + self.assertIn("Content-Type", session.headers) + self.assertEqual(session.headers["Content-Type"], "application/json") + + def test_transform_response_valid_json(self): + """Test that response is transformed and includes status code""" + mock_data = {"foo_bar": "spam", "email_address": "fake@none.com"} + mock_response = MagicMock() + mock_response.status_code = 200 + # http response body contains bytes + mock_response.content = json.dumps(mock_data).encode() + mock_response.json.return_value = mock_data + result = self.test_client.transform_response(mock_response) + # make sure output matches expected dict + self.assertEqual( + result, + {"foo_bar": "spam", "email_address": "fake@none.com", "status_code": 200}, + ) + + def test_transform_response_empty_content(self): + """Test that empty content or a 204 response returns only the status code""" + mock_response = MagicMock() + mock_response.status_code = 204 + # no data returned in 204 response + mock_response.content = b"" + result = self.test_client.transform_response(mock_response) + self.assertEqual(result, {"status_code": 204}) + + def test_transform_response_invalid_json(self): + """Test that response for invalid JSON returns an empty dict with the status code""" + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.content = b"not a valid json" + # bad json + mock_response.json.side_effect = json.decoder.JSONDecodeError( + "error", "not a valid json", 0 + ) + result = self.test_client.transform_response(mock_response) + self.assertEqual(result, {"status_code": 500}) + + @patch("src.stac_utils.mailchimp.time.sleep") + # note the session is a property in the parent Client class, so can't use MagicMock + @patch.object(MailChimpClient, "session", new_callable=PropertyMock) + def test_request_with_retry_return_success(self, mock_session_property, mock_sleep): + """Successful request should not delay in the loop""" + mock_response = MagicMock(status_code=200) + mock_session = MagicMock() + mock_session.request.return_value = mock_response + + # property returns the mock session + mock_session_property.return_value = mock_session + + response = self.test_client.request_with_retry( + method="GET", endpoint_url="www.fake_endpoint.com/mail" + ) + + # client returns session object as given + self.assertIs(response, mock_response) + # request method called once + mock_session.request.assert_called_once_with( + method="GET", url="www.fake_endpoint.com/mail" + ) + # retry not called + mock_sleep.assert_not_called() + + @patch("src.stac_utils.mailchimp.time.sleep") + # note the session is a property in the parent Client class, so can't use MagicMock + @patch.object(MailChimpClient, "session", new_callable=PropertyMock) + def test_request_with_retry_429_then_success( + self, mock_session_property, mock_sleep + ): + """Should sleep and retry once after a 429 rate limit flag before succeeding""" + + # first response = 429 + mock_response_429 = MagicMock(status_code=429) + # second response = 200 + mock_response_200 = MagicMock(status_code=200) + + # mock session that returns 429 once and then returns 200 + mock_session = MagicMock() + mock_session.request.side_effect = [mock_response_429, mock_response_200] + + # MailChimpClient.session property returns mock session + mock_session_property.return_value = mock_session + + # patching random.randint to return a set delay + with patch( + "src.stac_utils.mailchimp.random.randint", return_value=3 + ) as mock_rand: + response = self.test_client.request_with_retry( + method="GET", endpoint_url="www.fake_endpoint.com/mail" + ) + + # the function should return the 200 response + self.assertIs(response, mock_response_200) + + # .request() method should have been called twice, first 429 then retried (200) + self.assertEqual(mock_session.request.call_count, 2) + + # rand called once + mock_rand.assert_called_once() + + # sleep called once with the set delay + mock_sleep.assert_called_once_with(3) + + # @patch.object(MailChimpClient, "transform_response") # @patch.object(logger, "debug") # @patch("requests.Session.get") From ef2e3f88765252f190abfed1746f6f3478d6447f Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Sat, 11 Oct 2025 13:46:40 -0500 Subject: [PATCH 16/23] add test for max retries hit for request_with_retry --- src/tests/test_mailchimp.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/tests/test_mailchimp.py b/src/tests/test_mailchimp.py index c29fd1f8..3ff1eb25 100644 --- a/src/tests/test_mailchimp.py +++ b/src/tests/test_mailchimp.py @@ -132,6 +132,31 @@ def test_request_with_retry_429_then_success( # sleep called once with the set delay mock_sleep.assert_called_once_with(3) + @patch("src.stac_utils.mailchimp.time.sleep") + @patch.object(MailChimpClient, "session", new_callable=PropertyMock) + def test_request_with_retry_hits_max_retries( + self, mock_session_property, mock_sleep + ): + """Test that function retries max_retries times then returns the last 429 response""" + # one mock per retry attempt + mock_responses = [ + MagicMock(status_code=429) for _ in range(self.test_client.max_retries) + ] + mock_session = MagicMock() + mock_session.request.side_effect = mock_responses + mock_session_property.return_value = mock_session + + with patch("src.stac_utils.mailchimp.random.randint", return_value=3): + response = self.test_client.request_with_retry( + "GET", "www.fake_endpoint.com/mail" + ) + # the final response should be the last of the mock_responses + self.assertIs(response, mock_responses[-1]) + # times the mock object has been called should == the max_retries set + self.assertEqual(mock_session.request.call_count, self.test_client.max_retries) + # check to make sure number of delays == max_retries + self.assertEqual(mock_sleep.call_count, self.test_client.max_retries) + # @patch.object(MailChimpClient, "transform_response") # @patch.object(logger, "debug") From f84331b51e8aae382da0da87843ccb120b0e0513 Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Sat, 11 Oct 2025 14:27:40 -0500 Subject: [PATCH 17/23] add more tests for request_with_retry --- src/tests/test_mailchimp.py | 57 +++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/tests/test_mailchimp.py b/src/tests/test_mailchimp.py index 3ff1eb25..42133492 100644 --- a/src/tests/test_mailchimp.py +++ b/src/tests/test_mailchimp.py @@ -1,6 +1,7 @@ import os import json import unittest +import requests from unittest.mock import patch, MagicMock, PropertyMock from src.stac_utils.mailchimp import MailChimpClient, logger @@ -157,6 +158,62 @@ def test_request_with_retry_hits_max_retries( # check to make sure number of delays == max_retries self.assertEqual(mock_sleep.call_count, self.test_client.max_retries) + @patch("src.stac_utils.mailchimp.time.sleep") + @patch.object(MailChimpClient, "session", new_callable=PropertyMock) + def test_request_with_retry_handles_request_exception( + self, mock_session_property, mock_sleep + ): + """Test when RequestException is raised before success""" + # mock session + mock_session = MagicMock() + mock_response_200 = MagicMock(status_code=200) + # first: raise exception + # second: success + mock_session.request.side_effect = [ + requests.exceptions.RequestException(), + mock_response_200, + ] + mock_session_property.return_value = mock_session + + with patch("src.stac_utils.mailchimp.random.randint", return_value=3): + response = self.test_client.request_with_retry( + method="GET", endpoint_url="www.fake_endpoint.com/mail" + ) + + # final response should be success + self.assertIs(response, mock_response_200) + # function retries once after catching RequestException, and another ends in success + self.assertEqual(mock_session.request.call_count, 2) + # delay not called (only called if 429) + mock_sleep.assert_not_called() + + @patch("src.stac_utils.mailchimp.time.sleep") + @patch.object(MailChimpClient, "session", new_callable=PropertyMock) + def test_request_with_retry_exception_when_complete_failure( + self, mock_session_property, mock_sleep + ): + """Makes sure function raises RequestException when all retries fail with no response""" + mock_session = MagicMock() + # every call raises RequestException + mock_session.request.side_effect = [ + requests.exceptions.RequestException(), + requests.exceptions.RequestException(), + requests.exceptions.RequestException(), + ] + mock_session_property.return_value = mock_session + + with patch("src.stac_utils.mailchimp.random.randint", return_value=3): + # the final call will raise RequestException... + with self.assertRaises(requests.exceptions.RequestException): + self.test_client.request_with_retry( + method="GET", endpoint_url="www.fake_endpoint.com/mail" + ) + + # check to make sure number of delays == max_retries + self.assertEqual(mock_session.request.call_count, self.test_client.max_retries) + # delay not called (only called if 429) + mock_sleep.assert_not_called() + # @patch.object(MailChimpClient, "transform_response") # @patch.object(logger, "debug") From 1181d9d2f7789d37343ef51723b5d2a1d2b31fc2 Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Sat, 11 Oct 2025 14:50:58 -0500 Subject: [PATCH 18/23] add back previously written tests, patching in request_with_retry instead of mocking http directly where relevant --- src/tests/test_mailchimp.py | 749 ++++++++++++++++++------------------ 1 file changed, 380 insertions(+), 369 deletions(-) diff --git a/src/tests/test_mailchimp.py b/src/tests/test_mailchimp.py index 42133492..6ec1e0a3 100644 --- a/src/tests/test_mailchimp.py +++ b/src/tests/test_mailchimp.py @@ -209,377 +209,388 @@ def test_request_with_retry_exception_when_complete_failure( method="GET", endpoint_url="www.fake_endpoint.com/mail" ) - # check to make sure number of delays == max_retries + # check to make sure number of retry attempts == max_retries self.assertEqual(mock_session.request.call_count, self.test_client.max_retries) # delay not called (only called if 429) mock_sleep.assert_not_called() + @patch.object(MailChimpClient, "transform_response") + @patch.object(logger, "debug") + @patch.object(MailChimpClient, "request_with_retry") + def test_paginate_endpoint_valid( + self, mock_request_with_retry, mock_debug, mock_transform + ): + """Test that paginate_endpoint correctly aggregates results across multiple pages""" + # set pages + mock_transform.side_effect = [ + {"members": [{"id": "1"}, {"id": "2"}], "total_items": 4}, + {"members": [{"id": "3"}, {"id": "4"}], "total_items": 4}, + ] + + # set http calls + fake_response_1 = MagicMock() + fake_response_2 = MagicMock() + mock_request_with_retry.side_effect = [fake_response_1, fake_response_2] + + results = self.test_client.paginate_endpoint( + base_endpoint="lists/898/members", + data_key="members", + count=2, + max_pages=2, + ) + # all data in list + self.assertEqual(results, [{"id": "1"}, {"id": "2"}, {"id": "3"}, {"id": "4"}]) + # two calls + self.assertEqual(mock_request_with_retry.call_count, 2) + # debug not called + mock_debug.assert_not_called() + + @patch.object(MailChimpClient, "transform_response") + @patch.object(logger, "debug") + @patch.object(MailChimpClient, "request_with_retry") + def test_paginate_endpoint_debug_logs_when_empty( + self, mock_request_with_retry, mock_debug, mock_transform + ): + """Test that paginate_endpoint logs debug message and stops when no items are found""" + # mock first page has data, second page empty (triggers debug...) + mock_transform.side_effect = [ + {"members": [{"id": "1"}, {"id": "2"}], "total_items": 4}, + {"members": []}, + ] + + # set HTTP calls + fake_response_1 = MagicMock() + fake_response_2 = MagicMock() + mock_request_with_retry.side_effect = [fake_response_1, fake_response_2] + + self.test_client.paginate_endpoint( + base_endpoint="lists/898/members", + data_key="members", + count=2, + ) + + # should log debug once when second page is empty + mock_debug.assert_called_once_with( + "No items found at offset 2 for key 'members'" + ) + # two calls + self.assertEqual(mock_request_with_retry.call_count, 2) + + @patch.object(MailChimpClient, "transform_response") + @patch.object(logger, "debug") + @patch.object(MailChimpClient, "request_with_retry") + def test_paginate_endpoint_stops_when_total_items_reached( + self, mock_request_with_retry, mock_debug, mock_transform + ): + """Test that paginate_endpoint stops paginating when offset >= total_items""" + mock_transform.side_effect = [ + {"members": [{"id": "1"}, {"id": "2"}], "total_items": 3}, + {"members": [{"id": "3"}], "total_items": 3}, + ] + + # set HTTP calls + fake_response_1 = MagicMock() + fake_response_2 = MagicMock() + mock_request_with_retry.side_effect = [fake_response_1, fake_response_2] + + results = self.test_client.paginate_endpoint( + base_endpoint="lists/010/members", + data_key="members", + count=2, + ) + + # 3 total members + self.assertEqual(results, [{"id": "1"}, {"id": "2"}, {"id": "3"}]) + # two calls + self.assertEqual(mock_request_with_retry.call_count, 2) + # debug not called + mock_debug.assert_not_called() + + def test_get_subscriber_hash(self): + """Test that get_subscriber_hash returns correct md5 hash and normalizes to lowercase""" + email = "wEiRd@staclabs.coM" + expected_subscriber_hash = "67441e845c03ceda61635c3263393515" + + result = MailChimpClient.get_subscriber_hash(email) + self.assertEqual(result, expected_subscriber_hash) + + # lowercase version + self.assertEqual( + result, MailChimpClient.get_subscriber_hash("weird@staclabs.com") + ) + + @patch.object(MailChimpClient, "transform_response") + @patch.object(MailChimpClient, "request_with_retry") + def test_update_member_tags_success_active( + self, mock_request_with_retry, mock_transform + ): + """Test that update_member_tags correctly handles a 204 MailChimp success response for adding tags""" + fake_response = MagicMock() + fake_response.status_code = 204 + fake_response.content = b"" + mock_request_with_retry.return_value = fake_response + + mock_transform.return_value = {"status_code": 204} + + result = self.test_client.update_member_tags( + list_id="102930al", + email_address="fake@none.com", + tags=["NEWS ", "DONOR", " "], + active=True, + ) + + expected_hash = self.test_client.get_subscriber_hash("fake@none.com") + expected_url = f"https://us9.api.mailchimp.com/3.0/lists/102930al/members/{expected_hash}/tags" + + mock_request_with_retry.assert_called_once_with( + method="POST", + endpoint_url=expected_url, + json={ + "tags": [ + {"name": "NEWS", "status": "active"}, + {"name": "DONOR", "status": "active"}, + ] + }, + ) + + # transform_response called once + mock_transform.assert_called_once_with(fake_response) + + # no body in this call, just the status code + self.assertEqual(result, {"status_code": 204}) + + @patch.object(MailChimpClient, "transform_response") + @patch.object(MailChimpClient, "request_with_retry") + def test_update_member_tags_success_inactive( + self, mock_request_with_retry, mock_transform + ): + """Test that update_member_tags correctly handles a 204 MailChimp success response for removing tags""" + fake_response = MagicMock() + fake_response.status_code = 204 + fake_response.content = b"" + mock_request_with_retry.return_value = fake_response + + mock_transform.return_value = {"status_code": 204} + + result = self.test_client.update_member_tags( + list_id="102930al", + email_address="fake@none.com", + tags=["NEWS ", "DONOR", " "], + active=False, + ) + + expected_hash = self.test_client.get_subscriber_hash("fake@none.com") + expected_url = f"https://us9.api.mailchimp.com/3.0/lists/102930al/members/{expected_hash}/tags" + + mock_request_with_retry.assert_called_once_with( + method="POST", + endpoint_url=expected_url, + json={ + "tags": [ + {"name": "NEWS", "status": "inactive"}, + {"name": "DONOR", "status": "inactive"}, + ] + }, + ) + + # transform_response called once + mock_transform.assert_called_once_with(fake_response) + + # no body in this call, just the status code + self.assertEqual(result, {"status_code": 204}) + + # have to include this, as otherwise tests will fail as no datatype mapping + @patch.object( + MailChimpClient, + "get_merge_fields_data_type_map", + return_value={"FNAME": "text", "LNAME": "text"}, + ) + @patch.object(MailChimpClient, "transform_response") + @patch.object(MailChimpClient, "request_with_retry") + def test_upsert_member_success( + self, mock_request_with_retry, mock_transform, mock_merge_fields_map + ): + """Test that upsert_member sends correct payload and handles MailChimp 200 JSON response""" + + fake_response = MagicMock() + fake_response.status_code = 200 + + mock_request_with_retry.return_value = fake_response + mock_transform.return_value = { + "id": "8121stac", + "email_address": "fake@none.com", + "status_code": 200, + } + + list_id = "102930al" + email_address = "fake@none.com" + merge_fields = {"FNAME": "Fake", "LNAME": "Dude"} + + result = self.test_client.upsert_member( + list_id=list_id, email_address=email_address, merge_fields=merge_fields + ) + + expected_hash = self.test_client.get_subscriber_hash(email_address) + expected_url = ( + f"https://us9.api.mailchimp.com/3.0/lists/{list_id}/members/{expected_hash}" + ) + + expected_payload = { + "email_address": email_address, + "status_if_new": "subscribed", + "merge_fields": merge_fields, + } + # put called once + mock_request_with_retry.assert_called_once_with( + method="PUT", endpoint_url=expected_url, json=expected_payload + ) + # return is called once + mock_transform.assert_called_once_with(fake_response) + # success + self.assertEqual(result["status_code"], 200) + # compare final email val to expected + self.assertEqual(result["email_address"], "fake@none.com") + + # have to include this, as otherwise tests will fail as no datatype mapping + @patch.object( + MailChimpClient, + "get_merge_fields_data_type_map", + return_value={"FNAME": "text"}, + ) + @patch.object(MailChimpClient, "transform_response") + @patch.object(MailChimpClient, "request_with_retry") + def test_upsert_member_fail( + self, mock_request_with_retry, mock_transform, mock_merge_fields_map + ): + """Test that upsert_member raises error when merge fields contain fake tags""" + list_id = "102930al" + email_address = "fake@none.com" + + # test KeyError is raised + with self.assertRaises(KeyError): + self.test_client.upsert_member( + list_id=list_id, + email_address=email_address, + merge_fields={"FAKE_MERGE_TAG": "some_val"}, + ) + + # no put call for upsert should have occurred + mock_request_with_retry.assert_not_called() + # no transform_response call for upsert should have occurred + mock_transform.assert_not_called() + + @patch.object(MailChimpClient, "paginate_endpoint") + def test_get_merge_fields_data_type_map_success(self, mock_paginate): + """Test that get_merge_fields_data_type_map returns correct mapping on success""" + mock_paginate.return_value = [ + {"tag": "FNAME", "type": "text"}, + {"tag": "LNAME", "type": "text"}, + {"tag": "VAN_ID", "type": "number"}, + ] + + list_id = "98798798h" + + result = self.test_client.get_merge_fields_data_type_map(list_id) + + mock_paginate.assert_called_once_with( + base_endpoint=f"lists/{list_id}/merge-fields", data_key="merge_fields" + ) + + expected_map = {"FNAME": "text", "LNAME": "text", "VAN_ID": "number"} + self.assertEqual(result, expected_map) + + @patch.object(MailChimpClient, "paginate_endpoint") + def test_get_merge_fields_data_type_map_fail(self, mock_paginate): + """Test that get_merge_fields_data_type_map returns empty dict fail""" + + # paginate_endpoint returns no data + mock_paginate.return_value = [{}] + + list_id = "zzzz" + result = self.test_client.get_merge_fields_data_type_map(list_id) + + # paginate_endpoint called once + mock_paginate.assert_called_once_with( + base_endpoint=f"lists/{list_id}/merge-fields", data_key="merge_fields" + ) -# @patch.object(MailChimpClient, "transform_response") -# @patch.object(logger, "debug") -# @patch("requests.Session.get") -# def test_paginate_endpoint_valid(self, mock_get, mock_debug, mock_transform): -# """Test that paginate_endpoint correctly aggregates results across multiple pages""" -# # set pages -# mock_transform.side_effect = [ -# {"members": [{"id": "1"}, {"id": "2"}], "total_items": 4}, -# {"members": [{"id": "3"}, {"id": "4"}], "total_items": 4}, -# ] -# -# # set http calls -# fake_response_1 = MagicMock() -# fake_response_2 = MagicMock() -# mock_get.side_effect = [fake_response_1, fake_response_2] -# -# results = self.test_client.paginate_endpoint( -# base_endpoint="lists/898/members", -# data_key="members", -# count=2, -# max_pages=2, -# ) -# # all data in list -# self.assertEqual(results, [{"id": "1"}, {"id": "2"}, {"id": "3"}, {"id": "4"}]) -# # two calls -# self.assertEqual(mock_get.call_count, 2) -# # debug not called -# mock_debug.assert_not_called() -# -# @patch.object(MailChimpClient, "transform_response") -# @patch.object(logger, "debug") -# @patch("requests.Session.get") -# def test_paginate_endpoint_debug_logs_when_empty( -# self, mock_get, mock_debug, mock_transform -# ): -# """Test that paginate_endpoint logs debug message and stops when no items are found""" -# # mock first page has data, second page empty (triggers debug...) -# mock_transform.side_effect = [ -# {"members": [{"id": "1"}, {"id": "2"}], "total_items": 4}, -# {"members": []}, -# ] -# -# # set HTTP calls -# fake_response_1 = MagicMock() -# fake_response_2 = MagicMock() -# mock_get.side_effect = [fake_response_1, fake_response_2] -# -# self.test_client.paginate_endpoint( -# base_endpoint="lists/898/members", -# data_key="members", -# count=2, -# ) -# -# # should log debug once when second page is empty -# mock_debug.assert_called_once_with( -# "No items found at offset 2 for key 'members'" -# ) -# # two calls -# self.assertEqual(mock_get.call_count, 2) -# -# @patch.object(MailChimpClient, "transform_response") -# @patch.object(logger, "debug") -# @patch("requests.Session.get") -# def test_paginate_endpoint_stops_when_total_items_reached( -# self, mock_get, mock_debug, mock_transform -# ): -# """Test that paginate_endpoint stops paginating when offset >= total_items""" -# mock_transform.side_effect = [ -# {"members": [{"id": "1"}, {"id": "2"}], "total_items": 3}, -# {"members": [{"id": "3"}], "total_items": 3}, -# ] -# -# # set HTTP calls -# fake_response_1 = MagicMock() -# fake_response_2 = MagicMock() -# mock_get.side_effect = [fake_response_1, fake_response_2] -# -# results = self.test_client.paginate_endpoint( -# base_endpoint="lists/010/members", -# data_key="members", -# count=2, -# ) -# -# # 3 total members -# self.assertEqual(results, [{"id": "1"}, {"id": "2"}, {"id": "3"}]) -# # two calls -# self.assertEqual(mock_get.call_count, 2) -# # debug not called -# mock_debug.assert_not_called() -# -# def test_get_subscriber_hash(self): -# """Test that get_subscriber_hash returns correct md5 hash and normalizes to lowercase""" -# email = "wEiRd@staclabs.coM" -# expected_subscriber_hash = "67441e845c03ceda61635c3263393515" -# -# result = MailChimpClient.get_subscriber_hash(email) -# self.assertEqual(result, expected_subscriber_hash) -# -# # lowercase version -# self.assertEqual( -# result, MailChimpClient.get_subscriber_hash("weird@staclabs.com") -# ) -# -# @patch.object(MailChimpClient, "transform_response") -# @patch("requests.Session.post") -# def test_update_member_tags_success_active(self, mock_post, mock_transform): -# """Test that update_member_tags correctly handles a 204 MailChimp success response for adding tags""" -# fake_response = MagicMock() -# fake_response.status_code = 204 -# fake_response.content = b"" -# mock_post.return_value = fake_response -# -# mock_transform.return_value = {"status_code": 204} -# -# result = self.test_client.update_member_tags( -# list_id="102930al", -# email_address="fake@none.com", -# tags=["NEWS ", "DONOR", " "], -# active=True, -# ) -# -# expected_hash = self.test_client.get_subscriber_hash("fake@none.com") -# expected_url = f"https://us9.api.mailchimp.com/3.0/lists/102930al/members/{expected_hash}/tags" -# -# mock_post.assert_called_once_with( -# expected_url, -# json={ -# "tags": [ -# {"name": "NEWS", "status": "active"}, -# {"name": "DONOR", "status": "active"}, -# ] -# }, -# ) -# -# # transform_response called once -# mock_transform.assert_called_once_with(fake_response) -# -# # no body in this call, just the status code -# self.assertEqual(result, {"status_code": 204}) -# -# @patch.object(MailChimpClient, "transform_response") -# @patch("requests.Session.post") -# def test_update_member_tags_success_inactive(self, mock_post, mock_transform): -# """Test that update_member_tags correctly handles a 204 MailChimp success response for removing tags""" -# fake_response = MagicMock() -# fake_response.status_code = 204 -# fake_response.content = b"" -# mock_post.return_value = fake_response -# -# mock_transform.return_value = {"status_code": 204} -# -# result = self.test_client.update_member_tags( -# list_id="102930al", -# email_address="fake@none.com", -# tags=["NEWS ", "DONOR", " "], -# active=False, -# ) -# -# expected_hash = self.test_client.get_subscriber_hash("fake@none.com") -# expected_url = f"https://us9.api.mailchimp.com/3.0/lists/102930al/members/{expected_hash}/tags" -# -# mock_post.assert_called_once_with( -# expected_url, -# json={ -# "tags": [ -# {"name": "NEWS", "status": "inactive"}, -# {"name": "DONOR", "status": "inactive"}, -# ] -# }, -# ) -# -# # transform_response called once -# mock_transform.assert_called_once_with(fake_response) -# -# # no body in this call, just the status code -# self.assertEqual(result, {"status_code": 204}) -# -# # have to include this, as otherwise tests will fail as no datatype mapping -# @patch.object( -# MailChimpClient, -# "get_merge_fields_data_type_map", -# return_value={"FNAME": "text", "LNAME": "text"}, -# ) -# @patch.object(MailChimpClient, "transform_response") -# @patch("requests.Session.put") -# def test_upsert_member_success( -# self, mock_put, mock_transform, mock_merge_fields_map -# ): -# """Test that upsert_member sends correct payload and handles MailChimp 200 JSON response""" -# -# fake_response = MagicMock() -# fake_response.status_code = 200 -# -# mock_put.return_value = fake_response -# mock_transform.return_value = { -# "id": "8121stac", -# "email_address": "fake@none.com", -# "status_code": 200, -# } -# -# list_id = "102930al" -# email_address = "fake@none.com" -# merge_fields = {"FNAME": "Fake", "LNAME": "Dude"} -# -# result = self.test_client.upsert_member( -# list_id=list_id, email_address=email_address, merge_fields=merge_fields -# ) -# -# expected_hash = self.test_client.get_subscriber_hash(email_address) -# expected_url = ( -# f"https://us9.api.mailchimp.com/3.0/lists/{list_id}/members/{expected_hash}" -# ) -# -# expected_payload = { -# "email_address": email_address, -# "status_if_new": "subscribed", -# "merge_fields": merge_fields, -# } -# # put called once -# mock_put.assert_called_once_with(expected_url, json=expected_payload) -# # return is called once -# mock_transform.assert_called_once_with(fake_response) -# # success -# self.assertEqual(result["status_code"], 200) -# # compare final email val to expected -# self.assertEqual(result["email_address"], "fake@none.com") -# -# # have to include this, as otherwise tests will fail as no datatype mapping -# @patch.object( -# MailChimpClient, -# "get_merge_fields_data_type_map", -# return_value={"FNAME": "text"}, -# ) -# @patch.object(MailChimpClient, "transform_response") -# @patch("requests.Session.put") -# def test_upsert_member_fail(self, mock_put, mock_transform, mock_merge_fields_map): -# """Test that upsert_member raises error when merge fields contain fake tags""" -# list_id = "102930al" -# email_address = "fake@none.com" -# -# # test KeyError is raised -# with self.assertRaises(KeyError): -# self.test_client.upsert_member( -# list_id=list_id, -# email_address=email_address, -# merge_fields={"FAKE_MERGE_TAG": "some_val"}, -# ) -# -# # no put call for upsert should have occurred -# mock_put.assert_not_called() -# # no transform_response call for upsert should have occurred -# mock_transform.assert_not_called() -# -# @patch.object(MailChimpClient, "paginate_endpoint") -# def test_get_merge_fields_data_type_map_success(self, mock_paginate): -# """Test that get_merge_fields_data_type_map returns correct mapping on success""" -# mock_paginate.return_value = [ -# {"tag": "FNAME", "type": "text"}, -# {"tag": "LNAME", "type": "text"}, -# {"tag": "VAN_ID", "type": "number"}, -# ] -# -# list_id = "98798798h" -# -# result = self.test_client.get_merge_fields_data_type_map(list_id) -# -# mock_paginate.assert_called_once_with( -# base_endpoint=f"lists/{list_id}/merge-fields", data_key="merge_fields" -# ) -# -# expected_map = {"FNAME": "text", "LNAME": "text", "VAN_ID": "number"} -# self.assertEqual(result, expected_map) -# -# @patch.object(MailChimpClient, "paginate_endpoint") -# def test_get_merge_fields_data_type_map_fail(self, mock_paginate): -# """Test that get_merge_fields_data_type_map returns empty dict fail""" -# -# # paginate_endpoint returns no data -# mock_paginate.return_value = [{}] -# -# list_id = "zzzz" -# result = self.test_client.get_merge_fields_data_type_map(list_id) -# -# # paginate_endpoint called once -# mock_paginate.assert_called_once_with( -# base_endpoint=f"lists/{list_id}/merge-fields", data_key="merge_fields" -# ) -# -# # empty dict for result -# self.assertEqual(result, {}) -# -# @patch.object(MailChimpClient, "get_merge_fields_data_type_map") -# def test_format_merge_fields_for_list_skips_blank_and_none(self, mock_get_types): -# """Test that None and blank strings are ignored""" -# mock_get_types.return_value = {"FNAME": "text", "LNAME": "text"} -# -# merge_fields = {"FNAME": None, "LNAME": " "} -# result = self.test_client.format_merge_fields_for_list("llll", merge_fields) -# # should be an empty dict -# self.assertEqual(result, {}) -# -# @patch.object(MailChimpClient, "get_merge_fields_data_type_map") -# def test_format_merge_fields_for_list_text_success(self, mock_get_types): -# """Test that text fields are handled correctly.""" -# mock_get_types.return_value = {"FNAME": "text", "LNAME": "text"} -# -# merge_fields = {"FNAME": " John ", "LNAME": "Doe"} -# result = self.test_client.format_merge_fields_for_list("oapao1", merge_fields) -# -# mock_get_types.assert_called_once_with("oapao1") -# self.assertEqual(result, {"FNAME": "John", "LNAME": "Doe"}) -# -# @patch.object(MailChimpClient, "get_merge_fields_data_type_map") -# def test_format_merge_fields_for_list_zip_fail(self, mock_get_types): -# """Test that zip type over 5 digits raise error""" -# mock_get_types.return_value = {"ZIP": "zip"} -# -# merge_fields = {"ZIP": "1111122"} -# -# with self.assertRaises(ValueError): -# self.test_client.format_merge_fields_for_list("some_id", merge_fields) -# -# @patch.object(MailChimpClient, "get_merge_fields_data_type_map") -# def test_format_merge_fields_for_list_zip_success(self, mock_get_types): -# """Test that zip with exactly 5 digits succeeds""" -# mock_get_types.return_value = {"ZIP": "zip"} -# -# merge_fields = {"ZIP": "11111"} -# -# result = self.test_client.format_merge_fields_for_list("oapao1", merge_fields) -# -# mock_get_types.assert_called_once_with("oapao1") -# self.assertEqual(result, {"ZIP": "11111"}) -# -# @patch.object(MailChimpClient, "get_merge_fields_data_type_map") -# def test_format_merge_fields_for_list_with_helpers(self, mock_get_types): -# """Test that date, birthday, address, and number fields are pushed to the correct helper function""" -# mock_get_types.return_value = { -# "DATE": "date", -# "BIRTHDAY": "birthday", -# "ADDRESS": "address", -# "NUMBER": "number", -# } -# -# # replace methods with mocks -# with patch.object(self.test_client, "format_date") as mock_date, patch.object( -# self.test_client, "format_birthday" -# ) as mock_bday, patch.object( -# self.test_client, -# "format_address", -# ) as mock_addr, patch.object( -# self.test_client, -# "format_number", -# ) as mock_num: -# # placeholder fake data -# merge_fields = { -# "DATE": "2022-10-09", -# "BIRTHDAY": "10/09", -# "ADDRESS": {}, -# "NUMBER": 0, -# } -# -# self.test_client.format_merge_fields_for_list("some_id", merge_fields) -# # just test if helper methods were called -# mock_date.assert_called_once() -# mock_bday.assert_called_once() -# mock_addr.assert_called_once() -# mock_num.assert_called_once() + # empty dict for result + self.assertEqual(result, {}) + + @patch.object(MailChimpClient, "get_merge_fields_data_type_map") + def test_format_merge_fields_for_list_skips_blank_and_none(self, mock_get_types): + """Test that None and blank strings are ignored""" + mock_get_types.return_value = {"FNAME": "text", "LNAME": "text"} + + merge_fields = {"FNAME": None, "LNAME": " "} + result = self.test_client.format_merge_fields_for_list("llll", merge_fields) + # should be an empty dict + self.assertEqual(result, {}) + + @patch.object(MailChimpClient, "get_merge_fields_data_type_map") + def test_format_merge_fields_for_list_text_success(self, mock_get_types): + """Test that text fields are handled correctly.""" + mock_get_types.return_value = {"FNAME": "text", "LNAME": "text"} + + merge_fields = {"FNAME": " John ", "LNAME": "Doe"} + result = self.test_client.format_merge_fields_for_list("oapao1", merge_fields) + + mock_get_types.assert_called_once_with("oapao1") + self.assertEqual(result, {"FNAME": "John", "LNAME": "Doe"}) + + @patch.object(MailChimpClient, "get_merge_fields_data_type_map") + def test_format_merge_fields_for_list_zip_fail(self, mock_get_types): + """Test that zip type over 5 digits raise error""" + mock_get_types.return_value = {"ZIP": "zip"} + + merge_fields = {"ZIP": "1111122"} + + with self.assertRaises(ValueError): + self.test_client.format_merge_fields_for_list("some_id", merge_fields) + + @patch.object(MailChimpClient, "get_merge_fields_data_type_map") + def test_format_merge_fields_for_list_zip_success(self, mock_get_types): + """Test that zip with exactly 5 digits succeeds""" + mock_get_types.return_value = {"ZIP": "zip"} + + merge_fields = {"ZIP": "11111"} + + result = self.test_client.format_merge_fields_for_list("oapao1", merge_fields) + + mock_get_types.assert_called_once_with("oapao1") + self.assertEqual(result, {"ZIP": "11111"}) + + @patch.object(MailChimpClient, "get_merge_fields_data_type_map") + def test_format_merge_fields_for_list_with_helpers(self, mock_get_types): + """Test that date, birthday, address, and number fields are pushed to the correct helper function""" + mock_get_types.return_value = { + "DATE": "date", + "BIRTHDAY": "birthday", + "ADDRESS": "address", + "NUMBER": "number", + } + + # replace methods with mocks + with patch.object(self.test_client, "format_date") as mock_date, patch.object( + self.test_client, "format_birthday" + ) as mock_bday, patch.object( + self.test_client, + "format_address", + ) as mock_addr, patch.object( + self.test_client, + "format_number", + ) as mock_num: + # placeholder fake data + merge_fields = { + "DATE": "2022-10-09", + "BIRTHDAY": "10/09", + "ADDRESS": {}, + "NUMBER": 0, + } + + self.test_client.format_merge_fields_for_list("some_id", merge_fields) + # just test if helper methods were called + mock_date.assert_called_once() + mock_bday.assert_called_once() + mock_addr.assert_called_once() + mock_num.assert_called_once() From 8dc321e67bdd8d1935bac2bd344ad85058a08220 Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Sat, 11 Oct 2025 15:19:15 -0500 Subject: [PATCH 19/23] clean up and add more coverage for upsert_member tests --- src/tests/test_mailchimp.py | 68 ++++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/src/tests/test_mailchimp.py b/src/tests/test_mailchimp.py index 6ec1e0a3..35cf8a5b 100644 --- a/src/tests/test_mailchimp.py +++ b/src/tests/test_mailchimp.py @@ -399,16 +399,36 @@ def test_update_member_tags_success_inactive( # no body in this call, just the status code self.assertEqual(result, {"status_code": 204}) - # have to include this, as otherwise tests will fail as no datatype mapping - @patch.object( - MailChimpClient, - "get_merge_fields_data_type_map", - return_value={"FNAME": "text", "LNAME": "text"}, - ) + @patch.object(logger, "info") + @patch.object(MailChimpClient, "request_with_retry") + def test_update_member_tags_empty_tag_payload( + self, mock_request_with_retry, mock_info + ): + """Test that update_member_tags returns early when no valid tags exist""" + # no good tags in the payload + result = self.test_client.update_member_tags( + list_id="102930al", + email_address="fake@none.com", + tags=[" ", "", None], + active=True, + ) + + # check the return + self.assertEqual(result, {"status_code": 204, "info": "No valid tags provided"}) + + # no retries + mock_request_with_retry.assert_not_called() + + # check the log message + mock_info.assert_called_once_with( + "No valid tags provided for email: fake@none.com" + ) + @patch.object(MailChimpClient, "transform_response") + @patch.object(MailChimpClient, "format_merge_fields_for_list") @patch.object(MailChimpClient, "request_with_retry") def test_upsert_member_success( - self, mock_request_with_retry, mock_transform, mock_merge_fields_map + self, mock_request_with_retry, mock_format, mock_transform ): """Test that upsert_member sends correct payload and handles MailChimp 200 JSON response""" @@ -422,6 +442,8 @@ def test_upsert_member_success( "status_code": 200, } + mock_format.return_value = {"FNAME": "Fake", "LNAME": "Dude"} + list_id = "102930al" email_address = "fake@none.com" merge_fields = {"FNAME": "Fake", "LNAME": "Dude"} @@ -446,33 +468,33 @@ def test_upsert_member_success( ) # return is called once mock_transform.assert_called_once_with(fake_response) + # format_merge_fields_for_list called once + mock_format.assert_called_once_with(list_id, merge_fields) # success self.assertEqual(result["status_code"], 200) # compare final email val to expected self.assertEqual(result["email_address"], "fake@none.com") - # have to include this, as otherwise tests will fail as no datatype mapping - @patch.object( - MailChimpClient, - "get_merge_fields_data_type_map", - return_value={"FNAME": "text"}, - ) @patch.object(MailChimpClient, "transform_response") @patch.object(MailChimpClient, "request_with_retry") - def test_upsert_member_fail( - self, mock_request_with_retry, mock_transform, mock_merge_fields_map - ): + def test_upsert_member_fail(self, mock_request_with_retry, mock_transform): """Test that upsert_member raises error when merge fields contain fake tags""" list_id = "102930al" email_address = "fake@none.com" - # test KeyError is raised - with self.assertRaises(KeyError): - self.test_client.upsert_member( - list_id=list_id, - email_address=email_address, - merge_fields={"FAKE_MERGE_TAG": "some_val"}, - ) + # have to include this, as otherwise tests will fail as no datatype mapping + with patch.object( + MailChimpClient, + "get_merge_fields_data_type_map", + return_value={"FNAME": "text"}, + ): + # test KeyError is raised + with self.assertRaises(KeyError): + self.test_client.upsert_member( + list_id=list_id, + email_address=email_address, + merge_fields={"FAKE_MERGE_TAG": "some_val"}, + ) # no put call for upsert should have occurred mock_request_with_retry.assert_not_called() From 530188651989e6f4887e5c3311d2909129a76beb Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Sat, 11 Oct 2025 15:43:59 -0500 Subject: [PATCH 20/23] add test for format_date --- src/tests/test_mailchimp.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/tests/test_mailchimp.py b/src/tests/test_mailchimp.py index 35cf8a5b..6f8f4c75 100644 --- a/src/tests/test_mailchimp.py +++ b/src/tests/test_mailchimp.py @@ -4,6 +4,7 @@ import requests from unittest.mock import patch, MagicMock, PropertyMock from src.stac_utils.mailchimp import MailChimpClient, logger +from datetime import datetime, date class TestMailChimpClient(unittest.TestCase): @@ -616,3 +617,27 @@ def test_format_merge_fields_for_list_with_helpers(self, mock_get_types): mock_bday.assert_called_once() mock_addr.assert_called_once() mock_num.assert_called_once() + + def test_format_date(self): + """Test where input value is a date/datetime or string instance""" + # using datetime + datetime_value = datetime(2025, 1, 1) + function_datetime = self.test_client.format_date(datetime_value) + self.assertEqual(function_datetime, "2025-01-01") + + # using date + date_value = date(2025, 1, 1) + function_date = self.test_client.format_date(date_value) + self.assertEqual(function_date, "2025-01-01") + + # using string + function_string = self.test_client.format_date(" 2025-01-01 ") + self.assertEqual(function_string, "2025-01-01") + + # covers some other string formatted cases + self.assertEqual(self.test_client.format_date("01/01/2025"), "2025-01-01") + self.assertEqual(self.test_client.format_date("01-01-2025"), "2025-01-01") + + # ValueError raised when not a date + with self.assertRaises(ValueError): + self.test_client.format_date("not a date, obviously") From 29b6242ffe2004d8c6b139b13a648b4d6dd935e6 Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Sat, 11 Oct 2025 15:50:10 -0500 Subject: [PATCH 21/23] add test for format_birthday --- src/tests/test_mailchimp.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/tests/test_mailchimp.py b/src/tests/test_mailchimp.py index 6f8f4c75..3987a53c 100644 --- a/src/tests/test_mailchimp.py +++ b/src/tests/test_mailchimp.py @@ -641,3 +641,28 @@ def test_format_date(self): # ValueError raised when not a date with self.assertRaises(ValueError): self.test_client.format_date("not a date, obviously") + + def test_format_birthday(self): + """Test where input value is a date/datetime or string instance""" + # using datetime + datetime_value = datetime(2025, 1, 1) + function_datetime = self.test_client.format_birthday(datetime_value) + self.assertEqual(function_datetime, "01/01") + + # using date + date_value = date(2025, 1, 1) + function_date = self.test_client.format_birthday(date_value) + self.assertEqual(function_date, "01/01") + + # using string + function_string = self.test_client.format_birthday(" 01/01 ") + self.assertEqual(function_string, "01/01") + + # covers some other string formatted cases + self.assertEqual(self.test_client.format_birthday("01-01"), "01/01") + self.assertEqual(self.test_client.format_birthday("2025-01-01"), "01/01") + self.assertEqual(self.test_client.format_birthday("2025/01/01"), "01/01") + + # ValueError raised when not a date + with self.assertRaises(ValueError): + self.test_client.format_birthday("not a date, obviously") From 9eda7edac1b3b4bc9dc76072f9a3dcb734e71ec1 Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Sat, 11 Oct 2025 16:14:32 -0500 Subject: [PATCH 22/23] add test for format_address --- src/tests/test_mailchimp.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/tests/test_mailchimp.py b/src/tests/test_mailchimp.py index 3987a53c..14fd1f94 100644 --- a/src/tests/test_mailchimp.py +++ b/src/tests/test_mailchimp.py @@ -666,3 +666,34 @@ def test_format_birthday(self): # ValueError raised when not a date with self.assertRaises(ValueError): self.test_client.format_birthday("not a date, obviously") + + def test_format_address(self): + """Test format_address returns the correct values""" + # when input val is not a dict + with self.assertRaises(ValueError): + self.test_client.format_address("not a dict, obviously") + + # create a dict to test the normalize_string function nested in format_address + function_address_valid = self.test_client.format_address( + { + "addr1": "999 Dolly Ave", + "addr2": " Apt 3 ", + "city": "Delaware", + "state": "PA", + "zip": 90210, + "country": "USA ", + } + ) + + # check if one req element exists + self.assertIn("addr1", function_address_valid) + # check formatted val of addr1 + self.assertEqual(function_address_valid["addr1"], "999 Dolly Ave") + # check that a numeric val is now string + self.assertEqual(function_address_valid["zip"], "90210") + # coverage on the string trimming + self.assertEqual(function_address_valid["addr2"], "Apt 3") + + # creating an invalid req + with self.assertRaises(ValueError): + self.test_client.format_address({"addr2": "999 Dolly Ave "}) From 6b1646da6f87d4267cc835b0f691028968fca9e1 Mon Sep 17 00:00:00 2001 From: KeshSridhar Date: Sat, 11 Oct 2025 16:25:17 -0500 Subject: [PATCH 23/23] add test for format_number --- src/tests/test_mailchimp.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/tests/test_mailchimp.py b/src/tests/test_mailchimp.py index 14fd1f94..97d862c0 100644 --- a/src/tests/test_mailchimp.py +++ b/src/tests/test_mailchimp.py @@ -697,3 +697,32 @@ def test_format_address(self): # creating an invalid req with self.assertRaises(ValueError): self.test_client.format_address({"addr2": "999 Dolly Ave "}) + + def test_format_number(self): + """Test format_number returns the correct values""" + # test bool raises ValueError + with self.assertRaises(ValueError): + self.test_client.format_number(True) + + # test empty string raises ValueError + with self.assertRaises(ValueError): + self.test_client.format_number(" ") + + # test non-numeric string raises value error + with self.assertRaises(ValueError): + self.test_client.format_number("not a number, obviously") + + # integer input is valid + self.assertEqual(self.test_client.format_number(1), 1) + + # float input is valid + self.assertEqual(self.test_client.format_number(9.99), 9.99) + + # integer string is valid + self.assertEqual(self.test_client.format_number("1"), 1) + + # signed integer string is valid + self.assertEqual(self.test_client.format_number("-1"), -1) + + # float string is valid + self.assertEqual(self.test_client.format_number("9.99"), 9.99)