diff --git a/Readme.md b/Readme.md index 3a41cc1..dae8d0b 100644 --- a/Readme.md +++ b/Readme.md @@ -54,11 +54,32 @@ For more information how to obtain an API key visit the [following link](https:/ - [List all campaigns](#list-all-campaigns) - [Get a campaign](#get-a-campaign) - [Create a campaign](#create-a-campaign) + - [Create a simple editor campaign](#create-a-simple-editor-campaign) - [Update a campaign](#update-a-campaign) - [Schedule a campaign](#schedule-a-campaign) - [Cancel a campaign](#cancel-a-campaign) - [Delete a campaign](#delete-a-campaign) - [Get subscribers activity for a campaign](#get-subscribers-activity-for-a-campaign) + - [Emails](#emails) + - [Get an email](#get-an-email) + - [Get the HTML of an email](#get-the-html-of-an-email) + - [Get a block](#get-a-block) + - [Insert a block](#insert-a-block) + - [Update a block](#update-a-block) + - [Delete a block](#delete-a-block) + - [Move a block](#move-a-block) + - [Validate a block](#validate-a-block) + - [Update the design settings](#update-the-design-settings) + - [Validate the design settings](#validate-the-design-settings) + - [Save an email as a template](#save-an-email-as-a-template) + - [List the versions of an email](#list-the-versions-of-an-email) + - [Save a version of an email](#save-a-version-of-an-email) + - [Restore a version of an email](#restore-a-version-of-an-email) + - [Retry a write with an idempotency key](#retry-a-write-with-an-idempotency-key) + - [Brand styles](#brand-styles) + - [Get the brand styles](#get-the-brand-styles) + - [Files](#files) + - [List all files](#list-all-files) - [Forms](#forms) - [List all forms](#list-all-forms) - [Get a form](#get-a-form) @@ -111,10 +132,18 @@ client = MailerLite.Client({ ### Error handling -API responses with a 4xx or 5xx status code raise a `requests.HTTPError` exception. The exception includes the full response, so you can inspect the status code and error body: +API responses with a 4xx or 5xx status code raise a `mailerlite.ApiError` exception. `ApiError` is a subclass of `requests.HTTPError`, so `except requests.HTTPError` continues to work. The exception has these attributes: + +- `status_code`: the HTTP status code. +- `message`: the error message of the API. +- `code`: a machine-readable error code, if the API sends one. For example, `version_conflict`. +- `etag`: the current etag of the email, if the API sends one. The API sends it with `version_conflict`. +- `errors`: the validation errors for each field, if the API sends them. +- `response`: the full `requests.Response`. + +The attributes are `None` when the response does not have them. ```python -import requests import mailerlite as MailerLite client = MailerLite.Client({ @@ -123,9 +152,11 @@ client = MailerLite.Client({ try: response = client.subscribers.get('some@email.com') -except requests.HTTPError as e: - print(e.response.status_code) # e.g. 404 - print(e.response.json()) # error body from the API +except MailerLite.ApiError as e: + print(e.status_code) # e.g. 404 + print(e.message) # error message from the API + print(e.errors) # validation errors, if any + print(e.response.json()) # full error body from the API ``` Network-level failures (DNS errors, connection resets, timeouts) raise the standard `requests` exceptions such as `requests.ConnectionError` and `requests.Timeout`. Catch `requests.RequestException` to handle both HTTP and network errors with a single handler. @@ -589,6 +620,38 @@ params = { response = client.campaigns.create(params) ``` +### Create a simple editor campaign + + +To build the email with the simple editor, set `editor` to `MailerLite.EDITOR_SIMPLE`, and do not send `content`. Then edit the email with [the email methods](#emails). + +- `template_id` is optional. It is a shared template of the simple editor that the email starts from. It needs `editor`. +- All emails of a campaign must have the same `editor` and `template_id`. +- Only `create` accepts `editor` and `template_id`. `update` does not. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +params = { + "name": "Test Campaign", + "type": "regular", + "emails": [{ + "subject": "This is a test campaign", + "from_name": "Test Man", + "from": "testuser@mailerlite.com", + "editor": MailerLite.EDITOR_SIMPLE, + }] +} + +response = client.campaigns.create(params) + +email_id = response['data']['emails'][0]['id'] +``` + ### Update a campaign @@ -682,6 +745,416 @@ campaign_id = 123456 response = client.campaigns.activity(campaign_id) ``` +## Emails + + +These methods edit emails built with the simple editor. To create such an email, [create a simple editor campaign](#create-a-simple-editor-campaign). The ID of the email is in `response['data']['emails'][0]['id']`. + +- IDs are strings of digits. You can also give them as `int`. An empty ID raises `mailerlite.EmptyIdError`, and an ID that does not contain only digits raises `mailerlite.InvalidIdError`. The SDK sends no request in these cases. +- Before the request, the SDK checks only the IDs and the `settings` of `update_settings()`. It sends all other values as you give them, and the API checks them. An invalid value raises `mailerlite.ApiError`, for example with status `422`. +- The methods return a `mailerlite.ApiResponse`. It is a `dict` with the response body, and it also has `status_code` and `headers`. +- Each write needs the etag from the last response for the email, and returns a new etag. The etag covers the whole email, so send the writes to one email one after another. +- If the email changed after you read it, the write raises `mailerlite.ApiError` with `code` equal to `mailerlite.VERSION_CONFLICT`. Its `etag` is the current etag. Get the email again, and then repeat the write with the current etag. +- The keys of `editor_content` are camelCase, for example `textAlign`. The SDK sends them as you give them. + +### Get an email + + +Returns the email with its settings and blocks, and the etag of the email. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +email_id = '123456' + +response = client.emails.get(email_id) + +email = response['data'] +etag = response['etag'] +``` + +### Get the HTML of an email + + +Returns the HTML that the email sends. The API can return a temporary `503` error. If it does, try again later. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +email_id = '123456' + +response = client.emails.get_html(email_id) + +html = response['data']['html'] +``` + +### Get a block + + +Returns the block and its place in the email: `placement` has the IDs of the blocks `after` and `before` it, or `None`. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +email_id = '123456' +block_id = '57' + +response = client.emails.get_block(email_id, block_id) + +block = response['data'] +``` + +### Insert a block + + +Use the block types in `MailerLite.BlockType`. `editor_content` can be empty. Without `placement`, the API puts the block at the end of the email. The `before` and `after` positions also need a `block_id`. The API ignores the `block_id` of `start` and `end`. The SDK sends the placement as you give it. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +email_id = '123456' +etag = client.emails.get(email_id)['etag'] + +response = client.emails.create_block( + email_id, + etag, + MailerLite.BlockType.PARAGRAPH, + {'attrs': {'textAlign': 'left'}, 'content': [{'type': 'text', 'text': 'Hello'}]}, + placement={'position': MailerLite.BlockPosition.START}, +) + +block_id = response['data']['id'] +etag = response['etag'] +``` + +### Update a block + + +The new `editor_content` replaces the whole content of the block. The API removes the keys that you do not send. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +email_id = '123456' +block_id = '57' +etag = 'etag-from-the-last-response' + +response = client.emails.update_block( + email_id, + block_id, + etag, + {'attrs': {'textAlign': 'center'}, 'content': [{'type': 'text', 'text': 'Hello again'}]}, +) + +etag = response['etag'] +``` + +### Delete a block + + +Also deletes the blocks in it. The response has only the new etag. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +email_id = '123456' +block_id = '57' +etag = 'etag-from-the-last-response' + +response = client.emails.delete_block(email_id, block_id, etag) + +etag = response['etag'] +``` + +### Move a block + + +The `before` and `after` positions also need a `block_id`. The API ignores the `block_id` of `start` and `end`. The SDK sends the placement as you give it. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +email_id = '123456' +block_id = '57' +etag = 'etag-from-the-last-response' + +response = client.emails.move_block( + email_id, + block_id, + etag, + {'position': MailerLite.BlockPosition.AFTER, 'block_id': '12'}, +) + +etag = response['etag'] +``` + +### Validate a block + + +Checks a block before you insert it. It needs no email and no etag. An invalid block does not raise an error: the response has `valid` set to `False` and the `errors` for each field. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +response = client.emails.validate_block( + MailerLite.BlockType.BUTTON, + {'attrs': {'href': 'https://example.com'}, 'content': [{'type': 'text', 'text': 'Buy now'}]}, +) + +if not response['valid']: + print(response['errors']) +``` + +### Update the design settings + + +Send only the keys to change, in the shape of `settings` in the email. The groups are `brand`, `layout`, `typography`, `button` and `footer`. + +- Send all style values as strings, for example `'16'` and not `16`. +- The API stores each value that you send, also a value that the email inherits from your account. Then the email no longer follows your account for that value. +- To unset a brand colour, send an empty string. The API stores the value empty, and the email then uses the account value. +- To unset a footer text, send an empty editor document: `{'editor_content': {}}` or `{'editor_content': {'content': []}}`. +- Other colours, sizes, `button.border_radius` and `brand.style` cannot be empty. An empty string gives a `422` error. +- `footer.social_links` replaces the whole list. Keep the `link_id` of each link to keep its click history. +- `brand.mode` is one of `MailerLite.BrandMode`. To apply a preset palette, send its `identifier` from [the brand styles](#get-the-brand-styles) in `brand.style`. +- If the API removed unsafe values, the response has `rejected` with the keys of these values. +- `settings` must be a `dict` without an `etag` key, because the SDK adds the `etag` argument to it. Otherwise the SDK raises `TypeError` or `ValueError` and sends no request. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +email_id = '123456' +etag = 'etag-from-the-last-response' + +response = client.emails.update_settings(email_id, etag, { + 'brand': {'mode': MailerLite.BrandMode.CUSTOM, 'background_color': '#f4f4f4'}, + 'typography': {'paragraph': {'p1': {'size': '18'}}}, +}) + +etag = response['etag'] +if response.get('rejected'): + print(response['rejected']['keys']) +``` + +### Validate the design settings + + +Checks a change with the rules of the update, and saves nothing. It needs no etag. Invalid settings do not raise an error: the response has `valid` set to `False` and the `errors` for each field. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +email_id = '123456' + +response = client.emails.validate_settings(email_id, { + 'button': {'border_radius': '8'}, +}) + +if not response['valid']: + print(response['errors']) +``` + +### Save an email as a template + + +Copies the email to a new template. The API cannot delete templates. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +email_id = '123456' + +response = client.emails.create_template(email_id, 'Monthly newsletter') + +template_id = response['data']['id'] +``` + +### List the versions of an email + + +A version is a saved copy of the content and the settings of an email. The list starts with the newest version. `current` marks the version that the email has now. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +email_id = '123456' + +response = client.emails.list_versions(email_id, page=1, limit=10) +``` + +### Save a version of an email + + +Writes through the API do not save versions. If nothing changed after the last version, the API saves no new version and returns the last version. `response.status_code` is `201` for a new version and `200` for the last version. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +email_id = '123456' + +response = client.emails.create_version(email_id) + +version_id = response['data']['id'] +is_new = response.status_code == 201 +``` + +### Restore a version of an email + + +Puts the email back to the version, and returns the full email with the new etag. Before the restore, the API saves the current state as a version, so you can undo the restore. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +email_id = '123456' +version_id = '901' +etag = 'etag-from-the-last-response' + +response = client.emails.restore_version(email_id, version_id, etag) + +etag = response['etag'] +``` + +### Retry a write with an idempotency key + + +If a write fails with a network error, you do not know if the API did the write. Give the write an `idempotency_key`, and retry it with the same key and the same arguments. If the first request succeeded, the API returns the stored response and does not do the write two times. Then `response.is_idempotent_replay` is `True`. + +- Use a new key for each write. The API keeps a key for 24 hours. +- The key must be 1 to 255 printable ASCII characters. The SDK sends the key as you give it. The API answers an invalid key with status `400`. +- If `requests` cannot send the key in a header, it raises `requests.exceptions.InvalidHeader`. For example, a key with a space at the start or a new line causes this error. +- The same key with different arguments raises `ApiError` with status `422`. If the first request is still running, the API returns `409` without a `code`. +- These methods accept a key: `create_block`, `update_block`, `delete_block`, `move_block`, `update_settings`, `create_template`, `create_version` and `restore_version`. + +```python +import uuid + +import requests +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +email_id = '123456' +etag = 'etag-from-the-last-response' +key = str(uuid.uuid4()) + +try: + response = client.emails.create_block( + email_id, etag, MailerLite.BlockType.DIVIDER, {}, idempotency_key=key + ) +except (requests.ConnectionError, requests.Timeout): + # Network error: retry with the same key and the same arguments. + response = client.emails.create_block( + email_id, etag, MailerLite.BlockType.DIVIDER, {}, idempotency_key=key + ) + +print(response['data']['id'], response.is_idempotent_replay) +``` + +## Brand styles + + +### Get the brand styles + + +Returns the palettes of your account: `brand` (your brand styles, or `None` if your account has none), `default` and the `presets`. Match a palette on its `identifier`, not on its `name`. To apply a preset to an email, [update the design settings](#update-the-design-settings) with `{'brand': {'style': identifier}}`. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +response = client.brand_styles.get() + +identifiers = [preset['identifier'] for preset in response['data']['presets']] +``` + +## Files + + +### List all files + + +Returns the files of your media library. Use their `url` in image blocks. `width` and `height` are `None` for files that are not images. + +- `filter`: `name` (a part of the file name) and `type` (one of `MailerLite.FileType`). +- `sort`: one of `MailerLite.FileSort`. The default is newest first. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +response = client.files.list( + filter={'name': 'summer sale', 'type': MailerLite.FileType.IMAGE}, + sort=MailerLite.FileSort.SIZE_DESCENDING, + page=1, + limit=25, +) +``` + ## Forms diff --git a/VERSION b/VERSION index b1e80bb..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.3 +0.3.0 diff --git a/mailerlite/__init__.py b/mailerlite/__init__.py index 08810f2..45a473f 100644 --- a/mailerlite/__init__.py +++ b/mailerlite/__init__.py @@ -3,12 +3,28 @@ from __future__ import absolute_import from mailerlite.api_client import ApiClient +from mailerlite.exceptions import ( # noqa: F401 + ApiError, + EmptyIdError, + InvalidIdError, +) +from mailerlite.response import ApiResponse # noqa: F401 from mailerlite.sdk.automations import Automations from mailerlite.sdk.batch import Batches +from mailerlite.sdk.brand_styles import BrandStyles # import all api files into api package from mailerlite.sdk.campaigns import Campaigns +from mailerlite.sdk.emails import ( # noqa: F401 + EDITOR_SIMPLE, + VERSION_CONFLICT, + BlockPosition, + BlockType, + BrandMode, + Emails, +) from mailerlite.sdk.fields import Fields +from mailerlite.sdk.files import FileSort, Files, FileType # noqa: F401 from mailerlite.sdk.forms import Forms from mailerlite.sdk.groups import Groups from mailerlite.sdk.segments import Segments @@ -29,6 +45,9 @@ def __init__(self, config={}): self.fields = Fields(self.api_client) self.forms = Forms(self.api_client) self.campaigns = Campaigns(self.api_client) + self.emails = Emails(self.api_client) + self.brand_styles = BrandStyles(self.api_client) + self.files = Files(self.api_client) self.timezones = Timezones(self.api_client) self.webhooks = Webhooks(self.api_client) self.automations = Automations(self.api_client) diff --git a/mailerlite/api_client.py b/mailerlite/api_client.py index e487c10..38a3b0e 100644 --- a/mailerlite/api_client.py +++ b/mailerlite/api_client.py @@ -6,6 +6,8 @@ from functools import partialmethod from typing import Optional, Tuple +from mailerlite.exceptions import ApiError + class HttpMethods(object): """Allowed Http methods""" @@ -13,10 +15,11 @@ class HttpMethods(object): GET: str = "get" POST: str = "post" PUT: str = "put" + PATCH: str = "patch" DELETE: str = "delete" # Allowed methods - ALLOWED: Tuple[str, ...] = (GET, POST, PUT, DELETE) + ALLOWED: Tuple[str, ...] = (GET, POST, PUT, PATCH, DELETE) @classmethod def _is_method(cls, method: str, target: str) -> bool: @@ -26,6 +29,7 @@ def _is_method(cls, method: str, target: str) -> bool: is_get: partialmethod = partialmethod(_is_method, target=GET) is_post: partialmethod = partialmethod(_is_method, target=POST) is_put: partialmethod = partialmethod(_is_method, target=PUT) + is_patch: partialmethod = partialmethod(_is_method, target=PATCH) is_delete: partialmethod = partialmethod(_is_method, target=DELETE) @@ -73,24 +77,32 @@ def request( path: str, query_params: Optional[dict] = None, body: Optional[dict] = None, + headers: Optional[dict] = None, ) -> requests.models.Response: - """Requests Wrapper""" + """Requests Wrapper + + `headers` are added to the default headers for this request only. + """ method = method.lower() if method not in HttpMethods.ALLOWED: - raise ValueError("http method must be `POST`, `GET`, `PUT` or `DELETE`.") + raise ValueError( + "http method must be `POST`, `GET`, `PUT`, `PATCH` or `DELETE`." + ) kwargs = { "method": method, "url": urljoin(self.HOST, path), "params": query_params, - "headers": self.headers, + "headers": {**self.headers, **(headers or {})}, "timeout": self.timeout, } if HttpMethods.is_get(method): kwargs.update(allow_redirects=True) - if HttpMethods.is_post(method) or HttpMethods.is_put(method): + # Without a body, send no body and not the JSON `null` + if not HttpMethods.is_get(method) and body is not None: kwargs.update(data=json.dumps(body)) response = requests.request(**kwargs) - response.raise_for_status() + if 400 <= response.status_code < 600: + raise ApiError(response) return response diff --git a/mailerlite/exceptions.py b/mailerlite/exceptions.py new file mode 100644 index 0000000..72e898e --- /dev/null +++ b/mailerlite/exceptions.py @@ -0,0 +1,53 @@ +from __future__ import absolute_import + +import requests + + +class ApiError(requests.HTTPError): + """ + The API returned a 4xx or 5xx status code + + It is a `requests.HTTPError`, so `e.response` gives the full response. + The fields are `None` when the response body does not have them. + """ + + def __init__(self, response): + self.status_code = response.status_code + + body = _json_or_none(response) + if not isinstance(body, dict): + body = {} + + self.message = body.get("message") + self.code = body.get("code") + self.etag = body.get("etag") + self.errors = body.get("errors") + + kind = "Client Error" if response.status_code < 500 else "Server Error" + text = ( + f"{response.status_code} {kind}: {response.reason} for url: {response.url}" + ) + if self.message: + detail = f"{self.code}: {self.message}" if self.code else self.message + text = f"{text} ({detail})" + + super().__init__(text, response=response) + + def __reduce__(self): + # pickle calls cls(*self.args) by default, but __init__ needs the response + return self.__class__, (self.response,) + + +def _json_or_none(response): + try: + return response.json() + except ValueError: + return None + + +class EmptyIdError(ValueError): + """An ID for the URL path is empty. The SDK sends no request.""" + + +class InvalidIdError(ValueError): + """An ID for the URL path does not contain only digits. The SDK sends no request.""" diff --git a/mailerlite/response.py b/mailerlite/response.py new file mode 100644 index 0000000..3b88d6a --- /dev/null +++ b/mailerlite/response.py @@ -0,0 +1,48 @@ +from __future__ import absolute_import + +import copy + + +class ApiResponse(dict): + """ + The decoded JSON body of a response + + It is a dict, so you use it like the dicts that the other methods return. + It also has the status code and the headers of the response. + A response without a body, for example `204`, is an empty dict. + """ + + def __init__(self, response): + super().__init__(_json_object(response)) + self.status_code = response.status_code + self.headers = response.headers + + def copy(self): + """A shallow copy that keeps the status code and the headers""" + + return copy.copy(self) + + @property + def is_idempotent_replay(self): + """ + True if the API returned the stored response of an earlier request + with the same idempotency key + """ + + # A proxy can change the case of the value + value = self.headers.get("Idempotent-Replayed", "") + return value.strip().lower() == "true" + + +def _json_object(response): + if not response.content: + return {} + + body = response.json() + if not isinstance(body, dict): + raise TypeError( + f"The API returned a JSON {type(body).__name__}, not a JSON object, " + f"with status {response.status_code} for url: {response.url}" + ) + + return body diff --git a/mailerlite/sdk/__init__.py b/mailerlite/sdk/__init__.py index b07aabd..d8af9ca 100644 --- a/mailerlite/sdk/__init__.py +++ b/mailerlite/sdk/__init__.py @@ -1,8 +1,11 @@ from __future__ import absolute_import # import all api files into api package +from mailerlite.sdk.brand_styles import BrandStyles from mailerlite.sdk.campaigns import Campaigns +from mailerlite.sdk.emails import Emails from mailerlite.sdk.fields import Fields +from mailerlite.sdk.files import Files from mailerlite.sdk.forms import Forms from mailerlite.sdk.groups import Groups from mailerlite.sdk.segments import Segments diff --git a/mailerlite/sdk/_params.py b/mailerlite/sdk/_params.py new file mode 100644 index 0000000..f781cfb --- /dev/null +++ b/mailerlite/sdk/_params.py @@ -0,0 +1,52 @@ +from __future__ import absolute_import + +import re + +from mailerlite.exceptions import EmptyIdError, InvalidIdError + +_ID_PATTERN = re.compile(r"[0-9]+") + + +def path_id(value, name): + if value is None or value == "": + raise EmptyIdError(f"`{name}` must not be empty.") + + if isinstance(value, bool) or not isinstance(value, (str, int)): + raise TypeError( + f"`{name}` type is not valid. Expected `str` or `int`, got {type(value)}." + ) + + value = str(value) + # fullmatch, because `$` also matches before a final new line, + # and str.isdigit() also accepts digits that are not ASCII. + if not _ID_PATTERN.fullmatch(value): + raise InvalidIdError(f"`{name}` must contain only digits, got {value!r}.") + + return value + + +def require_dict(value, name): + if not isinstance(value, dict): + raise TypeError( + f"`{name}` type is not valid. Expected `dict`, got {type(value)}." + ) + + +def without_none(params): + return {key: value for key, value in params.items() if value is not None} + + +def filter_params(filter): + if filter is None: + return {} + + require_dict(filter, "filter") + + return {f"filter[{key}]": value for key, value in without_none(filter).items()} + + +def idempotency_headers(key): + if key is None: + return None + + return {"Idempotency-Key": key} diff --git a/mailerlite/sdk/brand_styles.py b/mailerlite/sdk/brand_styles.py new file mode 100644 index 0000000..b0633c4 --- /dev/null +++ b/mailerlite/sdk/brand_styles.py @@ -0,0 +1,26 @@ +from __future__ import absolute_import + +from mailerlite.response import ApiResponse + + +class BrandStyles(object): + # Brand styles base API uri + base_api_url = "api/brand-styles" + + def __init__(self, api_client): + self.api_client = api_client + + def get(self): + """ + Get the brand styles + + Returns the palettes of the account: `brand` (the brand styles of the + account, or `None` if it has none), `default` and the `presets`. + Match a palette on its `identifier`, not on its `name`. + + :raises: :class: `ApiError` : API request failed + :return: JSON object + :rtype: ApiResponse + """ + + return ApiResponse(self.api_client.request("GET", self.base_api_url)) diff --git a/mailerlite/sdk/campaigns.py b/mailerlite/sdk/campaigns.py index e1e3a39..7c5b0cb 100644 --- a/mailerlite/sdk/campaigns.py +++ b/mailerlite/sdk/campaigns.py @@ -13,6 +13,12 @@ def create(self, campaign): Create a campaign Ref: https://developers.mailerlite.com/docs/campaigns.html#create-a-campaign + To build the emails with the simple editor, set `editor` to `EDITOR_SIMPLE` + in each email, and do not send `content`. Then edit the emails with + `client.emails`. `template_id` is optional: a shared template of the + simple editor to start from. It needs `editor`. All emails of the campaign + must have the same `editor` and `template_id`. + :param campaign: dict A dict that contains all campaign related arguments :return: JSON array :rtype: dict @@ -27,6 +33,8 @@ def update(self, campaign_id, campaign): Provides ability to update single campaign. Ref: https://developers.mailerlite.com/docs/campaigns.html#update-campaign + Only `create()` accepts `editor` and `template_id` in the emails. + :param campaign_id: int Id of the campaign :param campaign: dict A dict that contains all campaign related arguments :return: JSON array diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py new file mode 100644 index 0000000..7ac3e67 --- /dev/null +++ b/mailerlite/sdk/emails.py @@ -0,0 +1,533 @@ +from __future__ import absolute_import + +from mailerlite.response import ApiResponse +from mailerlite.sdk import _params + +# The error code of a write with an etag that is not the current etag of the email +VERSION_CONFLICT = "version_conflict" + +# The value of `editor` in the emails of `campaigns.create()` for the simple editor +EDITOR_SIMPLE = "simple" + + +class BlockType(object): + """Block types. The API can also return types that are not in this list.""" + + BLOCKQUOTE = "blockquote" + BULLETED_LIST = "bulleted_list" + BUTTON = "button" + CODE = "code" + DIVIDER = "divider" + HEADING = "heading" + IMAGE = "image" + NUMBERED_LIST = "numbered_list" + PARAGRAPH = "paragraph" + SOCIAL_ICON = "social_icon" + # Only reads return this type. You cannot insert a block of this type. + TITLE = "title" + VIDEO = "video" + + +class BlockPosition(object): + """ + Positions of a block. `BEFORE` and `AFTER` also need a `block_id`. + The API ignores the `block_id` of `START` and `END`. + """ + + START = "start" + END = "end" + BEFORE = "before" + AFTER = "after" + + +class BrandMode(object): + """Where the palette of an email comes from, in `settings["brand"]["mode"]`""" + + ACCOUNT = "account" + TEMPLATE = "template" + CUSTOM = "custom" + + +class Emails(object): + """ + Emails built with the simple editor + + To create such an email, create a campaign with `"editor": "simple"`. + IDs are strings of digits. You can also give them as int. + + Idempotency keys: if a write fails with a network error, you do not know if + the API did the write. Give an `idempotency_key` to the write, and retry with + the same key and the same arguments. If the first request succeeded, the API + returns the stored response and does not do the write two times. Then + `is_idempotent_replay` of the response is true. Use a new key for each write. + The API keeps a key for 24 hours. The SDK sends the key as you give it, + and the API checks it. If `requests` cannot send the key in a header, + for example a key with a new line, it raises `requests.exceptions.InvalidHeader`. + + Before the request, the SDK checks only the IDs in the URL path and the + `settings` of `update_settings()`. The API checks all other values. + """ + + # Emails base API uri + base_api_url = "api/emails" + + def __init__(self, api_client): + self.api_client = api_client + + def get(self, email_id): + """ + Get an email + + Returns the email with its settings and blocks, and the etag of the email. + + :param email_id: str|int Email ID + :raises: :class: `TypeError` : `email_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` is empty + :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `ApiError` : API request failed, e.g. email was not found + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + + return ApiResponse( + self.api_client.request("GET", f"{self.base_api_url}/{email_id}") + ) + + def get_html(self, email_id): + """ + Get the HTML of an email + + Returns the HTML that the email sends, and the etag of the email. + The API can return a temporary `503` error. + + :param email_id: str|int Email ID + :raises: :class: `TypeError` : `email_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` is empty + :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `ApiError` : API request failed, e.g. email was not found + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + + return ApiResponse( + self.api_client.request("GET", f"{self.base_api_url}/{email_id}/html") + ) + + def get_block(self, email_id, block_id): + """ + Get a block of an email + + Returns the block, its place in the email, and the etag of the email. + + :param email_id: str|int Email ID + :param block_id: str|int Block ID + :raises: :class: `TypeError` : `email_id` or `block_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` or `block_id` is empty + :raises: :class: `InvalidIdError` : an ID does not contain only digits + :raises: :class: `ApiError` : API request failed, e.g. block was not found + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + block_id = _params.path_id(block_id, "block_id") + + return ApiResponse( + self.api_client.request( + "GET", f"{self.base_api_url}/{email_id}/blocks/{block_id}" + ) + ) + + def create_block( + self, + email_id, + etag, + block_type, + editor_content, + placement=None, + *, + idempotency_key=None, + ): + """ + Insert a block in an email + + Returns the new block and the new etag of the email. + + :param email_id: str|int Email ID + :param etag: str The etag from the last response for this email + :param block_type: str Block type, see `BlockType`. You cannot insert `title`. + :param editor_content: dict The content of the block. It can be empty. + Its keys are camelCase, for example `textAlign`. + :param placement: dict Optional. `{"position": ..., "block_id": ...}`, + see `BlockPosition`. Without it, the API puts the block at the end. + :param idempotency_key: str Optional. A key to retry the write safely, + see "Idempotency keys" in the class docstring + :raises: :class: `TypeError` : `email_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` is empty + :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `ApiError` : API request failed, e.g. the etag is old + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + body = _block_body(block_type, editor_content, placement) + body["etag"] = etag + headers = _params.idempotency_headers(idempotency_key) + + return ApiResponse( + self.api_client.request( + "POST", + f"{self.base_api_url}/{email_id}/blocks", + body=body, + headers=headers, + ) + ) + + def update_block( + self, email_id, block_id, etag, editor_content, *, idempotency_key=None + ): + """ + Update a block of an email + + Replaces the whole content of the block. The API removes the keys that you + do not send. Returns the block and the new etag of the email. + + :param email_id: str|int Email ID + :param block_id: str|int Block ID + :param etag: str The etag from the last response for this email + :param editor_content: dict The new content of the block. + Its keys are camelCase, for example `textAlign`. + :param idempotency_key: str Optional. A key to retry the write safely, + see "Idempotency keys" in the class docstring + :raises: :class: `TypeError` : `email_id` or `block_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` or `block_id` is empty + :raises: :class: `InvalidIdError` : an ID does not contain only digits + :raises: :class: `ApiError` : API request failed, e.g. the etag is old + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + block_id = _params.path_id(block_id, "block_id") + body = {"editor_content": editor_content, "etag": etag} + headers = _params.idempotency_headers(idempotency_key) + + return ApiResponse( + self.api_client.request( + "PATCH", + f"{self.base_api_url}/{email_id}/blocks/{block_id}", + body=body, + headers=headers, + ) + ) + + def delete_block(self, email_id, block_id, etag, *, idempotency_key=None): + """ + Delete a block of an email + + Also deletes the blocks in it. Returns the new etag of the email, + so it returns the response and not `True` like the other deletes. + + :param email_id: str|int Email ID + :param block_id: str|int Block ID + :param etag: str The etag from the last response for this email + :param idempotency_key: str Optional. A key to retry the write safely, + see "Idempotency keys" in the class docstring + :raises: :class: `TypeError` : `email_id` or `block_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` or `block_id` is empty + :raises: :class: `InvalidIdError` : an ID does not contain only digits + :raises: :class: `ApiError` : API request failed, e.g. the etag is old + :return: JSON object with only the new etag + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + block_id = _params.path_id(block_id, "block_id") + body = {"etag": etag} + headers = _params.idempotency_headers(idempotency_key) + + return ApiResponse( + self.api_client.request( + "DELETE", + f"{self.base_api_url}/{email_id}/blocks/{block_id}", + body=body, + headers=headers, + ) + ) + + def move_block(self, email_id, block_id, etag, placement, *, idempotency_key=None): + """ + Move a block of an email + + Returns the block, its new place in the email, and the new etag of the email. + + :param email_id: str|int Email ID + :param block_id: str|int Block ID + :param etag: str The etag from the last response for this email + :param placement: dict `{"position": ..., "block_id": ...}`, + see `BlockPosition` + :param idempotency_key: str Optional. A key to retry the write safely, + see "Idempotency keys" in the class docstring + :raises: :class: `TypeError` : `email_id` or `block_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` or `block_id` is empty + :raises: :class: `InvalidIdError` : an ID does not contain only digits + :raises: :class: `ApiError` : API request failed, e.g. the etag is old + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + block_id = _params.path_id(block_id, "block_id") + body = {"placement": placement, "etag": etag} + headers = _params.idempotency_headers(idempotency_key) + + return ApiResponse( + self.api_client.request( + "POST", + f"{self.base_api_url}/{email_id}/blocks/{block_id}/move", + body=body, + headers=headers, + ) + ) + + def validate_block(self, block_type, editor_content, placement=None): + """ + Validate a block + + Checks a block without an email and without an etag. An invalid block + is not an error: the response has `valid` set to false and the `errors`. + + :param block_type: str Block type, see `BlockType` + :param editor_content: dict The content of the block + :param placement: dict Optional. `{"position": ..., "block_id": ...}`, + see `BlockPosition` + :raises: :class: `ApiError` : API request failed + :return: JSON object with `valid`, `errors` and `schema` + :rtype: ApiResponse + """ + + body = _block_body(block_type, editor_content, placement) + + return ApiResponse( + self.api_client.request( + "POST", f"{self.base_api_url}/blocks/validate", body=body + ) + ) + + def update_settings(self, email_id, etag, settings, *, idempotency_key=None): + """ + Update the design settings of an email + + Send only the keys to change, in the shape of `settings` in `get()`. + The API stores each value that you send, also a value that the email + inherits from the account. + An empty string unsets a brand colour, and the email then uses the + account value. An empty editor document, `{'editor_content': {}}` or + `{'editor_content': {'content': []}}`, unsets a footer text. Other + colours, sizes, `button.border_radius` and `brand.style` cannot be + empty: an empty string gives a `422` error. + `footer.social_links` replaces the whole list: keep the `link_id` of + each link to keep its click history. + + The response can have `rejected` when the API removed unsafe values. + + :param email_id: str|int Email ID + :param etag: str The etag from the last response for this email + :param settings: dict The settings to change, without `etag` + :param idempotency_key: str Optional. A key to retry the write safely, + see "Idempotency keys" in the class docstring + :raises: :class: `TypeError` : `email_id` or `settings` type is not valid + :raises: :class: `ValueError` : `settings` contains `etag` + :raises: :class: `EmptyIdError` : `email_id` is empty + :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `ApiError` : API request failed, e.g. the etag is old + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + body = {**_settings(settings), "etag": etag} + headers = _params.idempotency_headers(idempotency_key) + + return ApiResponse( + self.api_client.request( + "PATCH", + f"{self.base_api_url}/{email_id}/settings", + body=body, + headers=headers, + ) + ) + + def validate_settings(self, email_id, settings): + """ + Validate a change to the design settings of an email + + Checks the settings with the rules of `update_settings()` and saves nothing. + It needs no etag. Invalid settings are not an error: the response has + `valid` set to false and the `errors`. + + :param email_id: str|int Email ID + :param settings: dict The settings that you want to send to `update_settings()` + :raises: :class: `TypeError` : `email_id` or `settings` type is not valid + :raises: :class: `ValueError` : `settings` contains `etag` + :raises: :class: `EmptyIdError` : `email_id` is empty + :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `ApiError` : API request failed + :return: JSON object with `valid`, `errors`, `schema` and maybe `rejected` + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + body = _settings(settings) + + return ApiResponse( + self.api_client.request( + "POST", + f"{self.base_api_url}/{email_id}/settings/validate", + body=body, + ) + ) + + def create_template(self, email_id, name, *, idempotency_key=None): + """ + Save an email as a template + + Copies the email to a new template with the given name. + The API cannot delete templates. + + :param email_id: str|int Email ID + :param name: str Name of the template + :param idempotency_key: str Optional. A key to retry the write safely, + see "Idempotency keys" in the class docstring + :raises: :class: `TypeError` : `email_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` is empty + :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `ApiError` : API request failed + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + headers = _params.idempotency_headers(idempotency_key) + + return ApiResponse( + self.api_client.request( + "POST", + f"{self.base_api_url}/{email_id}/templates", + body={"name": name}, + headers=headers, + ) + ) + + def list_versions(self, email_id, page=None, limit=None): + """ + List the versions of an email + + Returns the saved versions, newest first. `current` marks the version + that the email has now. + + :param email_id: str|int Email ID + :param page: int Optional. Page number + :param limit: int Optional. Number of versions on a page + :raises: :class: `TypeError` : `email_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` is empty + :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `ApiError` : API request failed + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + query_params = _params.without_none({"page": page, "limit": limit}) + + return ApiResponse( + self.api_client.request( + "GET", f"{self.base_api_url}/{email_id}/versions", query_params + ) + ) + + def create_version(self, email_id, *, idempotency_key=None): + """ + Save a version of an email + + Saves the content and the settings of the email as they are now. + If nothing changed after the last version, the API saves no new version + and returns the last version. The status code is `201` for a new version + and `200` for the last version. Both are a success. + + :param email_id: str|int Email ID + :param idempotency_key: str Optional. A key to retry the write safely, + see "Idempotency keys" in the class docstring + :raises: :class: `TypeError` : `email_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` is empty + :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `ApiError` : API request failed + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + headers = _params.idempotency_headers(idempotency_key) + + return ApiResponse( + self.api_client.request( + "POST", f"{self.base_api_url}/{email_id}/versions", headers=headers + ) + ) + + def restore_version(self, email_id, version_id, etag, *, idempotency_key=None): + """ + Restore a version of an email + + Restores the email to the version, and returns the email in the shape + of `get()` with the new etag. Before the restore, the API saves the + current state as a version, so you can undo the restore. + + :param email_id: str|int Email ID + :param version_id: str|int Version ID + :param etag: str The etag from the last response for this email + :param idempotency_key: str Optional. A key to retry the write safely, + see "Idempotency keys" in the class docstring + :raises: :class: `TypeError` : `email_id` or `version_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` or `version_id` is empty + :raises: :class: `InvalidIdError` : an ID does not contain only digits + :raises: :class: `ApiError` : API request failed, e.g. the etag is old + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + version_id = _params.path_id(version_id, "version_id") + body = {"etag": etag} + headers = _params.idempotency_headers(idempotency_key) + + return ApiResponse( + self.api_client.request( + "POST", + f"{self.base_api_url}/{email_id}/versions/{version_id}/restore", + body=body, + headers=headers, + ) + ) + + +def _block_body(block_type, editor_content, placement): + body = {"type": block_type, "editor_content": editor_content} + if placement is not None: + body["placement"] = placement + + return body + + +def _settings(settings): + _params.require_dict(settings, "settings") + # The body of update_settings() has the settings and the etag at the same level + if "etag" in settings: + raise ValueError("`settings` must not contain `etag`. Use the `etag` argument.") + + return settings diff --git a/mailerlite/sdk/files.py b/mailerlite/sdk/files.py new file mode 100644 index 0000000..cffe2ed --- /dev/null +++ b/mailerlite/sdk/files.py @@ -0,0 +1,58 @@ +from __future__ import absolute_import + +from mailerlite.response import ApiResponse +from mailerlite.sdk import _params + + +class FileType(object): + """File types for `filter={"type": ...}`""" + + IMAGE = "image" + PDF = "pdf" + EPUB = "epub" + + +class FileSort(object): + """Sort values. The values that start with `-` sort in descending order.""" + + CREATED_AT = "created_at" + CREATED_AT_DESCENDING = "-created_at" + NAME = "name" + NAME_DESCENDING = "-name" + SIZE = "size" + SIZE_DESCENDING = "-size" + + +class Files(object): + # Files base API uri + base_api_url = "api/files" + + def __init__(self, api_client): + self.api_client = api_client + + def list(self, filter=None, sort=None, page=None, limit=None): + """ + List the files + + Returns the files of the media library. Use their `url` in image blocks. + `width` and `height` are `None` for files that are not images. + + :param filter: dict Optional. `name` (part of the file name) and `type` + (see `FileType`) + :param sort: str Optional. See `FileSort`. The default is `-created_at`. + :param page: int Optional. Page number + :param limit: int Optional. Number of files on a page, from 1 to 100 + :raises: :class: `TypeError` : `filter` type is not valid + :raises: :class: `ApiError` : API request failed, e.g. the type is not valid + :return: JSON object + :rtype: ApiResponse + """ + + query_params = { + **_params.filter_params(filter), + **_params.without_none({"sort": sort, "page": page, "limit": limit}), + } + + return ApiResponse( + self.api_client.request("GET", self.base_api_url, query_params) + ) diff --git a/pyproject.toml b/pyproject.toml index 3e81aea..c0ee4c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mailerlite" -version = "0.2.0" +version = "0.3.0" description = "The official MailerLite Python SDK" authors = [{name = "MailerLite", email = "tech@mailerlite.com"}] requires-python = ">=3.10" diff --git a/setup.py b/setup.py index df55550..a0b71e2 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -__version__ = "0.1.10" +__version__ = "0.3.0" def _read_long_description(): diff --git a/tests/api_client_test.py b/tests/api_client_test.py index adb7433..c4e0cd9 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -1,5 +1,9 @@ +import pickle + import pytest +import requests from mailerlite.api_client import ApiClient +from mailerlite.exceptions import ApiError class TestApiClient: @@ -49,3 +53,170 @@ def test_it_will_return_error_if_request_method_is_not_supported(self): client = ApiClient({}) with pytest.raises(ValueError): client.request("PPOST", "some-url") + + def test_mock_api_records_the_prepared_request(self, mock_api): + mock_api.add_response(200, {"data": []}) + client = ApiClient({"api_key": "1234567890"}) + + response = client.request("GET", "api/timezones", {"filter[name]": "a b"}) + + assert response.json() == {"data": []} + assert len(mock_api.requests) == 1 + assert mock_api.last_request.method == "GET" + assert ( + mock_api.last_request.url + == "https://connect.mailerlite.com/api/timezones?filter%5Bname%5D=a+b" + ) + assert mock_api.last_request.headers["Authorization"] == "Bearer 1234567890" + assert mock_api.last_body is None + + def test_patch_request_sends_the_json_body(self, mock_api): + mock_api.add_response(200, {"data": {}}) + client = ApiClient({}) + + client.request("PATCH", "api/emails/1/settings", body={"etag": "42"}) + + assert mock_api.last_request.method == "PATCH" + assert mock_api.last_body == {"etag": "42"} + + def test_delete_request_sends_the_json_body(self, mock_api): + mock_api.add_response(200, {"etag": "43"}) + client = ApiClient({}) + + client.request("DELETE", "api/emails/1/blocks/2", body={"etag": "42"}) + + assert mock_api.last_request.method == "DELETE" + assert mock_api.last_body == {"etag": "42"} + + def test_delete_request_without_a_body_sends_no_body(self, mock_api): + mock_api.add_response(204) + client = ApiClient({}) + + client.request("DELETE", "api/groups/1") + + assert mock_api.last_request.method == "DELETE" + assert mock_api.last_request.body is None + + @pytest.mark.parametrize("method", ["POST", "PUT", "PATCH"]) + def test_write_request_without_a_body_sends_no_body(self, mock_api, method): + mock_api.add_response(200, {"data": {}}) + client = ApiClient({}) + + client.request(method, "api/emails/1/versions") + + assert mock_api.last_request.method == method + assert mock_api.last_request.body is None + + def test_version_conflict_raises_api_error_with_code_and_etag(self, mock_api): + mock_api.add_response( + 409, + { + "message": "The email changed. Get it again.", + "code": "version_conflict", + "etag": "44", + }, + ) + client = ApiClient({}) + + with pytest.raises(ApiError) as error: + client.request("PATCH", "api/emails/1/blocks/2", body={"etag": "42"}) + + assert isinstance(error.value, requests.HTTPError) + assert error.value.response.status_code == 409 + assert error.value.status_code == 409 + assert error.value.message == "The email changed. Get it again." + assert error.value.code == "version_conflict" + assert error.value.etag == "44" + assert error.value.errors is None + assert str(error.value) == ( + "409 Client Error: Conflict for url: " + "https://connect.mailerlite.com/api/emails/1/blocks/2 " + "(version_conflict: The email changed. Get it again.)" + ) + + def test_validation_error_raises_api_error_with_errors(self, mock_api): + mock_api.add_response( + 422, + { + "message": "The type field is required.", + "errors": {"type": ["The type field is required."]}, + }, + ) + client = ApiClient({}) + + with pytest.raises(ApiError) as error: + client.request("POST", "api/emails/1/blocks", body={}) + + assert error.value.message == "The type field is required." + assert error.value.code is None + assert error.value.etag is None + assert error.value.errors == {"type": ["The type field is required."]} + # The reason phrase of 422 depends on the Python version + reason = error.value.response.reason + assert str(error.value) == ( + f"422 Client Error: {reason} for url: " + "https://connect.mailerlite.com/api/emails/1/blocks " + "(The type field is required.)" + ) + + def test_error_without_a_json_body_raises_api_error(self, mock_api): + mock_api.add_response(503, "Service Unavailable", {"Content-Type": "text/html"}) + client = ApiClient({}) + + with pytest.raises(ApiError) as error: + client.request("GET", "api/emails/1/html") + + assert error.value.status_code == 503 + assert error.value.message is None + assert error.value.code is None + assert error.value.etag is None + assert error.value.errors is None + assert str(error.value) == ( + "503 Server Error: Service Unavailable for url: " + "https://connect.mailerlite.com/api/emails/1/html" + ) + + def test_api_error_survives_a_pickle_round_trip(self, mock_api): + mock_api.add_response( + 409, + { + "message": "The email changed. Get it again.", + "code": "version_conflict", + "etag": "44", + }, + ) + client = ApiClient({}) + + with pytest.raises(ApiError) as error: + client.request("PATCH", "api/emails/1/blocks/2", body={"etag": "42"}) + + copy = pickle.loads(pickle.dumps(error.value)) + + assert isinstance(copy, ApiError) + assert str(copy) == str(error.value) + assert copy.status_code == 409 + assert copy.message == "The email changed. Get it again." + assert copy.code == "version_conflict" + assert copy.etag == "44" + assert copy.errors is None + assert copy.response.status_code == 409 + + def test_api_error_is_exported_from_the_package(self): + import mailerlite + + assert mailerlite.ApiError is ApiError + + def test_request_adds_the_given_headers(self, mock_api): + mock_api.add_response(200, {"data": {}}) + mock_api.add_response(200, {"data": {}}) + client = ApiClient({"api_key": "1234567890"}) + + client.request( + "POST", "api/emails/1/versions", headers={"Idempotency-Key": "k1"} + ) + assert mock_api.last_request.headers["Idempotency-Key"] == "k1" + assert mock_api.last_request.headers["Authorization"] == "Bearer 1234567890" + + client.request("POST", "api/emails/1/versions") + assert "Idempotency-Key" not in mock_api.last_request.headers + assert "Idempotency-Key" not in client.headers diff --git a/tests/brand_styles_test.py b/tests/brand_styles_test.py new file mode 100644 index 0000000..9a7356b --- /dev/null +++ b/tests/brand_styles_test.py @@ -0,0 +1,46 @@ +import pytest +from mailerlite import Client +from mailerlite.response import ApiResponse + +from tests.conftest import load_fixture + +URL = "https://connect.mailerlite.com/api/brand-styles" + + +@pytest.fixture +def client(): + return Client({"api_key": "1234567890"}) + + +class TestBrandStyles: + def test_api_url_is_properly_set(self, client): + assert client.brand_styles.base_api_url == "api/brand-styles" + + def test_get_returns_the_palettes(self, client, mock_api): + mock_api.add_response(200, load_fixture("brand_styles.json")) + + response = client.brand_styles.get() + + assert mock_api.last_request.method == "GET" + assert mock_api.last_request.url == URL + assert mock_api.last_body is None + assert isinstance(response, ApiResponse) + data = response["data"] + assert data["brand"]["identifier"] == "account" + assert data["brand"]["primary_color"] == "#2cb191" + assert data["default"]["identifier"] == "default-brand" + assert [preset["identifier"] for preset in data["presets"]] == [ + "preset-business-casual", + "preset-retro", + ] + assert data["presets"][1]["font_family"] == "Courier New, monospace" + + def test_get_returns_no_brand_when_the_account_has_none(self, client, mock_api): + body = load_fixture("brand_styles.json") + body["data"]["brand"] = None + mock_api.add_response(200, body) + + response = client.brand_styles.get() + + assert response["data"]["brand"] is None + assert response["data"]["default"]["identifier"] == "default-brand" diff --git a/tests/campaigns_test.py b/tests/campaigns_test.py index 4a58e85..b3d7b04 100644 --- a/tests/campaigns_test.py +++ b/tests/campaigns_test.py @@ -6,8 +6,11 @@ import requests import vcr from dotenv import load_dotenv +from mailerlite.sdk.emails import EDITOR_SIMPLE from pytest import fixture +from tests.conftest import load_fixture + @fixture def campaign_keys(): @@ -250,3 +253,51 @@ def test_list_of_all_supported_languages_in_campaign(self, campaign_language_key assert isinstance(response, dict) assert isinstance(response["data"], list) assert set(campaign_language_keys).issubset(response["data"][0].keys()) + + +class TestCreateSimpleEditorCampaign: + def test_create_sends_the_editor_and_no_content(self, mock_api): + mock_api.add_response(201, load_fixture("campaign_simple_editor.json")) + client = MailerLite.Client({"api_key": "1234567890"}) + params = { + "name": "Simple editor campaign", + "type": "regular", + "emails": [ + { + "subject": "Hello", + "from_name": "Sender", + "from": "sender@example.com", + "reply_to": "reply@example.com", + "editor": EDITOR_SIMPLE, + "template_id": 789, + } + ], + } + + response = client.campaigns.create(params) + + assert mock_api.last_request.method == "POST" + assert ( + mock_api.last_request.url == "https://connect.mailerlite.com/api/campaigns" + ) + assert mock_api.last_body == { + "name": "Simple editor campaign", + "type": "regular", + "emails": [ + { + "subject": "Hello", + "from_name": "Sender", + "from": "sender@example.com", + "reply_to": "reply@example.com", + "editor": "simple", + "template_id": 789, + } + ], + } + assert "content" not in mock_api.last_body["emails"][0] + assert response["data"]["emails"][0]["id"] == "123" + assert response["data"]["emails"][0]["type"] == "builder_inline_email" + + def test_editor_simple_is_exported_from_the_package(self): + assert EDITOR_SIMPLE == "simple" + assert MailerLite.EDITOR_SIMPLE == EDITOR_SIMPLE diff --git a/tests/conftest.py b/tests/conftest.py index 60b49a9..9d0f1a0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,68 @@ +import json +from http.client import responses +from pathlib import Path + import pytest +import requests + +FIXTURES_DIR = Path(__file__).parent / "fixtures" def pytest_configure(): return {"entity_id": 0, "entity_email": "random@mailerlite.com"} + + +def load_fixture(name): + with open(FIXTURES_DIR / name) as fd: + return json.load(fd) + + +class MockApi(object): + def __init__(self): + self.requests = [] + self.responses = [] + + def add_response(self, status_code=200, body=None, headers=None): + if isinstance(body, (dict, list)): + body = json.dumps(body) + if isinstance(body, str): + body = body.encode() + + self.responses.append((status_code, body or b"", headers or {})) + + def send(self, request): + self.requests.append(request) + status_code, body, headers = self.responses.pop(0) + + response = requests.Response() + response.status_code = status_code + response.reason = responses.get(status_code, "") + response.headers.update({"Content-Type": "application/json", **headers}) + response._content = body + response.url = request.url + response.request = request + + return response + + @property + def last_request(self): + return self.requests[-1] + + @property + def last_body(self): + body = self.last_request.body + return None if body is None else json.loads(body) + + +@pytest.fixture +def mock_api(mocker): + """Replaces the HTTP transport. No request leaves the machine.""" + + api = MockApi() + mocker.patch( + "requests.Session.send", + autospec=True, + side_effect=lambda session, request, **kwargs: api.send(request), + ) + + return api diff --git a/tests/emails_test.py b/tests/emails_test.py new file mode 100644 index 0000000..27396d3 --- /dev/null +++ b/tests/emails_test.py @@ -0,0 +1,770 @@ +import json +import pytest +import requests +import mailerlite +from mailerlite import Client +from mailerlite.exceptions import ( + ApiError, + EmptyIdError, + InvalidIdError, +) +from mailerlite.response import ApiResponse +from mailerlite.sdk.emails import ( + VERSION_CONFLICT, + BlockPosition, + BlockType, + BrandMode, +) + +from tests.conftest import load_fixture + +BASE_URL = "https://connect.mailerlite.com/api/emails" + + +@pytest.fixture +def client(): + return Client({"api_key": "1234567890"}) + + +class TestEmails: + def test_api_url_is_properly_set(self, client): + assert client.emails.base_api_url == "api/emails" + + def test_get_returns_the_email(self, client, mock_api): + mock_api.add_response(200, load_fixture("email.json")) + + response = client.emails.get("123") + + assert mock_api.last_request.method == "GET" + assert mock_api.last_request.url == f"{BASE_URL}/123" + assert mock_api.last_body is None + assert isinstance(response, ApiResponse) + assert response.status_code == 200 + assert response["etag"] == "42" + email = response["data"] + assert email["id"] == "123" + assert email["type"] == "builder_inline_email" + assert email["emailable_type"] == "campaigns" + assert email["emailable_id"] == "456" + assert email["settings"]["brand"]["mode"] == "account" + assert email["settings"]["typography"]["heading"]["h1"]["size"] == "32" + assert email["settings"]["button"]["border_radius"] == "4" + assert email["settings"]["footer"]["show_preferences"] is False + assert email["settings"]["footer"]["social_links"][0]["link_id"] == 7 + assert email["blocks"][0]["editor_content"]["attrs"] == {"textAlign": "left"} + assert email["blocks"][1]["children"][0]["id"] == "3" + + def test_get_accepts_an_int_id(self, client, mock_api): + mock_api.add_response(200, load_fixture("email.json")) + + client.emails.get(123) + + assert mock_api.last_request.url == f"{BASE_URL}/123" + + @pytest.mark.parametrize("email_id", ["", None]) + def test_get_rejects_an_empty_id(self, client, mock_api, email_id): + with pytest.raises(EmptyIdError): + client.emails.get(email_id) + + assert mock_api.requests == [] + + @pytest.mark.parametrize( + "email_id", ["123/html", "123%2Fhtml", "12 3", "-1", "²", "123\n", "abc"] + ) + def test_get_rejects_an_id_that_is_not_only_digits( + self, client, mock_api, email_id + ): + with pytest.raises(InvalidIdError): + client.emails.get(email_id) + + assert mock_api.requests == [] + + @pytest.mark.parametrize("email_id", [True, 1.5, ["123"]]) + def test_get_rejects_an_id_of_the_wrong_type(self, client, mock_api, email_id): + with pytest.raises(TypeError): + client.emails.get(email_id) + + assert mock_api.requests == [] + + def test_id_errors_are_value_errors(self): + assert issubclass(EmptyIdError, ValueError) + assert issubclass(InvalidIdError, ValueError) + + def test_get_html_returns_the_html(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_html.json")) + + response = client.emails.get_html("123") + + assert mock_api.last_request.method == "GET" + assert mock_api.last_request.url == f"{BASE_URL}/123/html" + assert mock_api.last_body is None + assert response["data"]["html"] == ( + "
Hello
" + ) + assert response["etag"] == "42" + + def test_get_html_rejects_an_invalid_id(self, client, mock_api): + with pytest.raises(InvalidIdError): + client.emails.get_html("123/blocks") + + assert mock_api.requests == [] + + def test_get_block_returns_the_block(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_block.json")) + + response = client.emails.get_block("123", "57") + + assert mock_api.last_request.method == "GET" + assert mock_api.last_request.url == f"{BASE_URL}/123/blocks/57" + assert mock_api.last_body is None + assert response["etag"] == "43" + block = response["data"] + assert block["id"] == "57" + assert block["type"] == "paragraph" + assert block["editor_content"]["attrs"] == {"textAlign": "left"} + assert block["placement"] == {"after": "56", "before": None} + + @pytest.mark.parametrize( + "email_id, block_id, error", + [ + ("", "57", EmptyIdError), + ("123", "", EmptyIdError), + ("123", None, EmptyIdError), + ("123/html", "57", InvalidIdError), + ("123", "57/move", InvalidIdError), + ], + ) + def test_get_block_rejects_an_invalid_id( + self, client, mock_api, email_id, block_id, error + ): + with pytest.raises(error): + client.emails.get_block(email_id, block_id) + + assert mock_api.requests == [] + + def test_create_block_inserts_the_block(self, client, mock_api): + mock_api.add_response(201, load_fixture("email_block_created.json")) + editor_content = { + "attrs": {"textAlign": "center"}, + "content": [{"text": "Hi", "type": "text"}], + } + + response = client.emails.create_block( + "123", "42", BlockType.PARAGRAPH, editor_content + ) + + assert mock_api.last_request.method == "POST" + assert mock_api.last_request.url == f"{BASE_URL}/123/blocks" + assert mock_api.last_body == { + "type": "paragraph", + "editor_content": editor_content, + "etag": "42", + } + assert response.status_code == 201 + assert response["etag"] == "43" + assert response["data"]["id"] == "58" + assert response["data"]["editor_content"]["attrs"] == {"textAlign": "center"} + assert response["data"]["placement"] == {"after": "57", "before": None} + + def test_create_block_sends_the_placement(self, client, mock_api): + mock_api.add_response(201, load_fixture("email_block_created.json")) + + client.emails.create_block( + "123", + "42", + BlockType.DIVIDER, + {}, + placement={"position": BlockPosition.AFTER, "block_id": "57"}, + ) + + assert mock_api.last_body == { + "type": "divider", + "editor_content": {}, + "placement": {"position": "after", "block_id": "57"}, + "etag": "42", + } + + def test_create_block_rejects_an_invalid_email_id(self, client, mock_api): + with pytest.raises(InvalidIdError): + client.emails.create_block("123/html", "42", BlockType.DIVIDER, {}) + + assert mock_api.requests == [] + + def test_write_with_an_old_etag_raises_a_version_conflict(self, client, mock_api): + mock_api.add_response( + 409, + { + "message": "The email has changed since you read it.", + "code": "version_conflict", + "etag": "44", + }, + ) + + with pytest.raises(ApiError) as error: + client.emails.create_block("123", "42", BlockType.DIVIDER, {}) + + assert error.value.status_code == 409 + assert error.value.code == VERSION_CONFLICT + assert error.value.etag == "44" + assert "version_conflict" in str(error.value) + + def test_block_constants(self): + assert BlockType.BLOCKQUOTE == "blockquote" + assert BlockType.BULLETED_LIST == "bulleted_list" + assert BlockType.BUTTON == "button" + assert BlockType.CODE == "code" + assert BlockType.DIVIDER == "divider" + assert BlockType.HEADING == "heading" + assert BlockType.IMAGE == "image" + assert BlockType.NUMBERED_LIST == "numbered_list" + assert BlockType.PARAGRAPH == "paragraph" + assert BlockType.SOCIAL_ICON == "social_icon" + assert BlockType.TITLE == "title" + assert BlockType.VIDEO == "video" + assert BlockPosition.START == "start" + assert BlockPosition.END == "end" + assert BlockPosition.BEFORE == "before" + assert BlockPosition.AFTER == "after" + assert VERSION_CONFLICT == "version_conflict" + + def test_write_names_are_exported_from_the_package(self): + assert mailerlite.BlockType is BlockType + assert mailerlite.BlockPosition is BlockPosition + assert mailerlite.VERSION_CONFLICT == VERSION_CONFLICT + + def test_update_block_replaces_the_editor_content(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_block_updated.json")) + editor_content = {"content": [{"text": "Hello again", "type": "text"}]} + + response = client.emails.update_block("123", "57", "43", editor_content) + + assert mock_api.last_request.method == "PATCH" + assert mock_api.last_request.url == f"{BASE_URL}/123/blocks/57" + assert mock_api.last_body == {"editor_content": editor_content, "etag": "43"} + assert response["etag"] == "44" + assert response["data"]["editor_content"] == editor_content + assert response["data"]["placement"] == {"after": "56", "before": "58"} + + @pytest.mark.parametrize( + "block_id, etag, editor_content, error", + [ + ("", "43", {}, EmptyIdError), + ("57/move", "43", {}, InvalidIdError), + ], + ) + def test_update_block_rejects_invalid_arguments( + self, client, mock_api, block_id, etag, editor_content, error + ): + with pytest.raises(error): + client.emails.update_block("123", block_id, etag, editor_content) + + assert mock_api.requests == [] + + def test_delete_block_sends_the_etag_in_the_body(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_etag.json")) + + response = client.emails.delete_block("123", "57", "44") + + assert mock_api.last_request.method == "DELETE" + assert mock_api.last_request.url == f"{BASE_URL}/123/blocks/57" + assert mock_api.last_body == {"etag": "44"} + assert response == {"etag": "45"} + assert response.status_code == 200 + + @pytest.mark.parametrize( + "block_id, etag, error", + [ + (None, "44", EmptyIdError), + ("57%2F", "44", InvalidIdError), + ], + ) + def test_delete_block_rejects_invalid_arguments( + self, client, mock_api, block_id, etag, error + ): + with pytest.raises(error): + client.emails.delete_block("123", block_id, etag) + + assert mock_api.requests == [] + + def test_move_block_sends_the_placement(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_block_moved.json")) + + response = client.emails.move_block( + "123", "57", "45", {"position": BlockPosition.START} + ) + + assert mock_api.last_request.method == "POST" + assert mock_api.last_request.url == f"{BASE_URL}/123/blocks/57/move" + assert mock_api.last_body == {"placement": {"position": "start"}, "etag": "45"} + assert response["etag"] == "46" + assert response["data"]["id"] == "57" + assert response["data"]["placement"] == {"after": None, "before": "1"} + + @pytest.mark.parametrize( + "block_id, etag, placement, error", + [ + ("", "45", {"position": "end"}, EmptyIdError), + ("57/x", "45", {"position": "end"}, InvalidIdError), + ], + ) + def test_move_block_rejects_invalid_arguments( + self, client, mock_api, block_id, etag, placement, error + ): + with pytest.raises(error): + client.emails.move_block("123", block_id, etag, placement) + + assert mock_api.requests == [] + + def test_validate_block_sends_the_block_without_an_etag(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_block_validation_valid.json")) + + response = client.emails.validate_block( + BlockType.HEADING, + {"attrs": {"level": 2}}, + placement={"position": BlockPosition.END}, + ) + + assert mock_api.last_request.method == "POST" + assert mock_api.last_request.url == f"{BASE_URL}/blocks/validate" + assert mock_api.last_body == { + "type": "heading", + "editor_content": {"attrs": {"level": 2}}, + "placement": {"position": "end"}, + } + assert response["valid"] is True + assert response["errors"] == {} + assert response["schema"] == {"version": "2.73.2", "hash": "0f3a9c"} + + def test_validate_block_returns_the_errors_of_an_invalid_block( + self, client, mock_api + ): + mock_api.add_response(200, load_fixture("email_block_validation_invalid.json")) + + response = client.emails.validate_block("table", {}) + + assert mock_api.last_body == {"type": "table", "editor_content": {}} + assert response.status_code == 200 + assert response["valid"] is False + assert response["errors"] == {"type": ["The selected type is invalid."]} + + def test_update_settings_sends_only_the_given_keys(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_settings.json")) + settings = { + "brand": {"mode": BrandMode.CUSTOM, "background_color": "#f4f4f4"}, + "footer": {"info_text": {"editor_content": {"content": []}}}, + "typography": {"link": {"color": ""}}, + } + + response = client.emails.update_settings("123", "46", settings) + + assert mock_api.last_request.method == "PATCH" + assert mock_api.last_request.url == f"{BASE_URL}/123/settings" + assert mock_api.last_body == { + "brand": {"mode": "custom", "background_color": "#f4f4f4"}, + "footer": {"info_text": {"editor_content": {"content": []}}}, + "typography": {"link": {"color": ""}}, + "etag": "46", + } + assert response["etag"] == "47" + assert response["data"]["brand"]["mode"] == "custom" + assert response["data"]["brand"]["background_color"] == "#f4f4f4" + assert response["data"]["footer"]["social_links"][0]["link_id"] == 7 + assert response["rejected"] == { + "message": "Some attributes were rejected.", + "keys": ["footer.social_links[0].url"], + } + + def test_update_settings_does_not_change_the_given_dict(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_settings.json")) + settings = {"layout": {"alignment": "left"}} + + client.emails.update_settings("123", "46", settings) + + assert settings == {"layout": {"alignment": "left"}} + + @pytest.mark.parametrize( + "email_id, etag, settings, error", + [ + ("", "46", {}, EmptyIdError), + ("123/html", "46", {}, InvalidIdError), + ("123", "46", None, TypeError), + ("123", "46", {"etag": "45"}, ValueError), + ], + ) + def test_update_settings_rejects_invalid_arguments( + self, client, mock_api, email_id, etag, settings, error + ): + with pytest.raises(error): + client.emails.update_settings(email_id, etag, settings) + + assert mock_api.requests == [] + + def test_brand_mode_constants(self): + assert BrandMode.ACCOUNT == "account" + assert BrandMode.TEMPLATE == "template" + assert BrandMode.CUSTOM == "custom" + assert mailerlite.BrandMode is BrandMode + + def test_validate_settings_sends_the_settings_without_an_etag( + self, client, mock_api + ): + mock_api.add_response(200, load_fixture("email_settings_validation.json")) + + response = client.emails.validate_settings("123", {"layout": {"width": "700"}}) + + assert mock_api.last_request.method == "POST" + assert mock_api.last_request.url == f"{BASE_URL}/123/settings/validate" + assert mock_api.last_body == {"layout": {"width": "700"}} + assert response.status_code == 200 + assert response["valid"] is False + assert response["errors"] == { + "layout.width": ["The layout.width field cannot be changed."] + } + assert response["schema"]["version"] == "2.73.2" + assert response["rejected"]["keys"] == ["footer.social_links[0].url"] + + @pytest.mark.parametrize( + "email_id, settings, error", + [ + (None, {}, EmptyIdError), + ("1.5", {}, InvalidIdError), + ("123", [], TypeError), + ("123", None, TypeError), + ("123", {"etag": "45"}, ValueError), + ], + ) + def test_validate_settings_rejects_invalid_arguments( + self, client, mock_api, email_id, settings, error + ): + with pytest.raises(error): + client.emails.validate_settings(email_id, settings) + + assert mock_api.requests == [] + + def test_create_template_saves_the_email_as_a_template(self, client, mock_api): + mock_api.add_response(201, load_fixture("email_template.json")) + + response = client.emails.create_template("123", "Newsletter") + + assert mock_api.last_request.method == "POST" + assert mock_api.last_request.url == f"{BASE_URL}/123/templates" + assert mock_api.last_body == {"name": "Newsletter"} + assert response.status_code == 201 + assert response["data"] == { + "id": "789", + "name": "Newsletter", + "type": "email", + "created_at": "2026-10-28 12:00:00", + } + assert "etag" not in response + + @pytest.mark.parametrize( + "email_id, name, error", + [ + ("", "Newsletter", EmptyIdError), + ("12a", "Newsletter", InvalidIdError), + ], + ) + def test_create_template_rejects_invalid_arguments( + self, client, mock_api, email_id, name, error + ): + with pytest.raises(error): + client.emails.create_template(email_id, name) + + assert mock_api.requests == [] + + def test_list_versions_without_options(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_versions.json")) + + response = client.emails.list_versions("123") + + assert mock_api.last_request.method == "GET" + assert mock_api.last_request.url == f"{BASE_URL}/123/versions" + assert mock_api.last_body is None + assert response["data"][0] == { + "id": "902", + "created_at": "2026-10-28 12:05:00", + "current": True, + } + assert response["data"][1]["current"] is False + assert response["links"]["next"] is None + assert response["meta"]["total"] == 2 + assert "etag" not in response + + def test_list_versions_sends_the_page_and_the_limit(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_versions.json")) + + client.emails.list_versions("123", page=2, limit=10) + + assert mock_api.last_request.url == f"{BASE_URL}/123/versions?page=2&limit=10" + + def test_list_versions_rejects_an_invalid_id(self, client, mock_api): + with pytest.raises(InvalidIdError): + client.emails.list_versions("123/blocks") + + assert mock_api.requests == [] + + @pytest.mark.parametrize("status_code", [201, 200]) + def test_create_version_returns_the_version(self, client, mock_api, status_code): + mock_api.add_response(status_code, load_fixture("email_version.json")) + + response = client.emails.create_version("123") + + assert mock_api.last_request.method == "POST" + assert mock_api.last_request.url == f"{BASE_URL}/123/versions" + assert mock_api.last_request.body is None + assert response.status_code == status_code + assert response["data"] == { + "id": "903", + "created_at": "2026-10-28 12:10:00", + "current": True, + } + assert "etag" not in response + + def test_create_version_rejects_an_empty_id(self, client, mock_api): + with pytest.raises(EmptyIdError): + client.emails.create_version("") + + assert mock_api.requests == [] + + def test_restore_version_returns_the_email(self, client, mock_api): + mock_api.add_response(200, load_fixture("email.json")) + + response = client.emails.restore_version("123", "901", "47") + + assert mock_api.last_request.method == "POST" + assert mock_api.last_request.url == f"{BASE_URL}/123/versions/901/restore" + assert mock_api.last_body == {"etag": "47"} + assert response["etag"] == "42" + assert response["data"]["id"] == "123" + assert response["data"]["blocks"][0]["id"] == "1" + + @pytest.mark.parametrize( + "version_id, etag, error", + [ + ("", "47", EmptyIdError), + ("901/restore", "47", InvalidIdError), + ], + ) + def test_restore_version_rejects_invalid_arguments( + self, client, mock_api, version_id, etag, error + ): + with pytest.raises(error): + client.emails.restore_version("123", version_id, etag) + + assert mock_api.requests == [] + + +# Each email write that accepts an idempotency key: (method, arguments, fixture) +WRITES = [ + ("create_block", ("123", "42", "divider", {}), "email_block_created.json"), + ("update_block", ("123", "57", "42", {}), "email_block_updated.json"), + ("delete_block", ("123", "57", "42"), "email_etag.json"), + ("move_block", ("123", "57", "42", {"position": "end"}), "email_block_moved.json"), + ("update_settings", ("123", "42", {}), "email_settings.json"), + ("create_template", ("123", "Newsletter"), "email_template.json"), + ("create_version", ("123",), "email_version.json"), + ("restore_version", ("123", "901", "42"), "email.json"), +] + + +class TestEmailIdempotencyKeys: + @pytest.mark.parametrize("method, args, fixture", WRITES) + def test_write_sends_the_idempotency_key( + self, client, mock_api, method, args, fixture + ): + mock_api.add_response(200, load_fixture(fixture)) + + getattr(client.emails, method)(*args, idempotency_key="insert-divider-0001") + + assert mock_api.last_request.headers["Idempotency-Key"] == "insert-divider-0001" + + @pytest.mark.parametrize("method, args, fixture", WRITES) + def test_write_without_a_key_sends_no_idempotency_key( + self, client, mock_api, method, args, fixture + ): + mock_api.add_response(200, load_fixture(fixture)) + + getattr(client.emails, method)(*args) + + assert "Idempotency-Key" not in mock_api.last_request.headers + + def test_replayed_write_is_an_idempotent_replay(self, client, mock_api): + mock_api.add_response( + 201, + load_fixture("email_block_created.json"), + {"Idempotent-Replayed": "true"}, + ) + + response = client.emails.create_block( + "123", "42", "divider", {}, idempotency_key="insert-divider-0001" + ) + + assert response.status_code == 201 + assert response.is_idempotent_replay is True + assert response["data"]["id"] == "58" + + def test_first_write_is_not_an_idempotent_replay(self, client, mock_api): + mock_api.add_response(201, load_fixture("email_block_created.json")) + + response = client.emails.create_block( + "123", "42", "divider", {}, idempotency_key="insert-divider-0001" + ) + + assert response.is_idempotent_replay is False + + def test_write_accepts_a_key_of_255_printable_characters(self, client, mock_api): + key = "".join(chr(code) for code in range(0x21, 0x7F)) + " " + "k" * 160 + assert len(key) == 255 + mock_api.add_response(201, load_fixture("email_version.json")) + + client.emails.create_version("123", idempotency_key=key) + + assert mock_api.last_request.headers["Idempotency-Key"] == key + + +# Each email write that sends an etag: (method, arguments before the etag, after it) +ETAG_WRITES = [ + ("create_block", ("123",), ("divider", {}), "email_block_created.json"), + ("update_block", ("123", "57"), ({},), "email_block_updated.json"), + ("delete_block", ("123", "57"), (), "email_etag.json"), + ("move_block", ("123", "57"), ({"position": "end"},), "email_block_moved.json"), + ("update_settings", ("123",), ({},), "email_settings.json"), + ("restore_version", ("123", "901"), (), "email.json"), +] + +INVALID_BODY = { + "message": "The given data was invalid.", + "errors": {"etag": ["The etag field is required."]}, +} + + +class TestEmailsSendTheGivenValues: + """The SDK sends the values of the caller, and the API checks them.""" + + @pytest.mark.parametrize( + "placement", + [ + {"position": "after"}, + {"position": "before", "block_id": 57}, + {"position": "after", "block_id": "1/2"}, + {"position": "start", "block_id": "57"}, + {"position": "end", "block_id": None}, + {"position": "inside", "block_id": 7}, + "end", + ], + ) + def test_block_writes_send_the_placement_as_given( + self, client, mock_api, placement + ): + mock_api.add_response(201, load_fixture("email_block_created.json")) + mock_api.add_response(200, load_fixture("email_block_moved.json")) + mock_api.add_response(200, load_fixture("email_block_validation_valid.json")) + + client.emails.create_block("123", "42", BlockType.DIVIDER, {}, placement) + client.emails.move_block("123", "57", "45", placement) + client.emails.validate_block(BlockType.DIVIDER, {}, placement) + + assert [json.loads(request.body) for request in mock_api.requests] == [ + { + "type": "divider", + "editor_content": {}, + "placement": placement, + "etag": "42", + }, + {"placement": placement, "etag": "45"}, + {"type": "divider", "editor_content": {}, "placement": placement}, + ] + + @pytest.mark.parametrize("etag", ["", None, 42]) + @pytest.mark.parametrize("method, before, after, fixture", ETAG_WRITES) + def test_write_sends_the_etag_as_given( + self, client, mock_api, method, before, after, fixture, etag + ): + mock_api.add_response(200, load_fixture(fixture)) + + getattr(client.emails, method)(*before, etag, *after) + + assert mock_api.last_body["etag"] == etag + + def test_write_with_an_empty_etag_raises_the_api_error(self, client, mock_api): + mock_api.add_response(422, INVALID_BODY) + + with pytest.raises(ApiError) as error: + client.emails.delete_block("123", "57", "") + + assert mock_api.last_body == {"etag": ""} + assert error.value.status_code == 422 + assert error.value.errors == INVALID_BODY["errors"] + + @pytest.mark.parametrize( + "method, args, body", + [ + ( + "create_block", + ("123", "42", None, "text"), + {"type": None, "editor_content": "text", "etag": "42"}, + ), + ( + "update_block", + ("123", "57", "43", None), + {"editor_content": None, "etag": "43"}, + ), + ("create_template", ("123", None), {"name": None}), + ], + ) + def test_write_sends_a_value_of_the_wrong_type_and_raises_the_api_error( + self, client, mock_api, method, args, body + ): + mock_api.add_response(422, INVALID_BODY) + + with pytest.raises(ApiError): + getattr(client.emails, method)(*args) + + assert mock_api.last_body == body + + @pytest.mark.parametrize( + "block_type, editor_content", [(5, {}), (None, {}), ("heading", [])] + ) + def test_validate_block_sends_an_invalid_block( + self, client, mock_api, block_type, editor_content + ): + mock_api.add_response(200, load_fixture("email_block_validation_invalid.json")) + + response = client.emails.validate_block(block_type, editor_content) + + assert mock_api.last_body == { + "type": block_type, + "editor_content": editor_content, + } + assert response["valid"] is False + + @pytest.mark.parametrize( + "key", + [ + "", + pytest.param("k" * 256, id="256-characters"), + "tab\there", + "delete\x7f", + "trailing-space ", + ], + ) + @pytest.mark.parametrize("method, args, fixture", WRITES) + def test_write_sends_the_idempotency_key_as_given( + self, client, mock_api, method, args, fixture, key + ): + mock_api.add_response(200, load_fixture(fixture)) + + getattr(client.emails, method)(*args, idempotency_key=key) + + assert mock_api.last_request.headers["Idempotency-Key"] == key + + # requests cannot send these values in a header + @pytest.mark.parametrize("key", [" leading-space", "line\nbreak", 1]) + def test_write_raises_the_requests_error_for_a_key_that_http_cannot_send( + self, client, mock_api, key + ): + with pytest.raises(requests.exceptions.InvalidHeader): + client.emails.create_version("123", idempotency_key=key) + + assert mock_api.requests == [] diff --git a/tests/files_test.py b/tests/files_test.py new file mode 100644 index 0000000..828bdfb --- /dev/null +++ b/tests/files_test.py @@ -0,0 +1,80 @@ +import pytest +import mailerlite +from mailerlite import Client +from mailerlite.response import ApiResponse +from mailerlite.sdk.files import FileSort, FileType + +from tests.conftest import load_fixture + +URL = "https://connect.mailerlite.com/api/files" + + +@pytest.fixture +def client(): + return Client({"api_key": "1234567890"}) + + +class TestFiles: + def test_api_url_is_properly_set(self, client): + assert client.files.base_api_url == "api/files" + + def test_list_without_options(self, client, mock_api): + mock_api.add_response(200, load_fixture("files.json")) + + response = client.files.list() + + assert mock_api.last_request.method == "GET" + assert mock_api.last_request.url == URL + assert mock_api.last_body is None + assert isinstance(response, ApiResponse) + image, pdf = response["data"] + assert image["id"] == "301" + assert image["type"] == "image" + assert image["name"] == "summer sale banner.png" + assert image["size"] == 48213 + assert (image["width"], image["height"]) == (1200, 600) + assert pdf["type"] == "pdf" + assert pdf["width"] is None + assert pdf["height"] is None + assert response["meta"]["total"] == 2 + + def test_list_sends_a_name_filter_with_spaces(self, client, mock_api): + mock_api.add_response(200, load_fixture("files.json")) + + client.files.list(filter={"name": "summer sale"}) + + assert mock_api.last_request.url == f"{URL}?filter%5Bname%5D=summer+sale" + + def test_list_sends_all_options(self, client, mock_api): + mock_api.add_response(200, load_fixture("files.json")) + + client.files.list( + filter={"name": "banner", "type": FileType.IMAGE}, + sort=FileSort.SIZE_DESCENDING, + page=2, + limit=50, + ) + + assert mock_api.last_request.url == ( + f"{URL}?filter%5Bname%5D=banner&filter%5Btype%5D=image" + "&sort=-size&page=2&limit=50" + ) + + def test_list_rejects_a_filter_of_the_wrong_type(self, client, mock_api): + with pytest.raises(TypeError): + client.files.list(filter="image") + + assert mock_api.requests == [] + + def test_file_constants(self): + assert FileType.IMAGE == "image" + assert FileType.PDF == "pdf" + assert FileType.EPUB == "epub" + assert FileSort.CREATED_AT == "created_at" + assert FileSort.CREATED_AT_DESCENDING == "-created_at" + assert FileSort.NAME == "name" + assert FileSort.NAME_DESCENDING == "-name" + assert FileSort.SIZE == "size" + assert FileSort.SIZE_DESCENDING == "-size" + assert mailerlite.FileType is FileType + assert mailerlite.FileSort is FileSort diff --git a/tests/fixtures/brand_styles.json b/tests/fixtures/brand_styles.json new file mode 100644 index 0000000..dab5ce3 --- /dev/null +++ b/tests/fixtures/brand_styles.json @@ -0,0 +1,50 @@ +{ + "data": { + "brand": { + "identifier": "account", + "name": "Brand styles", + "font_family": "Inter, sans-serif", + "primary_color": "#2cb191", + "secondary_color": "#1f2937", + "heading_color": "#111827", + "text_color": "#374151", + "border_color": "#e5e7eb", + "background_color": "#ffffff" + }, + "default": { + "identifier": "default-brand", + "name": "Default", + "font_family": "Inter, sans-serif", + "primary_color": "#09c269", + "secondary_color": "#000000", + "heading_color": "#000000", + "text_color": "#000000", + "border_color": "#e5e7eb", + "background_color": "#ffffff" + }, + "presets": [ + { + "identifier": "preset-business-casual", + "name": "Business casual", + "font_family": "Georgia, serif", + "primary_color": "#1d4ed8", + "secondary_color": "#1e3a8a", + "heading_color": "#0f172a", + "text_color": "#334155", + "border_color": "#cbd5e1", + "background_color": "#f8fafc" + }, + { + "identifier": "preset-retro", + "name": "Retro", + "font_family": "Courier New, monospace", + "primary_color": "#d97706", + "secondary_color": "#92400e", + "heading_color": "#451a03", + "text_color": "#78350f", + "border_color": "#fde68a", + "background_color": "#fffbeb" + } + ] + } +} diff --git a/tests/fixtures/campaign_simple_editor.json b/tests/fixtures/campaign_simple_editor.json new file mode 100644 index 0000000..d54f882 --- /dev/null +++ b/tests/fixtures/campaign_simple_editor.json @@ -0,0 +1,24 @@ +{ + "data": { + "id": "456", + "account_id": "1", + "name": "Simple editor campaign", + "type": "regular", + "status": "draft", + "default_email_id": "123", + "emails": [ + { + "id": "123", + "account_id": "1", + "emailable_id": "456", + "emailable_type": "campaigns", + "type": "builder_inline_email", + "from": "sender@example.com", + "from_name": "Sender", + "reply_to": "reply@example.com", + "subject": "Hello", + "is_designed": true + } + ] + } +} diff --git a/tests/fixtures/email.json b/tests/fixtures/email.json new file mode 100644 index 0000000..2762d5b --- /dev/null +++ b/tests/fixtures/email.json @@ -0,0 +1,81 @@ +{ + "data": { + "id": "123", + "type": "builder_inline_email", + "emailable_type": "campaigns", + "emailable_id": "456", + "settings": { + "brand": { + "mode": "account", + "font_family": "Inter, sans-serif", + "background_color": "#ffffff", + "text_color": "#000000", + "border_color": "#e5e7eb" + }, + "layout": {"width_mode": "default", "alignment": "center"}, + "typography": { + "link": {"color": "#2cb191"}, + "heading": { + "font_family": "Inter, sans-serif", + "font_weight": "700", + "line_height": "150%", + "letter_spacing": "0", + "h1": {"color": "#000000", "size": "32"}, + "h2": {"color": "#000000", "size": "24"}, + "h3": {"color": "#000000", "size": "20"} + }, + "paragraph": { + "font_family": "Inter, sans-serif", + "font_weight": "400", + "line_height": "150%", + "letter_spacing": "0", + "p1": {"color": "#000000", "size": "16"} + } + }, + "button": { + "font_family": "Inter, sans-serif", + "font_weight": "600", + "letter_spacing": "0", + "background_color": "#2cb191", + "text_color": "#ffffff", + "border_radius": "4" + }, + "footer": { + "show_preferences": false, + "company_name": { + "editor_content": { + "content": [{"type": "text", "text": "Acme", "marks": [{"type": "bold"}]}] + } + }, + "address": { + "editor_content": {"content": [{"text": "1 Main Street", "type": "text"}]} + }, + "info_text": { + "editor_content": {"content": [{"text": "You signed up on our website.", "type": "text"}]} + }, + "unsubscribe_text": "Unsubscribe", + "preferences_text": "Update your preferences", + "social_links": [ + {"url": "https://example.com", "type": "website", "label": "Website", "link_id": 7} + ] + } + }, + "blocks": [ + { + "id": "1", + "type": "paragraph", + "editor_content": { + "attrs": {"textAlign": "left"}, + "content": [{"text": "Hello", "type": "text"}] + } + }, + { + "id": "2", + "type": "bulleted_list", + "editor_content": {}, + "children": [{"id": "3", "type": "paragraph", "editor_content": {}}] + } + ] + }, + "etag": "42" +} diff --git a/tests/fixtures/email_block.json b/tests/fixtures/email_block.json new file mode 100644 index 0000000..df7d248 --- /dev/null +++ b/tests/fixtures/email_block.json @@ -0,0 +1,12 @@ +{ + "data": { + "id": "57", + "type": "paragraph", + "editor_content": { + "attrs": {"textAlign": "left"}, + "content": [{"text": "Hello", "type": "text"}] + }, + "placement": {"after": "56", "before": null} + }, + "etag": "43" +} diff --git a/tests/fixtures/email_block_created.json b/tests/fixtures/email_block_created.json new file mode 100644 index 0000000..7b52e03 --- /dev/null +++ b/tests/fixtures/email_block_created.json @@ -0,0 +1,12 @@ +{ + "data": { + "id": "58", + "type": "paragraph", + "editor_content": { + "attrs": {"textAlign": "center"}, + "content": [{"text": "Hi", "type": "text"}] + }, + "placement": {"after": "57", "before": null} + }, + "etag": "43" +} diff --git a/tests/fixtures/email_block_moved.json b/tests/fixtures/email_block_moved.json new file mode 100644 index 0000000..3041e81 --- /dev/null +++ b/tests/fixtures/email_block_moved.json @@ -0,0 +1,12 @@ +{ + "data": { + "id": "57", + "type": "paragraph", + "editor_content": { + "attrs": {"textAlign": "left"}, + "content": [{"text": "Hello", "type": "text"}] + }, + "placement": {"after": null, "before": "1"} + }, + "etag": "46" +} diff --git a/tests/fixtures/email_block_updated.json b/tests/fixtures/email_block_updated.json new file mode 100644 index 0000000..cb87a43 --- /dev/null +++ b/tests/fixtures/email_block_updated.json @@ -0,0 +1,9 @@ +{ + "data": { + "id": "57", + "type": "paragraph", + "editor_content": {"content": [{"text": "Hello again", "type": "text"}]}, + "placement": {"after": "56", "before": "58"} + }, + "etag": "44" +} diff --git a/tests/fixtures/email_block_validation_invalid.json b/tests/fixtures/email_block_validation_invalid.json new file mode 100644 index 0000000..c5c04fb --- /dev/null +++ b/tests/fixtures/email_block_validation_invalid.json @@ -0,0 +1,5 @@ +{ + "valid": false, + "errors": {"type": ["The selected type is invalid."]}, + "schema": {"version": "2.73.2", "hash": "0f3a9c"} +} diff --git a/tests/fixtures/email_block_validation_valid.json b/tests/fixtures/email_block_validation_valid.json new file mode 100644 index 0000000..b73c417 --- /dev/null +++ b/tests/fixtures/email_block_validation_valid.json @@ -0,0 +1 @@ +{"valid": true, "errors": {}, "schema": {"version": "2.73.2", "hash": "0f3a9c"}} diff --git a/tests/fixtures/email_etag.json b/tests/fixtures/email_etag.json new file mode 100644 index 0000000..730eaff --- /dev/null +++ b/tests/fixtures/email_etag.json @@ -0,0 +1 @@ +{"etag": "45"} diff --git a/tests/fixtures/email_html.json b/tests/fixtures/email_html.json new file mode 100644 index 0000000..d680c41 --- /dev/null +++ b/tests/fixtures/email_html.json @@ -0,0 +1 @@ +{"data": {"html": "Hello
"}, "etag": "42"} diff --git a/tests/fixtures/email_settings.json b/tests/fixtures/email_settings.json new file mode 100644 index 0000000..b6193ec --- /dev/null +++ b/tests/fixtures/email_settings.json @@ -0,0 +1,111 @@ +{ + "data": { + "brand": { + "mode": "custom", + "font_family": "Inter, sans-serif", + "background_color": "#f4f4f4", + "text_color": "#000000", + "border_color": "#e5e7eb" + }, + "layout": { + "width_mode": "default", + "alignment": "center" + }, + "typography": { + "link": { + "color": "#2cb191" + }, + "heading": { + "font_family": "Inter, sans-serif", + "font_weight": "700", + "line_height": "150%", + "letter_spacing": "0", + "h1": { + "color": "#000000", + "size": "32" + }, + "h2": { + "color": "#000000", + "size": "24" + }, + "h3": { + "color": "#000000", + "size": "20" + } + }, + "paragraph": { + "font_family": "Inter, sans-serif", + "font_weight": "400", + "line_height": "150%", + "letter_spacing": "0", + "p1": { + "color": "#000000", + "size": "16" + } + } + }, + "button": { + "font_family": "Inter, sans-serif", + "font_weight": "600", + "letter_spacing": "0", + "background_color": "#2cb191", + "text_color": "#ffffff", + "border_radius": "4" + }, + "footer": { + "show_preferences": false, + "company_name": { + "editor_content": { + "content": [ + { + "type": "text", + "text": "Acme", + "marks": [ + { + "type": "bold" + } + ] + } + ] + } + }, + "address": { + "editor_content": { + "content": [ + { + "text": "1 Main Street", + "type": "text" + } + ] + } + }, + "info_text": { + "editor_content": { + "content": [ + { + "text": "You signed up on our website.", + "type": "text" + } + ] + } + }, + "unsubscribe_text": "Unsubscribe", + "preferences_text": "Update your preferences", + "social_links": [ + { + "url": "https://example.com", + "type": "website", + "label": "Website", + "link_id": 7 + } + ] + } + }, + "etag": "47", + "rejected": { + "message": "Some attributes were rejected.", + "keys": [ + "footer.social_links[0].url" + ] + } +} diff --git a/tests/fixtures/email_settings_validation.json b/tests/fixtures/email_settings_validation.json new file mode 100644 index 0000000..d1a571a --- /dev/null +++ b/tests/fixtures/email_settings_validation.json @@ -0,0 +1,9 @@ +{ + "valid": false, + "errors": {"layout.width": ["The layout.width field cannot be changed."]}, + "schema": {"version": "2.73.2", "hash": "0f3a9c"}, + "rejected": { + "message": "Some attributes were rejected.", + "keys": ["footer.social_links[0].url"] + } +} diff --git a/tests/fixtures/email_template.json b/tests/fixtures/email_template.json new file mode 100644 index 0000000..8f44cd4 --- /dev/null +++ b/tests/fixtures/email_template.json @@ -0,0 +1 @@ +{"data": {"id": "789", "name": "Newsletter", "type": "email", "created_at": "2026-10-28 12:00:00"}} diff --git a/tests/fixtures/email_version.json b/tests/fixtures/email_version.json new file mode 100644 index 0000000..8e0ef77 --- /dev/null +++ b/tests/fixtures/email_version.json @@ -0,0 +1 @@ +{"data": {"id": "903", "created_at": "2026-10-28 12:10:00", "current": true}} diff --git a/tests/fixtures/email_versions.json b/tests/fixtures/email_versions.json new file mode 100644 index 0000000..d1881d8 --- /dev/null +++ b/tests/fixtures/email_versions.json @@ -0,0 +1,26 @@ +{ + "data": [ + {"id": "902", "created_at": "2026-10-28 12:05:00", "current": true}, + {"id": "901", "created_at": "2026-10-28 12:00:00", "current": false} + ], + "links": { + "first": "https://connect.mailerlite.com/api/emails/123/versions?page=1", + "last": "https://connect.mailerlite.com/api/emails/123/versions?page=1", + "prev": null, + "next": null + }, + "meta": { + "current_page": 1, + "from": 1, + "last_page": 1, + "links": [ + {"url": null, "label": "« Previous", "page": null, "active": false}, + {"url": "https://connect.mailerlite.com/api/emails/123/versions?page=1", "label": "1", "page": 1, "active": true}, + {"url": null, "label": "Next »", "page": null, "active": false} + ], + "path": "https://connect.mailerlite.com/api/emails/123/versions", + "per_page": 25, + "to": 2, + "total": 2 + } +} diff --git a/tests/fixtures/files.json b/tests/fixtures/files.json new file mode 100644 index 0000000..4bf0846 --- /dev/null +++ b/tests/fixtures/files.json @@ -0,0 +1,44 @@ +{ + "data": [ + { + "id": "301", + "type": "image", + "name": "summer sale banner.png", + "url": "https://storage.example.com/files/summer-sale-banner.png", + "size": 48213, + "width": 1200, + "height": 600, + "created_at": "2026-10-20 09:30:00" + }, + { + "id": "302", + "type": "pdf", + "name": "summer sale terms.pdf", + "url": "https://storage.example.com/files/summer-sale-terms.pdf", + "size": 10240, + "width": null, + "height": null, + "created_at": "2026-10-21 10:00:00" + } + ], + "links": { + "first": "https://connect.mailerlite.com/api/files?page=1", + "last": "https://connect.mailerlite.com/api/files?page=1", + "prev": null, + "next": null + }, + "meta": { + "current_page": 1, + "from": 1, + "last_page": 1, + "links": [ + {"url": null, "label": "« Previous", "page": null, "active": false}, + {"url": "https://connect.mailerlite.com/api/files?page=1", "label": "1", "page": 1, "active": true}, + {"url": null, "label": "Next »", "page": null, "active": false} + ], + "path": "https://connect.mailerlite.com/api/files", + "per_page": 25, + "to": 2, + "total": 2 + } +} diff --git a/tests/params_test.py b/tests/params_test.py new file mode 100644 index 0000000..b45f71b --- /dev/null +++ b/tests/params_test.py @@ -0,0 +1,58 @@ +import pytest +from mailerlite.exceptions import EmptyIdError, InvalidIdError +from mailerlite.sdk import _params + + +class TestParams: + def test_path_id_returns_the_id_as_a_string(self): + assert _params.path_id(123, "email_id") == "123" + assert _params.path_id("123", "email_id") == "123" + + @pytest.mark.parametrize( + "value, error", + [ + ("", EmptyIdError), + (None, EmptyIdError), + ("12/3", InvalidIdError), + (True, TypeError), + (1.5, TypeError), + ], + ) + def test_path_id_rejects_an_invalid_id(self, value, error): + with pytest.raises(error, match="email_id"): + _params.path_id(value, "email_id") + + def test_require_dict_rejects_a_value_that_is_not_a_dict(self): + _params.require_dict({}, "settings") + + with pytest.raises(TypeError, match="settings"): + _params.require_dict([], "settings") + + def test_without_none_removes_the_keys_without_a_value(self): + assert _params.without_none({"page": 2, "limit": None}) == {"page": 2} + + def test_filter_params_returns_a_query_param_for_each_filter(self): + assert _params.filter_params(None) == {} + assert _params.filter_params({"name": "logo", "type": "image"}) == { + "filter[name]": "logo", + "filter[type]": "image", + } + + def test_filter_params_removes_the_filters_without_a_value(self): + assert _params.filter_params({"name": "logo", "type": None}) == { + "filter[name]": "logo" + } + + def test_filter_params_rejects_a_filter_that_is_not_a_dict(self): + with pytest.raises(TypeError, match="filter"): + _params.filter_params("image") + + def test_idempotency_headers(self): + assert _params.idempotency_headers(None) is None + assert _params.idempotency_headers("k1") == {"Idempotency-Key": "k1"} + + @pytest.mark.parametrize( + "key", ["", " k1", pytest.param("k" * 256, id="256-characters")] + ) + def test_idempotency_headers_send_the_key_as_given(self, key): + assert _params.idempotency_headers(key) == {"Idempotency-Key": key} diff --git a/tests/response_test.py b/tests/response_test.py new file mode 100644 index 0000000..e0a3445 --- /dev/null +++ b/tests/response_test.py @@ -0,0 +1,82 @@ +import json + +import pytest + +import mailerlite +from mailerlite.api_client import ApiClient +from mailerlite.response import ApiResponse + + +class TestApiResponse: + def test_it_is_a_dict_with_the_decoded_body(self, mock_api): + mock_api.add_response(201, {"data": {"id": "1"}}, {"X-Request-Id": "abc"}) + + response = ApiResponse(ApiClient({}).request("POST", "api/emails/1/versions")) + + assert isinstance(response, dict) + assert response == {"data": {"id": "1"}} + assert response["data"]["id"] == "1" + assert response.status_code == 201 + assert response.headers["x-request-id"] == "abc" + + def test_replayed_response_is_an_idempotent_replay(self, mock_api): + mock_api.add_response(201, {"data": {}}, {"Idempotent-Replayed": "true"}) + + response = ApiResponse(ApiClient({}).request("POST", "api/emails/1/blocks")) + + assert response.is_idempotent_replay is True + + @pytest.mark.parametrize("value", ["True", "TRUE", " true "]) + def test_replay_header_value_is_not_case_sensitive(self, mock_api, value): + mock_api.add_response(201, {"data": {}}, {"Idempotent-Replayed": value}) + + response = ApiResponse(ApiClient({}).request("POST", "api/emails/1/blocks")) + + assert response.is_idempotent_replay is True + + @pytest.mark.parametrize("value", ["false", "1", ""]) + def test_other_replay_header_values_are_not_an_idempotent_replay( + self, mock_api, value + ): + mock_api.add_response(201, {"data": {}}, {"Idempotent-Replayed": value}) + + response = ApiResponse(ApiClient({}).request("POST", "api/emails/1/blocks")) + + assert response.is_idempotent_replay is False + + def test_response_without_the_header_is_not_an_idempotent_replay(self, mock_api): + mock_api.add_response(201, {"data": {}}) + + response = ApiResponse(ApiClient({}).request("POST", "api/emails/1/blocks")) + + assert response.is_idempotent_replay is False + + def test_empty_body_is_an_empty_dict(self, mock_api): + mock_api.add_response(204) + + response = ApiResponse(ApiClient({}).request("DELETE", "api/emails/1/blocks/2")) + + assert response == {} + assert response.status_code == 204 + + @pytest.mark.parametrize("body", [[1, 2], "text", None, 1]) + def test_body_that_is_not_a_json_object_raises_type_error(self, mock_api, body): + mock_api.add_response(200, json.dumps(body)) + + with pytest.raises(TypeError, match="JSON object"): + ApiResponse(ApiClient({}).request("GET", "api/emails/1")) + + def test_copy_keeps_the_status_code_and_the_headers(self, mock_api): + mock_api.add_response(201, {"data": {}}, {"Idempotent-Replayed": "true"}) + response = ApiResponse(ApiClient({}).request("POST", "api/emails/1/blocks")) + + copied = response.copy() + + assert isinstance(copied, ApiResponse) + assert copied == response + assert copied is not response + assert copied.status_code == 201 + assert copied.is_idempotent_replay is True + + def test_api_response_is_exported_from_the_package(self): + assert mailerlite.ApiResponse is ApiResponse diff --git a/uv.lock b/uv.lock index c095698..d4ee8fa 100644 --- a/uv.lock +++ b/uv.lock @@ -369,7 +369,7 @@ wheels = [ [[package]] name = "mailerlite" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "requests" },