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 diff --git a/src/stac_utils/mailchimp.py b/src/stac_utils/mailchimp.py new file mode 100644 index 00000000..be186d8d --- /dev/null +++ b/src/stac_utils/mailchimp.py @@ -0,0 +1,507 @@ +import os +import json +import requests +from .http import HTTPClient +import logging +import hashlib +from datetime import datetime, date +from typing import Any +import time +import random + +# 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" + # set total amount of retries for endpoints + self.max_retries = 3 + 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 request_with_retry( + self, + method: str, + endpoint_url: str, + **kwargs, + ) -> requests.Response: + """ + 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 + + 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 + """ + 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, + 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}" + + # 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, []) + 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 + + # 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 + 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. + :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 = [ + 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" + + payload = { + "tags": [ + {"name": tag, "status": "active" if active else "inactive"} + for tag in cleaned + ] + } + # use request_with_retry + response = self.request_with_retry( + method="POST", + endpoint_url=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) + + # 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]: + """ + 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 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}") + + 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("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}") diff --git a/src/tests/test_mailchimp.py b/src/tests/test_mailchimp.py new file mode 100644 index 00000000..97d862c0 --- /dev/null +++ b/src/tests/test_mailchimp.py @@ -0,0 +1,728 @@ +import os +import json +import unittest +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): + 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("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("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 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}) + + @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_format, mock_transform + ): + """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, + } + + mock_format.return_value = {"FNAME": "Fake", "LNAME": "Dude"} + + 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) + # 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") + + @patch.object(MailChimpClient, "transform_response") + @patch.object(MailChimpClient, "request_with_retry") + 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" + + # 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() + # 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() + + 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") + + 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") + + 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 "}) + + 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)