diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9610086..068c48c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,8 @@ -name: CI +name: Pins & tests -# This repo has no PR gate. The pins check is the whole job -- it is cheap, needs -# no secrets, and stops the Dockerfile and the manifest drifting apart again. -# Adding it does NOT make this repo tier 2: nothing here auto-merges. +# This repo has no PR gate beyond what runs here: the pins check (cheap, no +# secrets, stops the Dockerfile and the manifest drifting apart) and the +# pytest suite. Neither auto-merges anything. on: pull_request: @@ -11,8 +11,15 @@ permissions: contents: read jobs: - ci: + pins-and-tests: + name: Pins & tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: WeMoveEU/ci-workflows/.github/actions/python-pins@v14 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install uv + - run: uv sync --group dev + - run: uv run pytest diff --git a/pyproject.toml b/pyproject.toml index ae16967..8b2a2b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,4 +31,5 @@ role = "library" [dependency-groups] dev = [ "pytest>=7.0.1", + "responses>=0.25", ] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9b58579 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,23 @@ +import pytest + +from actionkit import ActionKit +from actionkit.connection import Connection + +HOSTNAME = "example.com" +USERNAME = "user" +PASSWORD = "password" + + +@pytest.fixture +def base_url(): + return f"https://{HOSTNAME}" + + +@pytest.fixture +def connection(): + return Connection(HOSTNAME, USERNAME, PASSWORD) + + +@pytest.fixture +def ak(): + return ActionKit(HOSTNAME, USERNAME, PASSWORD) diff --git a/tests/test_actionkit.py b/tests/test_actionkit.py new file mode 100644 index 0000000..072c6b1 --- /dev/null +++ b/tests/test_actionkit.py @@ -0,0 +1,81 @@ +import pytest + +from actionkit import ActionKit, connect + + +# --- connect() --------------------------------------------------------- + + +def test_connect_requires_credentials(monkeypatch): + for var in ["ACTIONKIT_USERNAME", "ACTIONKIT_PASSWORD", "ACTIONKIT_HOSTNAME"]: + monkeypatch.delenv(var, raising=False) + with pytest.raises(Exception, match="couldn't find login information"): + connect() + + +def test_connect_uses_env_vars_when_kwargs_omitted(monkeypatch): + monkeypatch.setenv("ACTIONKIT_USERNAME", "envuser") + monkeypatch.setenv("ACTIONKIT_PASSWORD", "envpass") + monkeypatch.setenv("ACTIONKIT_HOSTNAME", "env.example.com") + connection = connect() + assert connection.hostname == "env.example.com" + assert connection.request_kwargs["auth"].username == "envuser" + assert connection.request_kwargs["auth"].password == "envpass" + + +def test_connect_kwargs_take_precedence_over_env_vars(monkeypatch): + monkeypatch.setenv("ACTIONKIT_USERNAME", "envuser") + monkeypatch.setenv("ACTIONKIT_PASSWORD", "envpass") + monkeypatch.setenv("ACTIONKIT_HOSTNAME", "env.example.com") + connection = connect(hostname="explicit.example.com", username="u", password="p") + assert connection.hostname == "explicit.example.com" + assert connection.request_kwargs["auth"].username == "u" + + +# --- ActionKit wiring ------------------------------------------------- + + +RESOURCE_ATTRS = [ + "Orders", + "OrderRecurring", + "DonationAction", + "Groups", + "Languages", + "Lists", + "Uploads", + "Users", + "UserFields", + "Campaigns", + "MultilingualCampaigns", + "Petitions", + "DonationPages", + "RecurringPaymentPush", + "ProfileCancelPush", + "ProfileUpdatePush", + "SQL", + "Transactions", + "SignupPages", + "SignupActions", + "GenericActions", + "GenericPages", +] + + +@pytest.mark.parametrize("attr", RESOURCE_ATTRS) +def test_actionkit_wires_every_resource_attribute(ak, attr): + resource = getattr(ak, attr) + assert resource.connection is ak.connection + + +def test_actionkit_static_helpers_delegate_to_connection(): + class _FakeResponse: + headers = {"Location": "https://example.com/rest/v1/thing/1/"} + + assert ( + ActionKit.get_resource_uri(_FakeResponse()) + == "https://example.com/rest/v1/thing/1/" + ) + assert ActionKit.get_resource_uri_id("https://example.com/rest/v1/thing/1/") == "1" + assert ( + ActionKit.get_resource_uri_id_from_response(_FakeResponse()) == "1" + ) diff --git a/tests/test_campaigns.py b/tests/test_campaigns.py new file mode 100644 index 0000000..3989895 --- /dev/null +++ b/tests/test_campaigns.py @@ -0,0 +1,72 @@ +import json as json_module + +import pytest +import responses + +from urls import rest + + +@pytest.fixture +def campaigns(ak): + return ak.Campaigns + + +@responses.activate +def test_list_returns_id_to_name_mapping(campaigns): + responses.add( + responses.GET, + rest("allowedpagefield/campaign"), + json={"choices": [["1", "First"], ["2", "Second"]]}, + status=200, + ) + assert campaigns.list() == {1: "First", 2: "Second"} + + +@responses.activate +def test_create_full_sequence(campaigns): + responses.add( + responses.POST, + rest("signuppage"), + status=201, + headers={"Location": rest("signuppage/5/")}, + ) + responses.add( + responses.GET, + rest("allowedpagefield/campaign"), + json={"field_choices": "1=Existing"}, + status=200, + ) + responses.add(responses.PATCH, rest("signuppage/5"), status=200) + responses.add(responses.PATCH, rest("allowedpagefield/campaign"), status=200) + responses.add(responses.PATCH, rest("allowedmailingfield/campaign"), status=200) + + result = campaigns.create( + "My Campaign", + "petition", + lead_campaigner="alice", + topic="climate", + fields={"extra": "1"}, + ) + + assert result == rest("signuppage/5/") + + create_body = json_module.loads(responses.calls[0].request.body) + assert create_body == { + "name": "My Campaign", + "title": "My Campaign", + "fields": { + "extra": "1", + "campaign_type": "petition", + "lead_campaigner": "alice", + "topic": "climate", + }, + } + + self_ref_body = json_module.loads(responses.calls[1].request.body) + assert self_ref_body == {"fields": {"campaign": "5"}} + + pagefield_body = json_module.loads(responses.calls[3].request.body) + assert pagefield_body == {"field_choices": "1=Existing\n5=My Campaign"} + + mailingfield_body = json_module.loads(responses.calls[4].request.body) + assert mailingfield_body == {"field_choices": "1=Existing\n5=My Campaign"} diff --git a/tests/test_connection.py b/tests/test_connection.py new file mode 100644 index 0000000..f5627d9 --- /dev/null +++ b/tests/test_connection.py @@ -0,0 +1,240 @@ +import json as json_module + +import pytest +import responses + +from actionkit.validation import ValidationError +from urls import rest + + +# --- _path() ----------------------------------------------------------- + + +def test_path_bare_path(connection): + assert connection._path("/a/b/c/") == rest("a/b/c/") + + +def test_path_without_leading_slash(connection): + assert connection._path("a/b/c") == rest("a/b/c") + + +def test_path_already_api_path(connection): + assert connection._path("/rest/v1/b/c/") == rest("b/c/") + + +def test_path_absolute_url_passed_through(connection): + assert connection._path("https://example.com/i/am/complete") == ( + "https://example.com/i/am/complete" + ) + + +def test_path_collapses_a_legitimate_double_slash_in_the_input(): + """ + Documents a quirk, not a fix: `_path` blindly does + f"/rest/v1/{path}".replace("//", "/"), so a caller-provided "//" inside + `path` gets silently collapsed along with the deliberate one the method + itself introduces when `path` has no leading slash. + """ + from actionkit.connection import Connection + + connection = Connection("example.com", "user", "password") + assert connection._path("orders//1") == rest("orders/1") + + +# --- _make_request() argument validation -------------------------------- + + +def test_get_with_json_raises_value_error(connection): + with pytest.raises(ValueError): + connection.get("thing", json={"a": 1}) + + +def test_non_get_with_params_raises_value_error(connection): + with pytest.raises(ValueError): + connection.post("thing", json={"a": 1}, params={"b": 2}) + + +def test_data_and_json_together_raises_value_error(connection): + with pytest.raises(ValueError): + connection.post("thing", json={"a": 1}, data={"b": 2}) + + +# --- happy path per verb ------------------------------------------------- + + +@responses.activate +def test_get_sends_auth_and_accept_headers_and_params(connection): + responses.add(responses.GET, rest("thing/"), json={"ok": True}, status=200) + response = connection.get("thing/", params={"q": "1"}) + + req = responses.calls[0].request + assert req.headers["Accept"] == "application/json" + assert req.headers["Authorization"].startswith("Basic ") + assert req.url == rest("thing/") + "?q=1" + assert response.json() == {"ok": True} + + +@responses.activate +def test_post_sends_json_body(connection): + responses.add(responses.POST, rest("thing/"), status=201, headers={"Location": rest("thing/1/")}) + connection.post("thing/", json={"name": "x"}) + + req = responses.calls[0].request + assert json_module.loads(req.body) == {"name": "x"} + + +@responses.activate +def test_patch_sends_json_body(connection): + responses.add(responses.PATCH, rest("thing/1/"), status=200) + connection.patch("thing/1/", json={"status": "done"}) + assert json_module.loads(responses.calls[0].request.body) == {"status": "done"} + + +@responses.activate +def test_put_sends_json_body(connection): + responses.add(responses.PUT, rest("thing/1/"), status=200) + connection.put("thing/1/", json={"status": "done"}) + assert json_module.loads(responses.calls[0].request.body) == {"status": "done"} + + +@responses.activate +def test_delete_makes_request(connection): + responses.add(responses.DELETE, rest("thing/1/"), status=204) + connection.delete("thing/1/") + assert len(responses.calls) == 1 + + +# --- retry / backoff on 5xx ---------------------------------------------- + + +@pytest.fixture(autouse=True) +def no_real_sleep(monkeypatch): + sleeps = [] + monkeypatch.setattr("time.sleep", lambda seconds: sleeps.append(seconds)) + return sleeps + + +@responses.activate +def test_retries_on_500_then_succeeds(connection, no_real_sleep): + responses.add(responses.GET, rest("thing/"), status=500) + responses.add(responses.GET, rest("thing/"), status=500) + responses.add(responses.GET, rest("thing/"), json={"ok": True}, status=200) + + response = connection.get("thing/") + + assert len(responses.calls) == 3 + assert response.json() == {"ok": True} + # initial_backoff = 3, doubling each retry + assert no_real_sleep == [3, 6] + + +@responses.activate +def test_exhausts_retries_and_raises(connection, no_real_sleep): + for _ in range(4): + responses.add(responses.GET, rest("thing/"), status=500) + + with pytest.raises(Exception): + connection.get("thing/") + + # num_retries = 3, so the initial attempt plus 3 retries = 4 total calls + assert len(responses.calls) == 4 + assert no_real_sleep == [3, 6, 12] + + +# --- error-response handling: HTTPError vs ValidationError --------------- + + +@responses.activate +def test_error_with_body_raises_validation_error_regardless_of_status(connection): + """ + Pins current behavior: _make_request only special-cases retry_codes + (just 500). Any other HTTPError whose response has a non-empty body is + unconditionally converted to ValidationError, independent of the status + code -- 400, 404, 409, whatever it is. Callers that do + `except HTTPError as e: if e.response.status_code == 404: ...` elsewhere + in this library never see that exception for a body-bearing response; + see test_httpmethods.py and test_donationaction.py for the fallout. + """ + responses.add( + responses.GET, + rest("thing/1/"), + status=404, + json={"error": "not found"}, + ) + with pytest.raises(ValidationError): + connection.get("thing/1/") + + +@responses.activate +def test_error_with_empty_body_raises_plain_http_error(connection): + import requests + + responses.add(responses.GET, rest("thing/1/"), status=404, body="") + with pytest.raises(requests.exceptions.HTTPError): + connection.get("thing/1/") + + +@responses.activate +def test_error_with_non_json_body_raises_json_decode_error(connection): + """ + ValidationError.__init__ does an unguarded json.loads(response_text), so + a non-JSON error body (e.g. an HTML error page) blows up with + json.JSONDecodeError instead of producing a ValidationError. + """ + responses.add( + responses.GET, + rest("thing/1/"), + status=400, + body="Internal Server Error", + content_type="text/html", + ) + with pytest.raises(json_module.JSONDecodeError): + connection.get("thing/1/") + + +# --- static resource-uri helpers ----------------------------------------- + + +class _FakeResponse: + def __init__(self, headers): + self.headers = headers + + +@pytest.mark.parametrize( + "headers,expected", + [ + ({"Location": rest("thing/1/")}, rest("thing/1/")), + ({}, None), + ], +) +def test_get_resource_uri(headers, expected): + from actionkit.connection import Connection + + assert Connection.get_resource_uri(_FakeResponse(headers)) == expected + + +@pytest.mark.parametrize( + "uri,expected", + [ + (rest("thing/1/"), "1"), + (rest("thing/42/"), "42"), + (rest("thing/"), None), + ], +) +def test_get_resource_uri_id(uri, expected): + from actionkit.connection import Connection + + assert Connection.get_resource_uri_id(uri) == expected + + +def test_get_resource_uri_id_from_response(): + from actionkit.connection import Connection + + response = _FakeResponse({"Location": rest("thing/7/")}) + assert Connection.get_resource_uri_id_from_response(response) == "7" + + +def test_get_resource_uri_from_id(): + from actionkit.connection import Connection + + assert Connection.get_resource_uri_from_id("5", "thing") == "/rest/v1/thing/5/" diff --git a/tests/test_donationaction.py b/tests/test_donationaction.py new file mode 100644 index 0000000..8f91361 --- /dev/null +++ b/tests/test_donationaction.py @@ -0,0 +1,554 @@ +import json as json_module +from datetime import datetime, timezone +from decimal import Decimal + +import pytest +import responses + +from actionkit.validation import ValidationError +from urls import rest + + +@pytest.fixture +def donationaction(ak): + return ak.DonationAction + + +PUSH_REQUIRED = dict( + amount=Decimal("5.00"), + currency="EUR", + page="my-donation-page", + payment_account="wemove-account", + email="a@example.com", +) + + +# --- push() ------------------------------------------------------------ + + +@pytest.mark.parametrize("missing", ["amount", "currency", "page", "payment_account"]) +def test_push_requires_each_of_amount_currency_page_payment_account( + donationaction, missing +): + kwargs = {**PUSH_REQUIRED, missing: None} + with pytest.raises(ValueError): + donationaction.push(**kwargs) + + +def test_push_requires_email_or_akid(donationaction): + kwargs = {**PUSH_REQUIRED} + kwargs.pop("email") + with pytest.raises(ValueError): + donationaction.push(**kwargs) + + +@responses.activate +def test_push_akid_alone_is_sufficient(donationaction): + kwargs = {**PUSH_REQUIRED} + kwargs.pop("email") + kwargs["akid"] = "u.42.abcdef" + responses.add( + responses.POST, + rest("donationpush/"), + status=201, + json={"resource_uri": rest("donationaction/1/")}, + ) + donationaction.push(**kwargs) + assert len(responses.calls) == 1 + + +@responses.activate +def test_push_sends_expected_payload_for_non_us_country(donationaction): + responses.add( + responses.POST, + rest("donationpush/"), + status=201, + json={"resource_uri": rest("donationaction/1/")}, + ) + donationaction.push( + email="a@example.com", + first_name="A", + last_name="B", + country="FR", + postal="75001", + amount=Decimal("5.00"), + currency="EUR", + page="my-page", + payment_account="wemove-account", + trans_id="tx1", + ) + body = json_module.loads(responses.calls[0].request.body) + assert body["order"] == { + "card_num": "4111111111111111", + "card_code": "007", + "amount": "5.00", + "currency": "EUR", + "exp_date_month": "12", + "exp_date_year": "9999", + "payment_account": "wemove-account", + "trans_id": "tx1", + } + assert body["user"]["postal"] == "75001" + assert body["user"]["zip"] is None + assert body["user"]["country"] == "FR" + assert body["donationpage"] == {"name": "my-page"} + assert "action" not in body + assert "order" in body and "recurring_id" not in body["order"] + + +@responses.activate +def test_push_sends_zip_not_postal_for_us_country(donationaction): + responses.add( + responses.POST, + rest("donationpush/"), + status=201, + json={"resource_uri": rest("donationaction/1/")}, + ) + donationaction.push(**{**PUSH_REQUIRED, "country": "US", "postal": "10001"}) + body = json_module.loads(responses.calls[0].request.body) + assert body["user"]["zip"] == "10001" + assert body["user"]["postal"] is None + + +@responses.activate +def test_push_sets_created_at_in_utc_isoformat(donationaction): + responses.add( + responses.POST, + rest("donationpush/"), + status=201, + json={"resource_uri": rest("donationaction/1/")}, + ) + created_at = datetime(2024, 1, 15, 16, 21, 29, tzinfo=timezone.utc) + donationaction.push(**PUSH_REQUIRED, created_at=created_at) + body = json_module.loads(responses.calls[0].request.body) + assert body["order"]["created_at"] == "2024-01-15T16:21:29+00:00" + + +@responses.activate +def test_push_recurring_id_adds_recurring_fields(donationaction): + responses.add( + responses.POST, + rest("donationpush/"), + status=201, + json={"resource_uri": rest("donationaction/1/")}, + ) + donationaction.push(**PUSH_REQUIRED, recurring_id="rec-1") + body = json_module.loads(responses.calls[0].request.body) + assert body["order"]["recurring_id"] == "rec-1" + assert body["order"]["recurring_period"] == "months" + + +@responses.activate +def test_push_custom_action_fields_and_skip_confirmation(donationaction): + responses.add( + responses.POST, + rest("donationpush/"), + status=201, + json={"resource_uri": rest("donationaction/1/")}, + ) + donationaction.push( + **PUSH_REQUIRED, custom_action_fields={"foo": "bar"}, skip_confirmation=True + ) + body = json_module.loads(responses.calls[0].request.body) + assert body["action"] == {"fields": {"foo": "bar"}, "skip_confirmation": "1"} + + +@responses.activate +def test_push_409_duplicate_writes_stderr_and_returns_none(donationaction, capsys): + responses.add(responses.POST, rest("donationpush/"), status=409, body="") + result = donationaction.push(**PUSH_REQUIRED) + assert result is None + assert "Duplicate donation_import_id" in capsys.readouterr().err + + +@responses.activate +def test_push_400_with_body_raises_validation_error_not_generic_exception( + donationaction, +): + """ + Pins the same systemic finding as test_connection.py and + test_transactions.py: push()'s `except HTTPError as e: if + e.response.status_code == 400: raise Exception(...)` branch + (donationaction.py:111-114) is written to handle 400s, but a real + ActionKit validation-error response carries a JSON body, so + _make_request converts it to ValidationError first -- this branch is + unreachable for a body-bearing 400 in practice, and ValidationError + propagates directly to the caller instead of the intended, friendlier + generic Exception with the response text embedded. + """ + responses.add( + responses.POST, + rest("donationpush/"), + status=400, + json={"amount": ["This field is required."]}, + ) + with pytest.raises(ValidationError): + donationaction.push(**PUSH_REQUIRED) + + +@responses.activate +def test_push_400_with_empty_body_hits_the_intended_generic_exception(donationaction): + """ + Contrast with the previous test: only an EMPTY-bodied 400 actually + reaches push()'s own `except HTTPError` 400-handling branch. + """ + responses.add(responses.POST, rest("donationpush/"), status=400, body="") + with pytest.raises(Exception) as excinfo: + donationaction.push(**PUSH_REQUIRED) + assert not isinstance(excinfo.value, ValidationError) + assert "Creation of donationaction failure" in str(excinfo.value) + + +# --- push_and_set_incomplete() / push_and_set_pending() ------------------ + + +@responses.activate +def test_push_and_set_incomplete_full_stack(donationaction): + responses.add( + responses.POST, + rest("donationpush/"), + status=201, + json={ + "resource_uri": rest("donationaction/1/"), + "status": "new", + "order": { + "resource_uri": rest("order/1/"), + "transactions": [rest("transaction/1/")], + "orderrecurrings": [], + }, + }, + ) + responses.add(responses.PATCH, rest("donationaction/1/"), status=200) + responses.add(responses.PATCH, rest("order/1/"), status=200) + responses.add(responses.PATCH, rest("transaction/1/"), status=200) + + result = donationaction.push_and_set_incomplete( + email="a@example.com", + first_name="A", + last_name="B", + country="FR", + postal="75001", + amount=Decimal("5.00"), + currency="EUR", + page="my-page", + payment_account="wemove-account", + ) + assert result == rest("donationaction/1/") + # 1 POST to create + 3 PATCHes (action/order/transaction) from set_push_status + assert len(responses.calls) == 4 + assert json_module.loads(responses.calls[1].request.body) == {"status": "incomplete"} + assert json_module.loads(responses.calls[2].request.body) == {"status": "incomplete"} + assert json_module.loads(responses.calls[3].request.body) == {"status": "incomplete"} + + +@responses.activate +def test_push_and_set_pending_sets_order_and_transaction_status_pending(donationaction): + responses.add( + responses.POST, + rest("donationpush/"), + status=201, + json={ + "resource_uri": rest("donationaction/1/"), + "status": "new", + "order": { + "resource_uri": rest("order/1/"), + "transactions": [rest("transaction/1/")], + "orderrecurrings": [], + }, + }, + ) + responses.add(responses.PATCH, rest("donationaction/1/"), status=200) + responses.add(responses.PATCH, rest("order/1/"), status=200) + responses.add(responses.PATCH, rest("transaction/1/"), status=200) + + action = donationaction.push_and_set_pending( + email="a@example.com", + first_name="A", + last_name="B", + country="FR", + postal="75001", + amount=Decimal("5.00"), + currency="EUR", + page="my-page", + payment_account="wemove-account", + ) + assert action["resource_uri"] == rest("donationaction/1/") + assert json_module.loads(responses.calls[1].request.body) == {"status": "incomplete"} + assert json_module.loads(responses.calls[2].request.body) == {"status": "pending"} + assert json_module.loads(responses.calls[3].request.body) == {"status": "pending"} + + +# --- set_push_status() -------------------------------------------------- + + +def test_set_push_status_requires_data_or_resource_uri(donationaction): + with pytest.raises(KeyError): + donationaction.set_push_status("completed") + + +def test_set_push_status_resource_uri_with_only_one_of_order_or_transaction_uri( + donationaction, +): + with pytest.raises(KeyError): + donationaction.set_push_status( + "completed", resource_uri=rest("donationaction/1/"), order_uri=rest("order/1/") + ) + + +def test_set_push_status_data_plus_uris_is_rejected(donationaction): + with pytest.raises(KeyError): + donationaction.set_push_status( + "completed", + donationaction_data={"status": "new"}, + resource_uri=rest("donationaction/1/"), + ) + + +@responses.activate +def test_set_push_status_skips_when_already_set(donationaction): + result = donationaction.set_push_status( + "completed", + donationaction_data={ + "status": "completed", + "resource_uri": rest("donationaction/1/"), + "order": { + "resource_uri": rest("order/1/"), + "transactions": [rest("transaction/1/")], + "orderrecurrings": [], + }, + }, + no_action_if_status_is_already_set=True, + ) + assert result == rest("donationaction/1/") + assert len(responses.calls) == 0 + + +@responses.activate +def test_set_push_status_full_uri_sequence(donationaction): + responses.add(responses.PATCH, rest("donationaction/1/"), status=200) + responses.add(responses.PATCH, rest("order/1/"), status=200) + responses.add(responses.PATCH, rest("transaction/1/"), status=200) + + result = donationaction.set_push_status( + "completed", + donationaction_data={ + "status": "new", + "resource_uri": rest("donationaction/1/"), + "order": { + "resource_uri": rest("order/1/"), + "transactions": [rest("transaction/1/")], + "orderrecurrings": [], + }, + }, + ) + assert result == rest("donationaction/1/") + assert len(responses.calls) == 3 + + +@responses.activate +def test_set_push_status_custom_action_fields_adds_fourth_patch(donationaction): + responses.add(responses.PATCH, rest("donationaction/1/"), status=200) + responses.add(responses.PATCH, rest("order/1/"), status=200) + responses.add(responses.PATCH, rest("transaction/1/"), status=200) + responses.add(responses.PATCH, rest("donationaction/1/"), status=200) + + donationaction.set_push_status( + "completed", + donationaction_data={ + "status": "new", + "resource_uri": rest("donationaction/1/"), + "fields": {"existing": "1"}, + "order": { + "resource_uri": rest("order/1/"), + "transactions": [rest("transaction/1/")], + "orderrecurrings": [], + }, + }, + custom_action_fields={"new_field": "2"}, + ) + assert len(responses.calls) == 4 + fields_body = json_module.loads(responses.calls[3].request.body) + assert fields_body == {"fields": {"existing": "1", "new_field": "2"}} + + +@responses.activate +def test_set_push_status_recurring_id_patches_first_orderrecurring_uri(donationaction): + responses.add(responses.PATCH, rest("donationaction/1/"), status=200) + responses.add(responses.PATCH, rest("order/1/"), status=200) + responses.add(responses.PATCH, rest("transaction/1/"), status=200) + responses.add(responses.PATCH, rest("orderrecurring/1/"), status=200) + + donationaction.set_push_status( + "completed", + donationaction_data={ + "status": "new", + "resource_uri": rest("donationaction/1/"), + "order": { + "resource_uri": rest("order/1/"), + "transactions": [rest("transaction/1/")], + "orderrecurrings": [rest("orderrecurring/1/"), rest("orderrecurring/2/")], + }, + }, + recurring_id="rec-1", + ) + assert len(responses.calls) == 4 + body = json_module.loads(responses.calls[3].request.body) + assert body == {"recurring_id": "rec-1", "recurring_period": "months"} + + +# --- set_push_status_* wrappers ------------------------------------------ + + +@pytest.mark.parametrize( + "wrapper,expected_kwargs", + [ + ("set_push_status_incomplete", {"action_status": "incomplete"}), + ( + "set_push_status_completed", + {"action_status": "completed", "no_action_if_status_is_already_set": True}, + ), + ("set_push_status_failed", {"action_status": "failed"}), + ( + "set_push_status_pending", + { + "action_status": "incomplete", + "order_status": "pending", + "transaction_status": "pending", + }, + ), + ], +) +def test_status_wrappers_delegate_to_set_push_status( + donationaction, monkeypatch, wrapper, expected_kwargs +): + calls = [] + + def fake_set_push_status(self, action_status, *args, **kwargs): + calls.append((action_status, kwargs)) + return "resource_uri" + + monkeypatch.setattr( + "actionkit.donationaction.DonationAction.set_push_status", fake_set_push_status + ) + getattr(donationaction, wrapper)(donationaction_data={"status": "new"}) + assert len(calls) == 1 + action_status, kwargs = calls[0] + assert action_status == expected_kwargs["action_status"] + for key, value in expected_kwargs.items(): + if key == "action_status": + continue + assert kwargs.get(key) == value + + +# --- cancel_recurring_profile() / add_recurring_payment() ---------------- + + +@responses.activate +def test_cancel_recurring_profile_posts_expected_payload(donationaction): + responses.add(responses.POST, rest("profilecancelpush/"), status=201, json={}) + donationaction.cancel_recurring_profile("rec-1", "processor") + body = json_module.loads(responses.calls[0].request.body) + assert body == {"recurring_id": "rec-1", "canceled_by": "processor"} + + +@responses.activate +def test_add_recurring_payment_posts_payload_unchanged(donationaction): + responses.add(responses.POST, rest("recurringpaymentpush/"), status=201, json={}) + payment = {"order_id": "1", "success": True} + donationaction.add_recurring_payment(payment) + assert json_module.loads(responses.calls[0].request.body) == payment + + +# --- extract_resource_uris() ---------------------------------------------- + + +def test_extract_resource_uris_requires_an_argument(donationaction): + with pytest.raises(KeyError): + donationaction.extract_resource_uris() + + +def test_extract_resource_uris_from_data(donationaction): + data = { + "resource_uri": rest("donationaction/1/"), + "order": { + "resource_uri": rest("order/1/"), + "transactions": [rest("transaction/1/")], + "orderrecurrings": [rest("orderrecurring/1/")], + }, + } + assert donationaction.extract_resource_uris(donationaction_data=data) == { + "resource_uri": rest("donationaction/1/"), + "order_uri": rest("order/1/"), + "transaction_uri": rest("transaction/1/"), + "orderrecurring_uris": [rest("orderrecurring/1/")], + } + + +@responses.activate +def test_extract_resource_uris_fetches_by_resource_uri(donationaction): + responses.add( + responses.GET, + rest("donationaction/1/"), + json={ + "resource_uri": rest("donationaction/1/"), + "order": { + "resource_uri": rest("order/1/"), + "transactions": [rest("transaction/1/")], + "orderrecurrings": [], + }, + }, + status=200, + ) + result = donationaction.extract_resource_uris(resource_uri=rest("donationaction/1/")) + assert result["order_uri"] == rest("order/1/") + + +# --- delete_donationaction() / delete_donationaction_by_resource_id ------ + + +@responses.activate +def test_delete_donationaction_incomplete_deletes(donationaction): + responses.add( + responses.GET, + rest("donationaction/1/"), + json={"status": "incomplete"}, + status=200, + ) + responses.add(responses.DELETE, rest("donationaction/1/"), status=204) + assert donationaction.delete_donationaction(rest("donationaction/1/")) is True + assert len(responses.calls) == 2 + + +@responses.activate +def test_delete_donationaction_non_incomplete_does_not_delete(donationaction): + responses.add( + responses.GET, + rest("donationaction/1/"), + json={"status": "completed"}, + status=200, + ) + assert donationaction.delete_donationaction(rest("donationaction/1/")) is True + assert len(responses.calls) == 1 + + +@responses.activate +def test_delete_donationaction_404_returns_false(donationaction): + responses.add(responses.GET, rest("donationaction/1/"), status=404, body="") + assert donationaction.delete_donationaction(rest("donationaction/1/")) is False + + +@responses.activate +def test_delete_donationaction_by_resource_id_builds_uri_and_delegates(donationaction): + responses.add( + responses.GET, + rest("donationaction/1/"), + json={"status": "incomplete"}, + status=200, + ) + responses.add(responses.DELETE, rest("donationaction/1/"), status=204) + donationaction.delete_donationaction_by_resource_id("1") + assert len(responses.calls) == 2 + + +def test_delete_donationaction_by_resource_id_noop_without_resource_id(donationaction): + donationaction.delete_donationaction_by_resource_id(None) diff --git a/tests/test_genericactions.py b/tests/test_genericactions.py new file mode 100644 index 0000000..361f978 --- /dev/null +++ b/tests/test_genericactions.py @@ -0,0 +1,48 @@ +import json as json_module + +import pytest +import responses + +from urls import rest + + +@pytest.fixture +def genericactions(ak): + return ak.GenericActions + + +def test_update_requires_resource_id_or_resource_uri(genericactions): + with pytest.raises(ValueError): + genericactions.update() + + +@responses.activate +def test_update_with_resource_id_builds_uri(genericactions): + responses.add(responses.PATCH, rest("action/1/"), status=200) + genericactions.update(resource_id="1", foo="bar") + assert responses.calls[0].request.url == rest("action/1/") + assert json_module.loads(responses.calls[0].request.body) == {"foo": "bar"} + + +@responses.activate +def test_update_with_fields_fetches_and_merges_before_patching(genericactions): + responses.add( + responses.GET, + rest("action/1/"), + json={"fields": {"existing": "1"}}, + status=200, + ) + responses.add(responses.PATCH, rest("action/1/"), status=200) + + genericactions.update(resource_uri=rest("action/1/"), fields={"new": "2"}) + + assert len(responses.calls) == 2 + body = json_module.loads(responses.calls[1].request.body) + assert body == {"fields": {"existing": "1", "new": "2"}} + + +@responses.activate +def test_update_without_fields_does_not_fetch_first(genericactions): + responses.add(responses.PATCH, rest("action/1/"), status=200) + genericactions.update(resource_uri=rest("action/1/"), status="done") + assert len(responses.calls) == 1 diff --git a/tests/test_groups.py b/tests/test_groups.py new file mode 100644 index 0000000..6b72fec --- /dev/null +++ b/tests/test_groups.py @@ -0,0 +1,44 @@ +import json as json_module + +import pytest +import responses + +from urls import rest + + +@pytest.fixture +def groups(ak): + return ak.Groups + + +@responses.activate +def test_uris_maps_name_to_resource_uri_with_limit_100(groups): + responses.add( + responses.GET, + rest("usergroup"), + json={ + "objects": [ + {"name": "Volunteers", "resource_uri": rest("usergroup/1/")}, + {"name": "Donors", "resource_uri": rest("usergroup/2/")}, + ] + }, + status=200, + ) + result = groups.uris() + assert result == { + "Volunteers": rest("usergroup/1/"), + "Donors": rest("usergroup/2/"), + } + assert responses.calls[0].request.url == rest("usergroup") + "?_limit=100" + + +@responses.activate +def test_create_posts_group(groups): + responses.add( + responses.POST, + rest("usergroup"), + status=201, + headers={"Location": rest("usergroup/3/")}, + ) + assert groups.create({"name": "New Group"}) == rest("usergroup/3/") + assert json_module.loads(responses.calls[0].request.body) == {"name": "New Group"} diff --git a/tests/test_httpmethods.py b/tests/test_httpmethods.py new file mode 100644 index 0000000..4ea7c11 --- /dev/null +++ b/tests/test_httpmethods.py @@ -0,0 +1,200 @@ +import json as json_module + +import pytest +import responses + +from actionkit.httpmethods import HttpMethods +from urls import rest + + +class _Dummy(HttpMethods): + resource_name = "dummy" + + +@pytest.fixture +def dummy(connection): + return _Dummy(connection) + + +def test_resource_name_not_defined_raises_not_implemented_error(connection): + class _NoName(HttpMethods): + pass + + with pytest.raises(NotImplementedError): + _NoName(connection).resource_name + + +# --- get() ----------------------------------------------------------------- + + +@responses.activate +def test_get_falls_back_to_resource_name(dummy): + responses.add(responses.GET, rest("dummy"), json={"ok": True}, status=200) + assert dummy.get() == {"ok": True} + + +@responses.activate +def test_get_uses_explicit_resource_uri_and_params(dummy): + responses.add(responses.GET, rest("dummy/1/"), json={"id": 1}, status=200) + result = dummy.get(rest("dummy/1/"), foo="bar") + assert result == {"id": 1} + assert responses.calls[0].request.url == rest("dummy/1/") + "?foo=bar" + + +# --- search() ---------------------------------------------------------------- + + +@responses.activate +def test_search_single_page(dummy): + responses.add( + responses.GET, + rest("dummy"), + json={"objects": [{"id": 1}, {"id": 2}], "meta": {"next": None}}, + status=200, + ) + assert dummy.search() == [{"id": 1}, {"id": 2}] + + +@responses.activate +def test_search_follows_pagination(dummy): + next_url = rest("dummy") + "?offset=2" + responses.add( + responses.GET, + rest("dummy"), + json={"objects": [{"id": 1}], "meta": {"next": next_url}}, + status=200, + ) + responses.add( + responses.GET, + next_url, + json={"objects": [{"id": 2}], "meta": {"next": None}}, + status=200, + ) + assert dummy.search() == [{"id": 1}, {"id": 2}] + assert len(responses.calls) == 2 + + +@responses.activate +def test_search_wraps_400_into_plain_exception(dummy): + responses.add(responses.GET, rest("dummy"), status=400, body="") + with pytest.raises(Exception) as excinfo: + dummy.search() + assert not isinstance(excinfo.value, ValueError) + + +# --- delete() ---------------------------------------------------------------- + + +@responses.activate +def test_delete_dry_run_makes_no_request(dummy): + assert dummy.delete(rest("dummy/1/"), dry_run=True) is True + assert len(responses.calls) == 0 + + +@responses.activate +def test_delete_success_returns_true(dummy): + responses.add(responses.DELETE, rest("dummy/1/"), status=204) + assert dummy.delete(rest("dummy/1/")) is True + + +@responses.activate +def test_delete_404_with_empty_body_and_ignore_404_returns_false(dummy): + """ + Only reaches HttpMethods.delete's except-HTTPError branch when the + response body is empty -- see test_connection.py's + test_error_with_body_raises_validation_error_regardless_of_status. A + real ActionKit 404 with a JSON body would raise ValidationError instead, + which this method does not catch at all (see the next test). + """ + responses.add(responses.DELETE, rest("dummy/1/"), status=404, body="") + assert dummy.delete(rest("dummy/1/"), ignore_404=True) is False + + +@responses.activate +def test_delete_404_with_empty_body_and_ignore_404_false_raises(dummy): + import requests + + responses.add(responses.DELETE, rest("dummy/1/"), status=404, body="") + with pytest.raises(requests.exceptions.HTTPError): + dummy.delete(rest("dummy/1/"), ignore_404=False) + + +@responses.activate +def test_delete_404_with_json_body_is_not_caught_as_ignore_404(dummy): + """ + Pins a likely-broken interaction with the Connection-layer finding: + a real ActionKit 404 response almost always carries a JSON body, so + _make_request converts it to ValidationError before HttpMethods.delete's + `except HTTPError` block ever sees it -- ignore_404=True silently does + NOT protect the caller in that case, ValidationError propagates instead. + """ + from actionkit.validation import ValidationError + + responses.add( + responses.DELETE, rest("dummy/1/"), status=404, json={"error": "not found"} + ) + with pytest.raises(ValidationError): + dummy.delete(rest("dummy/1/"), ignore_404=True) + + +@responses.activate +def test_delete_non_404_error_reraises(dummy): + """ + Uses 403, not 500 -- 500 is Connection's sole retry_code and would + trigger the real (unmocked, in this file) backoff sleep loop. + """ + import requests + + responses.add(responses.DELETE, rest("dummy/1/"), status=403, body="") + with pytest.raises(requests.exceptions.HTTPError): + dummy.delete(rest("dummy/1/")) + + +# --- patch() / put() ----------------------------------------------------- + + +@responses.activate +def test_patch_sends_body_and_returns_true(dummy): + responses.add(responses.PATCH, rest("dummy/1/"), status=200) + assert dummy.patch(rest("dummy/1/"), {"status": "done"}) is True + assert json_module.loads(responses.calls[0].request.body) == {"status": "done"} + + +@responses.activate +def test_put_sends_body_and_returns_true(dummy): + responses.add(responses.PUT, rest("dummy/1/"), status=200) + assert dummy.put(rest("dummy/1/"), {"status": "done"}) is True + assert json_module.loads(responses.calls[0].request.body) == {"status": "done"} + + +# --- post() ---------------------------------------------------------------- + + +@responses.activate +def test_post_returns_resource_uri_from_location_header(dummy): + responses.add( + responses.POST, + rest("dummy"), + status=201, + headers={"Location": rest("dummy/1/")}, + ) + assert dummy.post({"name": "x"}) == rest("dummy/1/") + + +@responses.activate +def test_post_returns_none_when_no_location_header(dummy): + responses.add(responses.POST, rest("dummy"), status=201) + assert dummy.post({"name": "x"}) is None + + +# --- get_resource_uri_from_id() / get_by_id() ----------------------------- + + +def test_get_resource_uri_from_id(dummy): + assert dummy.get_resource_uri_from_id("7") == "/rest/v1/dummy/7/" + + +@responses.activate +def test_get_by_id(dummy): + responses.add(responses.GET, rest("dummy/7"), json={"id": 7}, status=200) + assert dummy.get_by_id("7") == {"id": 7} diff --git a/tests/test_languages.py b/tests/test_languages.py new file mode 100644 index 0000000..bbbd6b8 --- /dev/null +++ b/tests/test_languages.py @@ -0,0 +1,66 @@ +import json as json_module + +import pytest +import responses + +from urls import rest + + +@pytest.fixture +def languages(ak): + return ak.Languages + + +def _lang(name, iso_code, translations=None): + return { + "name": name, + "iso_code": iso_code, + "resource_uri": rest(f"language/{iso_code}/"), + "translations": json_module.dumps(translations or {}), + } + + +@responses.activate +def test_by_code_uses_actual_iso_code_and_skips_disco(languages): + fr = _lang("French", "fr") + fake = _lang("Disco", "xx", {"actual_iso_code": "de"}) + responses.add( + responses.GET, + rest("language"), + json={"objects": [fr, fake], "meta": {"next": None}}, + status=200, + ) + result = languages.by_code() + assert set(result.keys()) == {"fr"} + assert result["fr"]["iso_code"] == "fr" + + +@responses.activate +def test_by_code_uses_translation_override_when_present(languages): + workaround = _lang("Klingon (fake)", "xx", {"actual_iso_code": "tlh"}) + responses.add( + responses.GET, + rest("language"), + json={"objects": [workaround], "meta": {"next": None}}, + status=200, + ) + result = languages.by_code() + assert "tlh" in result + assert result["tlh"]["iso_code"] == "tlh" + + +@responses.activate +def test_uris_does_not_filter_disco(languages): + """ + Unlike by_code(), uris() has no "Disco" exclusion -- pinning the + inconsistency between the two methods. + """ + disco = _lang("Disco", "xx", {"actual_iso_code": "de"}) + responses.add( + responses.GET, + rest("language"), + json={"objects": [disco], "meta": {"next": None}}, + status=200, + ) + result = languages.uris() + assert result == {"de": rest("language/xx/")} diff --git a/tests/test_lists.py b/tests/test_lists.py new file mode 100644 index 0000000..4daa424 --- /dev/null +++ b/tests/test_lists.py @@ -0,0 +1,71 @@ +import pytest +import responses + +from urls import rest + + +@pytest.fixture +def lists(ak): + return ak.Lists + + +@responses.activate +def test_get_or_create_returns_existing_list(lists): + responses.add( + responses.GET, + rest("list"), + json={ + "meta": {"total_count": 1}, + "objects": [{"id": 1, "name": "Newsletter"}], + }, + status=200, + ) + result = lists.get_or_create("Newsletter") + assert result == {"id": 1, "name": "Newsletter"} + assert len(responses.calls) == 1 + + +@responses.activate +def test_get_or_create_creates_when_missing(lists): + """ + Pins the documented "terrible design" (lists.py:12): creating a list + takes a second GET round-trip to fetch the object that was just POSTed, + rather than trusting the POST response. + """ + responses.add( + responses.GET, + rest("list"), + json={"meta": {"total_count": 0}, "objects": []}, + status=200, + ) + responses.add( + responses.POST, + rest("list"), + status=201, + headers={"Location": rest("list/9/")}, + ) + responses.add( + responses.GET, rest("list/9/"), json={"id": 9, "name": "New List"}, status=200 + ) + + result = lists.get_or_create("New List", notes="a note") + + assert result == {"id": 9, "name": "New List"} + assert len(responses.calls) == 3 + import json as json_module + + assert json_module.loads(responses.calls[1].request.body) == { + "name": "New List", + "notes": "a note", + } + + +@responses.activate +def test_all_returns_objects(lists): + responses.add( + responses.GET, + rest("list"), + json={"objects": [{"id": 1}, {"id": 2}]}, + status=200, + ) + assert lists.all() == [{"id": 1}, {"id": 2}] diff --git a/tests/test_multilingualcampaigns.py b/tests/test_multilingualcampaigns.py new file mode 100644 index 0000000..ed59dde --- /dev/null +++ b/tests/test_multilingualcampaigns.py @@ -0,0 +1,54 @@ +import json as json_module +from urllib.parse import parse_qs, urlparse + +import pytest +import responses + +from actionkit.multilingualcampaigns import MultilingualCampaigns +from urls import rest + + +@pytest.fixture +def mlc(ak): + return ak.MultilingualCampaigns + + +def test_name_joins_campaign_and_action_type(): + assert MultilingualCampaigns.name("My Campaign", "petition") == "My Campaign: petition" + + +@responses.activate +def test_get_step_returns_none_when_not_found(mlc): + responses.add( + responses.GET, + rest("multilingualcampaign"), + json={"objects": [], "meta": {"next": None}}, + status=200, + ) + assert mlc.get_step("My Campaign", "petition") is None + query = parse_qs(urlparse(responses.calls[0].request.url).query) + assert query["name"] == ["My Campaign: petition"] + + +@responses.activate +def test_get_step_returns_first_match(mlc): + responses.add( + responses.GET, + rest("multilingualcampaign"), + json={"objects": [{"id": 1}], "meta": {"next": None}}, + status=200, + ) + assert mlc.get_step("My Campaign", "petition") == {"id": 1} + + +@responses.activate +def test_create_posts_generated_name(mlc): + responses.add( + responses.POST, + rest("multilingualcampaign"), + status=201, + headers={"Location": rest("multilingualcampaign/1/")}, + ) + assert mlc.create("My Campaign", "petition") == rest("multilingualcampaign/1/") + body = json_module.loads(responses.calls[0].request.body) + assert body == {"name": "My Campaign: petition"} diff --git a/tests/test_orders.py b/tests/test_orders.py new file mode 100644 index 0000000..5f4a7cc --- /dev/null +++ b/tests/test_orders.py @@ -0,0 +1,38 @@ +import json as json_module +from decimal import Decimal + +import pytest +import responses + +from urls import rest + + +@pytest.fixture +def orders(ak): + return ak.Orders + + +def test_update_requires_resource_id_or_resource_uri(orders): + with pytest.raises(ValueError): + orders.update(total=Decimal("5.00")) + + +@responses.activate +def test_update_with_resource_uri_sends_decimal_as_string(orders): + responses.add(responses.PATCH, rest("order/1/"), status=200) + orders.update(resource_uri=rest("order/1/"), total=Decimal("5.00")) + assert json_module.loads(responses.calls[0].request.body) == {"total": "5.00"} + + +@responses.activate +def test_update_with_resource_id_builds_uri(orders): + responses.add(responses.PATCH, rest("order/1/"), status=200) + orders.update(resource_id="1", total=Decimal("5.00")) + assert responses.calls[0].request.url == rest("order/1/") + + +@responses.activate +def test_update_without_total_omits_it_from_payload(orders): + responses.add(responses.PATCH, rest("order/1/"), status=200) + orders.update(resource_uri=rest("order/1/"), status="paid") + assert json_module.loads(responses.calls[0].request.body) == {"status": "paid"} diff --git a/tests/test_path.py b/tests/test_path.py deleted file mode 100644 index aa21fc4..0000000 --- a/tests/test_path.py +++ /dev/null @@ -1,24 +0,0 @@ -import unittest - -import actionkit - - -class BaseUrlTest(unittest.TestCase): - def setUp(self): - self.ak = actionkit.ActionKit("example.com", "user", "password").connection - - def test_absolute_path(self): - self.assertEqual(self.ak._path("/a/b/c/"), "https://example.com/rest/v1/a/b/c/") - - def test_api_path(self): - ak = actionkit.ActionKit("example.com", "user", "password") - self.assertEqual( - self.ak._path("/rest/v1/b/c/"), "https://example.com/rest/v1/b/c/" - ) - - def test_absolute_url(self): - ak = actionkit.ActionKit("example.com", "user", "password") - self.assertEqual( - self.ak._path("https://example.com/i/am/complete"), - "https://example.com/i/am/complete", - ) diff --git a/tests/test_petitions.py b/tests/test_petitions.py new file mode 100644 index 0000000..4fe3111 --- /dev/null +++ b/tests/test_petitions.py @@ -0,0 +1,191 @@ +import json as json_module + +import pytest +import responses + +from urls import rest + + +@pytest.fixture +def petitions(ak): + return ak.Petitions + + +# --- get() -- overridden, incompatible signature --------------------------- + + +@responses.activate +def test_get_by_id_hardcodes_petitionpage_path(petitions): + """ + Petitions.get(id) overrides HttpMethods.get(resource_uri=None, *args, + **params) with an incompatible signature: positional-only `id`, no + query-param support, and it hardcodes "petitionpage/{id}/" rather than + using self.resource_name (which happens to also be "petitionpage" here, + but that's coincidence, not delegation). + """ + responses.add(responses.GET, rest("petitionpage/1/"), json={"id": 1}, status=200) + assert petitions.get(1) == {"id": 1} + + +def test_get_with_resource_uri_kwarg_breaks_the_override(petitions): + """ + The base class's `get(resource_uri=..., **params)` call shape is valid + on HttpMethods but not on this override -- Petitions.get(self, id) has + no `resource_uri` parameter, so calling it the way the base class + documents its contract raises TypeError. A Liskov-substitution break: + code written against HttpMethods.get's signature cannot safely call + Petitions.get. + """ + with pytest.raises(TypeError): + petitions.get(resource_uri=rest("petitionpage/1/")) + + +# --- update() ------------------------------------------------------------ + + +@responses.activate +def test_update_patches_petitionpage_path(petitions): + responses.add(responses.PATCH, rest("petitionpage/1/"), status=200) + assert petitions.update(1, {"title": "New title"}) is True + body = json_module.loads(responses.calls[0].request.body) + assert body == {"title": "New title"} + + +# --- create() ------------------------------------------------------------ + + +@responses.activate +def test_create_posts_page_form_and_followup_in_order(petitions): + responses.add( + responses.POST, + rest("petitionpage"), + status=201, + headers={"Location": rest("petitionpage/1/")}, + ) + responses.add( + responses.POST, + rest("petitionform"), + status=201, + headers={"Location": rest("petitionform/1/")}, + ) + responses.add( + responses.POST, + rest("pagefollowup"), + status=201, + headers={"Location": rest("pagefollowup/1/")}, + ) + + page_uri, form_uri, followup_uri = petitions.create( + page={"name": "test-page"}, + content={"statement_text": "Sign this"}, + followup={"thank_you_text": "Thanks!"}, + ) + + assert page_uri == rest("petitionpage/1/") + assert form_uri == rest("petitionform/1/") + assert followup_uri == rest("pagefollowup/1/") + + assert [c.request.url.rsplit("?", 1)[0] for c in responses.calls] == [ + rest("petitionpage"), + rest("petitionform"), + rest("pagefollowup"), + ] + + form_body = json_module.loads(responses.calls[1].request.body) + assert form_body == {"statement_text": "Sign this", "page": rest("petitionpage/1/")} + + followup_body = json_module.loads(responses.calls[2].request.body) + assert followup_body == {"thank_you_text": "Thanks!", "page": rest("petitionpage/1/")} + + +# --- create_from_model() -------------------------------------------------- + + +@responses.activate +def test_create_from_model_merges_and_copies_form_fields(petitions): + model = { + "language": "en", + "goal": 100, + "goal_type": "signatures", + "recognize": True, + "allow_multiple_responses": False, + "fields": {"model_field": "1"}, + "groups": [{"resource_uri": rest("usergroup/1/")}], + "cms_form": { + "resource_uri": rest("petitionform/9/"), + "about_text": "About", + "statement_leadin": "Leadin", + "statement_text": "Statement", + "templateset": "default", + "thank_you_text": "Thanks", + }, + "followup": { + "id": 99, + "page": rest("petitionpage/9/"), + "resource_uri": rest("pagefollowup/9/"), + "url": "https://example.com/thanks", + "thank_you_text": "Old thanks", + }, + } + page = {"name": "new-page", "fields": {"page_field": "2"}} + content = {"about_text": "Overridden about"} + followup = {"thank_you_text": "New thanks"} + + responses.add( + responses.POST, + rest("petitionpage"), + status=201, + headers={"Location": rest("petitionpage/2/")}, + ) + responses.add( + responses.POST, + rest("petitionform"), + status=201, + headers={"Location": rest("petitionform/2/")}, + ) + responses.add( + responses.POST, + rest("pagefollowup"), + status=201, + headers={"Location": rest("pagefollowup/2/")}, + ) + responses.add( + responses.GET, + rest("userformfield"), + json={"objects": [ + { + "id": 1, + "form_id": 9, + "created_at": "x", + "updated_at": "x", + "resource_uri": rest("userformfield/1/"), + "label": "Email", + } + ]}, + status=200, + ) + responses.add(responses.POST, rest("userformfield"), status=201) + + uris = petitions.create_from_model(model, page, content, followup) + assert uris == ( + rest("petitionpage/2/"), + rest("petitionform/2/"), + rest("pagefollowup/2/"), + ) + + page_body = json_module.loads(responses.calls[0].request.body) + assert page_body["fields"] == {"model_field": "1", "page_field": "2"} + assert page_body["groups"] == [rest("usergroup/1/")] + + content_body = json_module.loads(responses.calls[1].request.body) + assert content_body["about_text"] == "Overridden about" + assert content_body["statement_text"] == "Statement" + + followup_body = json_module.loads(responses.calls[2].request.body) + assert followup_body["thank_you_text"] == "New thanks" + assert "id" not in followup_body and "resource_uri" not in followup_body + + field_body = json_module.loads(responses.calls[4].request.body) + assert field_body["label"] == "Email" + assert field_body["form_id"] == "2" + assert "id" not in field_body and "created_at" not in field_body diff --git a/tests/test_profilecancelpush.py b/tests/test_profilecancelpush.py new file mode 100644 index 0000000..6dbb0dd --- /dev/null +++ b/tests/test_profilecancelpush.py @@ -0,0 +1,47 @@ +import json as json_module +from datetime import datetime, timezone + +import pytest +import responses + +from urls import rest + + +@pytest.fixture +def profilecancelpush(ak): + return ak.ProfileCancelPush + + +def test_push_requires_order_id_or_recurring_id(profilecancelpush): + with pytest.raises(ValueError): + profilecancelpush.push() + + +@responses.activate +def test_push_sends_expected_payload(profilecancelpush): + responses.add(responses.POST, rest("profilecancelpush"), status=201, json={}) + profilecancelpush.push(order_id="42", canceled_by="user") + body = json_module.loads(responses.calls[0].request.body) + assert body == {"canceled_by": "user", "order_id": "42"} + + +@responses.activate +def test_push_defaults_canceled_by_to_processor(profilecancelpush): + responses.add(responses.POST, rest("profilecancelpush"), status=201, json={}) + profilecancelpush.push(recurring_id="rec-1") + body = json_module.loads(responses.calls[0].request.body) + assert body["canceled_by"] == "processor" + + +def test_push_rejects_naive_created_at(profilecancelpush): + with pytest.raises(ValueError): + profilecancelpush.push(order_id="42", created_at=datetime.now()) + + +@responses.activate +def test_push_strips_created_at(profilecancelpush): + responses.add(responses.POST, rest("profilecancelpush"), status=201, json={}) + created_at = datetime(2024, 1, 15, 16, 21, 29, 930080, tzinfo=timezone.utc) + profilecancelpush.push(order_id="42", created_at=created_at) + body = json_module.loads(responses.calls[0].request.body) + assert body["created_at"] == "2024-01-15T16:21:29" diff --git a/tests/test_profileupdatepush.py b/tests/test_profileupdatepush.py new file mode 100644 index 0000000..7429056 --- /dev/null +++ b/tests/test_profileupdatepush.py @@ -0,0 +1,54 @@ +import json as json_module +from datetime import datetime, timezone +from decimal import Decimal + +import pytest +import responses + +from urls import rest + + +@pytest.fixture +def profileupdatepush(ak): + return ak.ProfileUpdatePush + + +def test_push_requires_order_id_or_recurring_id(profileupdatepush): + with pytest.raises(ValueError): + profileupdatepush.push(amount=Decimal("5.00"), currency="eur") + + +@responses.activate +def test_push_sends_expected_payload_and_uppercases_currency(profileupdatepush): + responses.add(responses.POST, rest("profileupdatepush"), status=201, json={}) + profileupdatepush.push( + amount=Decimal("5.00"), currency="eur", order_id="42", trans_id="tx1" + ) + body = json_module.loads(responses.calls[0].request.body) + assert body == { + "amount": "5.00", + "currency": "EUR", + "trans_id": "tx1", + "order_id": "42", + } + + +def test_push_rejects_naive_created_at(profileupdatepush): + with pytest.raises(ValueError): + profileupdatepush.push( + amount=Decimal("5.00"), + currency="eur", + order_id="42", + created_at=datetime.now(), + ) + + +@responses.activate +def test_push_strips_created_at(profileupdatepush): + responses.add(responses.POST, rest("profileupdatepush"), status=201, json={}) + created_at = datetime(2024, 1, 15, 16, 21, 29, tzinfo=timezone.utc) + profileupdatepush.push( + amount=Decimal("5.00"), currency="eur", order_id="42", created_at=created_at + ) + body = json_module.loads(responses.calls[0].request.body) + assert body["created_at"] == "2024-01-15T16:21:29" diff --git a/tests/test_recurringpaymentpush.py b/tests/test_recurringpaymentpush.py new file mode 100644 index 0000000..6594bc7 --- /dev/null +++ b/tests/test_recurringpaymentpush.py @@ -0,0 +1,64 @@ +import json as json_module +from datetime import datetime, timezone + +import pytest +import responses + +from urls import rest + + +@pytest.fixture +def recurringpaymentpush(ak): + return ak.RecurringPaymentPush + + +def test_push_requires_order_id_or_recurring_id(recurringpaymentpush): + with pytest.raises(ValueError): + recurringpaymentpush.push() + + +@responses.activate +def test_push_sends_expected_payload_with_defaults(recurringpaymentpush): + responses.add(responses.POST, rest("recurringpaymentpush"), status=201, json={}) + recurringpaymentpush.push(order_id="42") + body = json_module.loads(responses.calls[0].request.body) + assert body == { + "success": True, + "status": "completed", + "failure_code": None, + "failure_message": None, + "failure_description": None, + "trans_id": None, + "order_id": "42", + } + + +@responses.activate +def test_push_reports_failure(recurringpaymentpush): + responses.add(responses.POST, rest("recurringpaymentpush"), status=201, json={}) + recurringpaymentpush.push( + recurring_id="rec-1", + success=False, + status="failed", + failure_code="card_declined", + failure_message="Card declined", + ) + body = json_module.loads(responses.calls[0].request.body) + assert body["success"] is False + assert body["status"] == "failed" + assert body["failure_code"] == "card_declined" + assert body["recurring_id"] == "rec-1" + + +def test_push_rejects_naive_created_at(recurringpaymentpush): + with pytest.raises(ValueError): + recurringpaymentpush.push(order_id="42", created_at=datetime.now()) + + +@responses.activate +def test_push_strips_created_at(recurringpaymentpush): + responses.add(responses.POST, rest("recurringpaymentpush"), status=201, json={}) + created_at = datetime(2024, 1, 15, 16, 21, 29, tzinfo=timezone.utc) + recurringpaymentpush.push(order_id="42", created_at=created_at) + body = json_module.loads(responses.calls[0].request.body) + assert body["created_at"] == "2024-01-15T16:21:29" diff --git a/tests/test_sql.py b/tests/test_sql.py new file mode 100644 index 0000000..6769aec --- /dev/null +++ b/tests/test_sql.py @@ -0,0 +1,74 @@ +import json as json_module + +import pytest +import responses + +from urls import rest + + +@pytest.fixture +def sql(ak): + return ak.SQL + + +def test_run_report_requires_report_name(sql): + with pytest.raises(ValueError): + sql.run_report("") + + +@responses.activate +def test_run_report_posts_to_report_name_path_and_returns_json(sql): + responses.add( + responses.POST, + rest("report/run/my_report"), + json={"results": [1, 2, 3]}, + status=200, + ) + assert sql.run_report("my_report", extra="x") == {"results": [1, 2, 3]} + assert json_module.loads(responses.calls[0].request.body) == {"extra": "x"} + + +def test_run_query_requires_query(sql): + with pytest.raises(ValueError): + sql.run_query("") + + +@responses.activate +def test_run_query_posts_query_with_defaults_and_returns_json(sql): + responses.add( + responses.POST, rest("report/run/sql"), json=[[1], [2]], status=200 + ) + result = sql.run_query("SELECT 1") + assert result == [[1], [2]] + body = json_module.loads(responses.calls[0].request.body) + assert body == { + "query": "SELECT 1", + "refresh": False, + "cache_duration": 600, + } + + +@responses.activate +def test_fetch_transaction_id_by_trans_id_returns_first_column_of_first_row(sql): + responses.add( + responses.POST, rest("report/run/sql"), json=[[42]], status=200 + ) + assert sql.fetch_transaction_id_by_trans_id("tx1") == 42 + + +@responses.activate +def test_fetch_transaction_id_by_trans_id_no_results_returns_none(sql): + responses.add(responses.POST, rest("report/run/sql"), json=[], status=200) + assert sql.fetch_transaction_id_by_trans_id("tx1") is None + + +@responses.activate +def test_fetch_signup_action_ids_flattens_rows(sql): + responses.add( + responses.POST, rest("report/run/sql"), json=[[1], [2], [3]], status=200 + ) + assert sql.fetch_signup_action_ids(page_id=10, user_id=20) == [1, 2, 3] + body = json_module.loads(responses.calls[0].request.body) + assert body["page_id"] == 10 + assert body["user_id"] == 20 + assert body["cache_duration"] == 1 diff --git a/tests/test_transactions.py b/tests/test_transactions.py new file mode 100644 index 0000000..9f40fba --- /dev/null +++ b/tests/test_transactions.py @@ -0,0 +1,166 @@ +import json as json_module +from decimal import Decimal + +import pytest +import responses + +from actionkit.validation import ValidationError +from urls import rest + + +@pytest.fixture +def transactions(ak): + return ak.Transactions + + +# --- create() ---------------------------------------------------------- + + +@responses.activate +def test_create_with_order_uri_sends_expected_payload(transactions): + responses.add( + responses.POST, + rest("transaction"), + status=201, + headers={"Location": rest("transaction/1/")}, + ) + result = transactions.create( + account="WM-Card", + amount=Decimal("5.00"), + currency="EUR", + order_uri=rest("order/1/"), + ) + assert result == rest("transaction/1/") + body = json_module.loads(responses.calls[0].request.body) + assert body == { + "account": "WM-Card", + "amount": "5.00", + "currency": "EUR", + "type": "sale", + "order": rest("order/1/"), + } + + +def test_create_requires_order_uri_or_order_id(transactions): + with pytest.raises(ValueError): + transactions.create(account="WM-Card", amount=Decimal("5.00"), currency="EUR") + + +def test_create_rejects_invalid_type(transactions): + with pytest.raises(ValueError): + transactions.create( + account="WM-Card", + amount=Decimal("5.00"), + currency="EUR", + order_uri=rest("order/1/"), + type="not-a-real-type", + ) + + +def test_create_with_order_id_is_broken(transactions): + """ + Pins a real bug, does not fix it: Transactions.create (transactions.py:97) + calls `Orders.get_resource_uri_from_id(order_id)` UNBOUND on the Orders + class rather than on an instance. HttpMethods.get_resource_uri_from_id is + `def get_resource_uri_from_id(self, resource_id)`, so this call binds + order_id to `self` and leaves the real `resource_id` parameter unfilled. + Transactions.create(order_id=...) (without order_uri) currently always + raises TypeError, never reaching the network. Contrast with the correct + `self.get_resource_uri_from_id(resource_id)` pattern in orders.py:23. + """ + with pytest.raises(TypeError): + transactions.create( + account="WM-Card", + amount=Decimal("5.00"), + currency="EUR", + order_id="123", + ) + + +# --- reverse() --------------------------------------------------------- + + +@responses.activate +def test_reverse_with_transaction_uri(transactions): + responses.add( + responses.POST, rest("transaction/1/reverse"), status=201, json={"ok": True} + ) + transactions.reverse(transaction_uri=rest("transaction/1/")) + assert len(responses.calls) == 1 + + +@responses.activate +def test_reverse_with_transaction_id_builds_uri(transactions): + responses.add( + responses.POST, rest("transaction/1/reverse"), status=201, json={"ok": True} + ) + transactions.reverse(transaction_id="1") + assert len(responses.calls) == 1 + + +def test_reverse_requires_id_or_uri(transactions): + with pytest.raises(ValueError): + transactions.reverse() + + +@responses.activate +def test_reverse_404_warns_and_returns_none(transactions): + responses.add( + responses.POST, rest("transaction/1/reverse"), status=404, body="" + ) + assert transactions.reverse(transaction_uri=rest("transaction/1/")) is None + + +@responses.activate +def test_reverse_400_already_reversed_message_returns_none(transactions): + """ + Only reachable when the 400 response body is EMPTY of a status-code + trigger... actually the opposite: _make_request only lets a bare + requests.HTTPError through when the body is empty. A real "already + reversed" 400 response from ActionKit carries a JSON body, so in + practice it surfaces as ValidationError, not HTTPError -- see the next + test, which is what actually happens against a real server. + """ + responses.add(responses.POST, rest("transaction/1/reverse"), status=400, body="") + with pytest.raises(Exception): + transactions.reverse(transaction_uri=rest("transaction/1/")) + + +@responses.activate +def test_reverse_400_with_body_raises_validation_error_and_is_not_caught(transactions): + """ + Pins a likely-broken interaction: a real ActionKit "already reversed" + 400 response has a JSON body like {"order_id": "Transaction has already + been reversed."}, which _make_request converts to ValidationError before + reverse()'s `except HTTPError` branch can inspect the status code. The + `except ValidationError` branch below it is meant to catch this instead, + but see test_reverse_validation_error_already_reversed_branch_is_dead -- + that branch's own comparison looks broken too. + """ + responses.add( + responses.POST, + rest("transaction/1/reverse"), + status=400, + json={"order_id": "Transaction has already been reversed."}, + ) + with pytest.raises(ValidationError): + transactions.reverse(transaction_uri=rest("transaction/1/")) + + +def test_reverse_validation_error_already_reversed_branch_is_dead(transactions): + """ + Pins a second bug in reverse()'s `except ValidationError` branch + (transactions.py:48-57): it compares `e.errors` -- a LIST, per + ValidationError.__init__ (`self.errors = list(response.values())`) -- + against the string literal 'Transaction has already been reversed.'. A + list can never equal that string, so this branch's "swallow an + already-reversed error" behavior can never actually fire; it always + falls through to `raise e`. This test exercises the comparison directly + rather than through a live HTTP call, to isolate it from the (also + broken) exception-type mismatch covered above. + """ + err = ValidationError( + json_module.dumps({"order_id": "Transaction has already been reversed."}) + ) + assert err.errors == ["Transaction has already been reversed."] + assert err.errors != "Transaction has already been reversed." diff --git a/tests/test_uploads.py b/tests/test_uploads.py new file mode 100644 index 0000000..458eb72 --- /dev/null +++ b/tests/test_uploads.py @@ -0,0 +1,79 @@ +import responses + +from urls import rest + + +@responses.activate +def test_poll_gets_upload_status(ak): + responses.add( + responses.GET, rest("upload/1/"), json={"is_completed": True}, status=200 + ) + assert ak.Uploads.poll(rest("upload/1/")) == {"is_completed": True} + + +@responses.activate +def test_upload_sends_expected_multipart_fields(ak, tmp_path, monkeypatch): + csv_file = tmp_path / "import.csv" + csv_file.write_text("email\na@example.com\n") + + responses.add( + responses.POST, + rest("upload"), + status=201, + headers={"Location": rest("upload/1/")}, + ) + responses.add( + responses.GET, rest("upload/1/"), json={"is_completed": True}, status=200 + ) + monkeypatch.setattr("time.sleep", lambda seconds: None) + + ak.Uploads.upload(str(csv_file), "/rest/v1/importpage/1/") + + post_request = responses.calls[0].request + assert post_request.headers["Content-Type"].startswith("multipart/form-data") + body = post_request.body.decode("utf-8") if isinstance(post_request.body, bytes) else post_request.body + assert 'name="page"' in body + assert "/rest/v1/importpage/1/" in body + assert 'name="upload"; filename=' in body + assert 'name="autocreate_user_fields"' in body + # autocreate_user_fields is sent as the literal string "false", not a bool + assert "\r\n\r\nfalse\r\n" in body + + +@responses.activate +def test_upload_polls_until_is_completed(ak, tmp_path, monkeypatch): + """ + Pins the current, unbounded polling loop -- see uploads.py:32-34. There + is no timeout or max-attempt count in the real implementation; a stuck + ActionKit import would hang the caller forever. This test only + terminates because the mocked poll() sequence is finite. + """ + csv_file = tmp_path / "import.csv" + csv_file.write_text("email\na@example.com\n") + + responses.add( + responses.POST, + rest("upload"), + status=201, + headers={"Location": rest("upload/1/")}, + ) + poll_results = [ + {"is_completed": False}, + {"is_completed": False}, + {"is_completed": True}, + ] + call_count = {"n": 0} + + def poll(self, upload_url): + result = poll_results[call_count["n"]] + call_count["n"] += 1 + return result + + monkeypatch.setattr("actionkit.uploads.Uploads.poll", poll) + sleeps = [] + monkeypatch.setattr("time.sleep", lambda seconds: sleeps.append(seconds)) + + ak.Uploads.upload(str(csv_file), "/rest/v1/importpage/1/") + + assert call_count["n"] == 3 + assert sleeps == [1, 1] diff --git a/tests/test_users.py b/tests/test_users.py new file mode 100644 index 0000000..8696d0c --- /dev/null +++ b/tests/test_users.py @@ -0,0 +1,149 @@ +import base64 +import hashlib + +import pytest +import responses + +from urls import rest + + +def _hash(secret, cleartext): + sha = hashlib.sha256(f"{secret}.{cleartext}".encode("ascii")) + return base64.urlsafe_b64encode(sha.digest()).decode("ascii")[:6] + + +@pytest.fixture +def users(ak): + return ak.Users + + +# --- id() ------------------------------------------------------------------ + + +@pytest.mark.parametrize( + "uri,expected", + [ + ("/user/1", "1"), + ("/user/11/", "11"), + ], +) +def test_id_extracts_numeric_id(users, uri, expected): + assert users.id(uri) == expected + + +def test_id_raises_bare_exception_on_no_match(users): + """ + Pins an inconsistency: this raises a bare Exception, not ValueError -- + unlike get_by_akid below, which wraps failures into ValueError. + """ + with pytest.raises(Exception) as excinfo: + users.id("/user/asdf") + assert not isinstance(excinfo.value, ValueError) + + +# --- get_by_email() ---------------------------------------------------- + + +@responses.activate +def test_get_by_email_no_results_returns_none(users): + responses.add( + responses.GET, + rest("user"), + json={"objects": [], "meta": {"next": None}}, + status=200, + ) + assert users.get_by_email("nobody@example.com") is None + + +@responses.activate +def test_get_by_email_returns_first_result(users): + responses.add( + responses.GET, + rest("user"), + json={ + "objects": [{"id": 1, "email": "a@example.com"}], + "meta": {"next": None}, + }, + status=200, + ) + assert users.get_by_email("a@example.com") == {"id": 1, "email": "a@example.com"} + + +# --- create() / update() / uri() ------------------------------------------ + + +@responses.activate +def test_create_posts_user(users): + responses.add( + responses.POST, rest("user"), status=201, headers={"Location": rest("user/1/")} + ) + assert users.create({"email": "a@example.com"}) == rest("user/1/") + + +@responses.activate +def test_update_patches_user(users): + responses.add(responses.PATCH, rest("user/1/"), status=200) + assert users.update(rest("user/1/"), {"first_name": "A"}) is True + + +def test_uri_with_id(users): + assert users.uri("42") == "user/42" + + +def test_uri_without_id(users): + assert users.uri() == "user/" + + +# --- get_by_akid() ---------------------------------------------------------- + +# akid contract, reverse-engineered from get_by_akid()'s `chunks[1]` and +# verify_hashed_value()'s "pop the last dot-segment off as the hash" scheme: +# the cleartext portion must itself contain a "." so that splitting the full +# akid on "." puts the user id at index 1, e.g. "u.." (see +# scripts/hashme.py, which hashes an arbitrary caller-supplied string). + + +def _akid(secret, user_id, prefix="u"): + cleartext = f"{prefix}.{user_id}" + return f"{cleartext}.{_hash(secret, cleartext)}" + + +@responses.activate +def test_get_by_akid_limited_returns_only_three_fields(users, monkeypatch): + monkeypatch.setenv("ACTIONKIT_SECRET_KEY", "s3cr3t") + akid = _akid("s3cr3t", 42) + responses.add( + responses.GET, + rest("user/42"), + json={ + "id": 42, + "first_name": "A", + "last_name": "B", + "email": "a@example.com", + "phone": "12345", + }, + status=200, + ) + result = users.get_by_akid(akid) + assert result == {"first_name": "A", "last_name": "B", "email": "a@example.com"} + + +@responses.activate +def test_get_by_akid_unlimited_returns_everything(users, monkeypatch): + monkeypatch.setenv("ACTIONKIT_SECRET_KEY", "s3cr3t") + akid = _akid("s3cr3t", 42) + full_user = {"id": 42, "first_name": "A", "last_name": "B", "email": "a@example.com"} + responses.add(responses.GET, rest("user/42"), json=full_user, status=200) + assert users.get_by_akid(akid, limited=False) == full_user + + +def test_get_by_akid_bad_hash_wraps_into_value_error(users, monkeypatch): + monkeypatch.setenv("ACTIONKIT_SECRET_KEY", "s3cr3t") + with pytest.raises(ValueError, match="Invalid akid"): + users.get_by_akid("u.42.wrong1") + + +def test_get_by_akid_missing_secret_wraps_into_value_error(users, monkeypatch): + monkeypatch.delenv("ACTIONKIT_SECRET_KEY", raising=False) + with pytest.raises(ValueError, match="Invalid akid"): + users.get_by_akid("u.42.abcdef") diff --git a/tests/test_users_regex.py b/tests/test_users_regex.py deleted file mode 100644 index 9e9ac69..0000000 --- a/tests/test_users_regex.py +++ /dev/null @@ -1,18 +0,0 @@ - -import unittest -import actionkit - - -class BaseUrlTest(unittest.TestCase): - def setUp(self): - self.ak = actionkit.ActionKit("example.com", "user", "password") - - def test_user_id_check(self): - self.assertEqual(self.ak.Users.id("/user/1"), "1") - self.assertEqual(self.ak.Users.id("/user/11/"), "11") - - with self.assertRaises(Exception): - self.ak.Users.id("/user/asdf") - - - diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..71599f4 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,109 @@ +import base64 +import hashlib +from datetime import datetime, timedelta, timezone + +import pytest + +from actionkit.utils import ( + convert_datetime_to_utc, + datetime_to_stripped_isoformat, + verify_hashed_value, +) + + +def _hash(secret, cleartext): + sha = hashlib.sha256(f"{secret}.{cleartext}".encode("ascii")) + return base64.urlsafe_b64encode(sha.digest()).decode("ascii")[:6] + + +# --- convert_datetime_to_utc() -------------------------------------------- + + +def test_convert_datetime_to_utc_from_other_timezone(): + minus_five = timezone(timedelta(hours=-5)) + dt = datetime(2024, 1, 15, 11, 21, 29, tzinfo=minus_five) + converted = convert_datetime_to_utc(dt) + assert converted == datetime(2024, 1, 15, 16, 21, 29, tzinfo=timezone.utc) + + +def test_convert_datetime_to_utc_already_utc_is_unchanged(): + dt = datetime(2024, 1, 15, 16, 21, 29, tzinfo=timezone.utc) + assert convert_datetime_to_utc(dt) == dt + + +# --- datetime_to_stripped_isoformat() --------------------------------- + +def test_strips_microseconds_and_positive_offset(): + dt = datetime(2024, 1, 15, 16, 21, 29, 930080, tzinfo=timezone.utc) + assert datetime_to_stripped_isoformat(dt) == "2024-01-15T16:21:29" + + +def test_strips_positive_offset_without_microseconds(): + dt = datetime(2024, 1, 15, 16, 21, 29, tzinfo=timezone.utc) + assert datetime_to_stripped_isoformat(dt) == "2024-01-15T16:21:29" + + +def test_naive_datetime_without_microseconds_is_a_no_op(): + dt = datetime(2024, 1, 15, 16, 21, 29) + assert datetime_to_stripped_isoformat(dt) == "2024-01-15T16:21:29" + + +def test_negative_offset_without_microseconds_is_not_stripped(): + """ + Documents a quirk, not a fix: the function only ever splits on "." then + falls back to splitting on "+", never "-". A negative-offset datetime + with no microseconds survives with its offset intact. In practice every + production call site runs convert_datetime_to_utc() first, which always + produces a "+00:00" offset, so this path isn't hit for real -- but it's + part of the function's actual contract. + """ + minus_five = timezone(timedelta(hours=-5)) + dt = datetime(2024, 1, 15, 16, 21, 29, tzinfo=minus_five) + assert datetime_to_stripped_isoformat(dt) == "2024-01-15T16:21:29-05:00" + + +def test_negative_offset_with_microseconds_is_stripped(): + """ + Contrast with the previous test: when microseconds ARE present, the + "." split fires first and discards everything after it, offset included. + """ + minus_five = timezone(timedelta(hours=-5)) + dt = datetime(2024, 1, 15, 16, 21, 29, 930080, tzinfo=minus_five) + assert datetime_to_stripped_isoformat(dt) == "2024-01-15T16:21:29" + + +# --- verify_hashed_value() ------------------------------------------------- + + +def test_verify_hashed_value_correct_hash_returns_cleartext(): + secret = "s3cr3t" + cleartext = "42" + hashed = f"{cleartext}.{_hash(secret, cleartext)}" + assert verify_hashed_value(hashed, secret) == cleartext + + +def test_verify_hashed_value_wrong_hash_raises(): + with pytest.raises(Exception): + verify_hashed_value("42.wrong1", "s3cr3t") + + +def test_verify_hashed_value_missing_secret_raises_specific_message(monkeypatch): + monkeypatch.delenv("ACTIONKIT_SECRET_KEY", raising=False) + with pytest.raises(Exception, match="ACTIONKIT_SECRET_KEY must be defined."): + verify_hashed_value("42.abcdef", None) + + +def test_verify_hashed_value_param_takes_precedence_over_env(monkeypatch): + monkeypatch.setenv("ACTIONKIT_SECRET_KEY", "env-secret") + param_secret = "param-secret" + cleartext = "42" + hashed = f"{cleartext}.{_hash(param_secret, cleartext)}" + assert verify_hashed_value(hashed, param_secret) == cleartext + + +def test_verify_hashed_value_falls_back_to_env_var(monkeypatch): + env_secret = "env-secret" + monkeypatch.setenv("ACTIONKIT_SECRET_KEY", env_secret) + cleartext = "42" + hashed = f"{cleartext}.{_hash(env_secret, cleartext)}" + assert verify_hashed_value(hashed, None) == cleartext diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..26ce883 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,46 @@ +import json +from datetime import datetime, timezone + +import pytest + +from actionkit.validation import ValidationError, validate_datetime_is_timezone_aware + + +# --- ValidationError --------------------------------------------------- + + +def test_validation_error_parses_json_body(): + body = json.dumps({"name": ["A page with this short name already exists."]}) + err = ValidationError(body) + assert err.error_dict == {"name": ["A page with this short name already exists."]} + assert err.errors == [["A page with this short name already exists."]] + + +def test_validation_error_non_json_body_raises_json_decode_error(): + with pytest.raises(json.JSONDecodeError): + ValidationError("not json") + + +def test_validation_error_getitem_present_key_returns_its_value(): + err = ValidationError(json.dumps({"order_id": ["already reversed"]})) + assert err["order_id"] == ["already reversed"] + + +def test_validation_error_getitem_missing_key_returns_empty_list_not_keyerror(): + """ + Deliberately permissive contract: __getitem__ never raises KeyError. + """ + err = ValidationError(json.dumps({"order_id": ["already reversed"]})) + assert err["some_other_field"] == [] + + +# --- validate_datetime_is_timezone_aware() -------------------------------- + + +def test_naive_datetime_raises_value_error(): + with pytest.raises(ValueError): + validate_datetime_is_timezone_aware(datetime.now()) + + +def test_aware_datetime_does_not_raise(): + validate_datetime_is_timezone_aware(datetime.now(tz=timezone.utc)) diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 0000000..485d267 --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,2 @@ +def rest(path): + return f"https://example.com/rest/v1/{path}" diff --git a/uv.lock b/uv.lock index bc62d55..99ed104 100644 --- a/uv.lock +++ b/uv.lock @@ -14,6 +14,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "responses" }, ] [package.metadata] @@ -23,7 +24,10 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=7.0.1" }] +dev = [ + { name = "pytest", specifier = ">=7.0.1" }, + { name = "responses", specifier = ">=0.25" }, +] [[package]] name = "certifi" @@ -251,6 +255,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -278,6 +337,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] +[[package]] +name = "responses" +version = "0.26.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/47/f216a33221db8eff328987661cf18371afee89c62a62b434b963d6b509c9/responses-0.26.3.tar.gz", hash = "sha256:b0c11ca8131b8b227b8d5108e6ed39772222bd5aab030ed430e8f99057c4c409", size = 86335, upload-time = "2026-08-26T19:17:24.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/86/ca7958de70cb0752350575e98229368a3a2f746a2942034b3364e17312bb/responses-0.26.3-py3-none-any.whl", hash = "sha256:74474f799334ac4f37d93b6437ecc3bb1bb5c77a8d31780a338643be2dce0af8", size = 36289, upload-time = "2026-08-26T19:17:23.176Z" }, +] + [[package]] name = "urllib3" version = "2.7.0"