diff --git a/requirements.dev.in b/requirements.dev.in index b636c0c9..5c9b1021 100644 --- a/requirements.dev.in +++ b/requirements.dev.in @@ -11,5 +11,4 @@ pytest pytest-cov pytest-env pytest-freezer -# pinning to 1.0.5 pending this issue https://github.com/gabrielfalcao/HTTPretty/issues/425 -httpretty==1.0.5 +mocket diff --git a/requirements.dev.txt b/requirements.dev.txt index 4426ff40..abda84e9 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -92,6 +92,10 @@ coverage[toml]==7.6.1 \ --hash=sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234 \ --hash=sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc # via pytest-cov +decorator==5.1.1 \ + --hash=sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330 \ + --hash=sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186 + # via mocket distlib==0.3.8 \ --hash=sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784 \ --hash=sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64 @@ -104,9 +108,10 @@ freezegun==1.5.1 \ --hash=sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9 \ --hash=sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1 # via pytest-freezer -httpretty==1.0.5 \ - --hash=sha256:e53c927c4d3d781a0761727f1edfad64abef94e828718e12b672a678a8b3e0b5 - # via -r requirements.dev.in +h11==0.14.0 \ + --hash=sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d \ + --hash=sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761 + # via mocket identify==2.6.0 \ --hash=sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf \ --hash=sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0 @@ -115,6 +120,10 @@ iniconfig==2.0.0 \ --hash=sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3 \ --hash=sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 # via pytest +mocket==3.13.2 \ + --hash=sha256:1a6b3658e668c2bc1fe8442df7840b5b77318544f5c157a9952776b17ebff2a2 \ + --hash=sha256:e3b61cb2af2aa696a877b1d2723c0efebbb768e8dc20f076ff2da8d0421742c0 + # via -r requirements.dev.in nodeenv==1.9.1 \ --hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \ --hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9 @@ -142,6 +151,10 @@ pre-commit==4.0.1 \ --hash=sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2 \ --hash=sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878 # via -r requirements.dev.in +puremagic==1.28 \ + --hash=sha256:195893fc129657f611b86b959aab337207d6df7f25372209269ed9e303c1a8c0 \ + --hash=sha256:e16cb9708ee2007142c37931c58f07f7eca956b3472489106a7245e5c3aa1241 + # via mocket pyproject-hooks==1.1.0 \ --hash=sha256:4b37730834edbd6bd37f26ece6b44802fb1c1ee2ece0e54ddff8bfc06db86965 \ --hash=sha256:7ceeefe9aec63a1064c18d939bdc3adf2d8aa1988a510afec15151578b232aa2 @@ -253,6 +266,12 @@ six==1.16.0 \ # via # -c requirements.prod.txt # python-dateutil +urllib3==2.2.2 \ + --hash=sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472 \ + --hash=sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168 + # via + # -c requirements.prod.txt + # mocket virtualenv==20.26.3 \ --hash=sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a \ --hash=sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589 diff --git a/tests/mock_http_request.py b/tests/mock_http_request.py index 744621ca..81a6e0f8 100644 --- a/tests/mock_http_request.py +++ b/tests/mock_http_request.py @@ -1,6 +1,8 @@ import json +from urllib.parse import parse_qs -import httpretty +from mocket import Mocket +from mocket.mockhttp import Entry from .time_helpers import TS @@ -43,7 +45,7 @@ } -def httpretty_register(responses_dict): +def mocket_register(responses_dict): """ A helper to register slack URIs Called with a dict of endpoints and mock response json: @@ -62,14 +64,11 @@ def httpretty_register(responses_dict): a different response each time. """ for endpoint, responses in responses_dict.items(): - httpretty.register_uri( - httpretty.POST, + Entry.register( + Entry.POST, f"https://slack.com/api/{endpoint}", - responses=[ - httpretty.Response(body=json.dumps(response)) for response in responses - ], + *[json.dumps(response) for response in responses], ) - return httpretty def register_bot_uris(): @@ -81,7 +80,7 @@ def register_bot_uris(): Tests that need this method will need to register it themselves in order to ensure it returns the expected user(s). """ - httpretty_register( + mocket_register( { # authenticate "auth.test": [{"ok": True}], @@ -140,7 +139,7 @@ def register_dispatcher_uris(): the output or to report success. If a job fails, it will also call getPermalink to get the message's URL in order to report to tech-support. """ - httpretty_register( + mocket_register( { "chat.postMessage": [{"ok": True, "ts": TS, "channel": "channel"}], "chat.getPermalink": [ @@ -154,7 +153,7 @@ def register_dispatcher_uris(): def get_mock_received_requests(): """ Return a dict of {apipath: body} for each request received by - httpretty. + mocket. Note that the slack_sdk uses params for its api calls for most methods. It uses json for calls that use (or can use) blocks. For our purposes, this if just the chat.postMessage calls. @@ -167,9 +166,12 @@ def get_mock_received_requests(): https://github.com/slackapi/python-slack-sdk/blob/c47ea206491ca3e0be48749169041cf84925acd0/slack_sdk/web/client.py#L2709 """ requests_by_path = {} - for request in httpretty.latest_requests(): - body = request.parsed_body - if request.path == "/api/chat.postMessage": - body = json.loads(body) + for request in Mocket.request_list(): + if request.headers["content-length"] == "0": + body = "" + elif request.path == "/api/chat.postMessage": + body = json.loads(request.body) + else: + body = parse_qs(request.body) requests_by_path.setdefault(request.path, []).append(body) return requests_by_path diff --git a/tests/test_bot.py b/tests/test_bot.py index 44d1f90a..9f65c4dd 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -3,8 +3,8 @@ from datetime import datetime, timedelta from unittest.mock import Mock, patch -import httpretty import pytest +from mocket import Mocketizer, mocketize from slack_bolt import App from slack_bolt.request import BoltRequest from slack_sdk.errors import SlackApiError @@ -21,7 +21,12 @@ assert_suppression_matches, ) from .job_configs import config -from .mock_http_request import USERS, get_mock_received_requests, register_bot_uris +from .mock_http_request import ( + USERS, + get_mock_received_requests, + mocket_register, + register_bot_uris, +) from .time_helpers import T0, TS, T @@ -40,12 +45,10 @@ def get_mock_app(): @pytest.fixture def mock_app(): - httpretty.enable(allow_net_connect=False) register_bot_uris() - mock_app = get_mock_app() - yield mock_app - httpretty.disable() - httpretty.reset() + with Mocketizer(strict_mode=True): + mock_app = get_mock_app() + yield mock_app def test_joined_channels(mock_app): @@ -56,7 +59,7 @@ def test_joined_channels(mock_app): ) -@httpretty.activate(allow_net_connect=False) +@mocketize(strict_mode=True) @pytest.mark.parametrize( "setting,value", [ @@ -81,7 +84,7 @@ def test_register_listeners_support_channel_settings(setting, value): bot.register_listeners(app, config, channels, bot_user_id, internal_user_ids) -@httpretty.activate(allow_net_connect=False) +@mocketize(strict_mode=True) @pytest.mark.parametrize( "setting,value", [ @@ -840,13 +843,7 @@ def test_remove_non_existent_job(mock_app): def test_restricted_jobs_only_scheduled_for_internal_users( mock_app, user, command, reaction_count, scheduled_job_count ): - httpretty.register_uri( - httpretty.POST, - "https://slack.com/api/users.info", - responses=[ - httpretty.Response(body=json.dumps({"ok": True, "user": USERS[user]})) - ], - ) + mocket_register({"users.info": [{"ok": True, "user": USERS[user]}]}) assert not scheduler.get_jobs() handle_message( diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index 30932508..5b0d7a52 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -5,8 +5,9 @@ from pathlib import Path from unittest.mock import Mock, patch -import httpretty import pytest +from mocket import Mocket, Mocketizer +from mocket.mockhttp import Entry from bennettbot import scheduler, settings from bennettbot.dispatcher import JobDispatcher, MessageChecker, run_once @@ -16,7 +17,7 @@ from .job_configs import config from .mock_http_request import ( get_mock_received_requests, - httpretty_register, + mocket_register, register_dispatcher_uris, ) from .time_helpers import T0, TS, T @@ -33,11 +34,9 @@ def remove_logs_dir(): @pytest.fixture(autouse=True) def mock_http(): - httpretty.enable(allow_net_connect=False) register_dispatcher_uris() - yield - httpretty.disable() - httpretty.reset() + with Mocketizer(strict_mode=True): + yield def test_run_once(): @@ -168,7 +167,11 @@ def test_job_success_with_slack_exception(): # Test that the job still succeeds even if notifying slack errors # We mock the MAX_SLACK_NOTIFY_RETRIES so that this test doesn't do the # (time-consuming) retrying in slack.py - httpretty_register( + + # reset Mocket so we can override the chat.postMessage set in the + # autoused mock_http fixture + Mocket.reset() + mocket_register( {"chat.postMessage": [{"ok": False, "error": "error"}]}, ) @@ -476,7 +479,7 @@ def test_job_with_code_format(): def test_job_with_long_code_output_is_uploaded_as_file(): - httpretty_register( + mocket_register( { "files.getUploadURLExternal": [ { @@ -490,8 +493,8 @@ def test_job_with_long_code_output_is_uploaded_as_file(): ], } ) - httpretty.register_uri( - httpretty.POST, + Entry.single_register( + Entry.POST, "https://files.example.com/upload/v1/ABC123", ) @@ -528,7 +531,7 @@ def build_log_dir(job_type_with_namespace): def test_message_checker_run(freezer): freezer.move_to("2024-10-08 23:30") - httpretty_register( + mocket_register( { "search.messages": [ {"ok": True, "messages": {"matches": []}}, @@ -544,7 +547,7 @@ def test_message_checker_run(freezer): # search.messages is called twice for each run of the checker # no matches, so no reactions or messages reposted. - assert len(httpretty.latest_requests()) == 4 + assert len(Mocket.request_list()) == 4 requests_by_path = get_mock_received_requests() last_search_query = requests_by_path["/api/search.messages"][-1]["query"][0] assert "after:2024-10-06" in last_search_query @@ -558,7 +561,7 @@ def test_message_checker_run(freezer): ), ) def test_message_checker_matched_messages(keyword, support_channel, reaction): - httpretty_register( + mocket_register( { "search.messages": [ { @@ -599,7 +602,7 @@ def test_message_checker_matched_messages(keyword, support_channel, reaction): # search.messages is called once # other 3 endpoints called once each for 2 matched messages requiring # reaction and reposting. - assert len(httpretty.latest_requests()) == 7 + assert len(Mocket.request_list()) == 7 requests_by_path = get_mock_received_requests() assert requests_by_path["/api/search.messages"] == [ diff --git a/tests/test_notify_slack.py b/tests/test_notify_slack.py index a011b698..1e08bc39 100644 --- a/tests/test_notify_slack.py +++ b/tests/test_notify_slack.py @@ -1,18 +1,18 @@ from unittest.mock import patch -import httpretty import pytest +from mocket import mocketize from bennettbot import settings from bennettbot.slack import notify_slack, slack_web_client from workspace.utils.blocks import get_text_block -from .mock_http_request import get_mock_received_requests, httpretty_register +from .mock_http_request import get_mock_received_requests, mocket_register -@httpretty.activate(allow_net_connect=False) +@mocketize(strict_mode=True) def test_notify_slack_success(): - httpretty_register( + mocket_register( {"chat.postMessage": [{"ok": True, "ts": 123.45, "channel": "test-channel"}]} ) @@ -26,9 +26,9 @@ def test_notify_slack_success(): } -@httpretty.activate(allow_net_connect=False) +@mocketize(strict_mode=True) def test_notify_slack_success_blocks(): - httpretty_register( + mocket_register( {"chat.postMessage": [{"ok": True, "ts": 123.45, "channel": "test-channel"}]} ) block_message = [get_text_block(text="my message")] @@ -49,13 +49,13 @@ def test_notify_slack_success_blocks(): } -@httpretty.activate(allow_net_connect=False) +@mocketize(strict_mode=True) @patch("bennettbot.dispatcher.settings.MAX_SLACK_NOTIFY_RETRIES", 1) def test_notify_slack_retries(): # Mock 2 responses from the postMessage endpoint # We allow 1 retry to notify slack with the original message # The first attempt is an error, the second succeeds - httpretty_register( + mocket_register( {"chat.postMessage": [{"ok": False, "error": "error"}, {"ok": True}]} ) @@ -73,14 +73,14 @@ def test_notify_slack_retries(): } -@httpretty.activate(allow_net_connect=False) +@mocketize(strict_mode=True) @patch("bennettbot.dispatcher.settings.MAX_SLACK_NOTIFY_RETRIES", 0) def test_notify_slack_retries_fallback(): # Mock 2 responses from the postMessage endpoint # We only allow 1 atttempt to notify slack with the original message # The first attempt is an error, so we fall back to notifying about the failure # The fallback (second mocked response) succeeds - httpretty_register( + mocket_register( {"chat.postMessage": [{"ok": False, "error": "error"}, {"ok": True}]} ) @@ -100,14 +100,14 @@ def test_notify_slack_retries_fallback(): assert latest_requests[1]["text"] == "Could not notify slack" -@httpretty.activate(allow_net_connect=False) +@mocketize(strict_mode=True) @patch("bennettbot.dispatcher.settings.MAX_SLACK_NOTIFY_RETRIES", 1) def test_notify_slack_retries_fallback_error(): # Make the postMessage endpoint always error # We only allow 2 atttempt to notify slack with the original message # Both error, so we fall back to notifying about the failure # The fallback (second mocked response) also errors, so we give up and just log - httpretty_register({"chat.postMessage": [{"ok": False, "error": "error"}]}) + mocket_register({"chat.postMessage": [{"ok": False, "error": "error"}]}) notify_slack( slack_web_client(), "test-channel", "my message", 234.56, retry_delay=0.1 @@ -115,7 +115,6 @@ def test_notify_slack_retries_fallback_error(): latest_requests = get_mock_received_requests()["/api/chat.postMessage"] assert len(latest_requests) == 3 - assert len(httpretty.latest_requests()) == 3 for request_body in latest_requests[:2]: # first attempted calls with original message assert request_body == { diff --git a/tests/webserver/test_github.py b/tests/webserver/test_github.py index 69feebda..6816a908 100644 --- a/tests/webserver/test_github.py +++ b/tests/webserver/test_github.py @@ -1,13 +1,13 @@ from unittest.mock import patch -import httpretty import pytest +from mocket import Mocket, mocketize from bennettbot import scheduler from bennettbot.job_configs import build_config from ..assertions import assert_job_matches, assert_slack_client_sends_messages -from ..mock_http_request import httpretty_register +from ..mock_http_request import mocket_register from ..time_helpers import T0, T @@ -58,9 +58,9 @@ def test_valid_auth_header(web_client): assert rsp.status_code == 200 -@httpretty.activate(allow_net_connect=False) +@mocketize(strict_mode=True) def test_on_closed_merged_pr(web_client): - httpretty_register({"chat.postMessage": {"ok": True}}) + mocket_register({"chat.postMessage": {"ok": True}}) headers = {"X-Hub-Signature": "sha1=3e09e676b4a62b634401b44b4c4ff1f58404e746"} with patch("bennettbot.webserver.github.config", new=dummy_config): @@ -74,12 +74,14 @@ def test_on_closed_merged_pr(web_client): assert_slack_client_sends_messages(messages_kwargs=[]) -@httpretty.activate(allow_net_connect=False) +@mocketize(strict_mode=True) def test_on_closed_merged_pr_with_suppression(web_client): - httpretty_register({"chat.postMessage": [{"ok": True}]}) + mocket_register({"chat.postMessage": [{"ok": True}]}) scheduler.schedule_suppression("test_deploy", T(-60), T(60)) - headers = {"X-Hub-Signature": "sha1=3e09e676b4a62b634401b44b4c4ff1f58404e746"} + headers = { + "X-Hub-Signature": "sha1=3e09e676b4a62b634401b44b4c4ff1f58404e746", + } with patch("bennettbot.webserver.github.config", new=dummy_config): rsp = web_client.post("/github/test/", data=PAYLOAD_PR_CLOSED, headers=headers) @@ -90,7 +92,7 @@ def test_on_closed_merged_pr_with_suppression(web_client): assert_job_matches(jj[0], "test_deploy", {}, "#some-team", T(60), None) # message sent for suppression - assert len(httpretty.latest_requests()) == 1 + assert len(Mocket.request_list()) == 1 assert_slack_client_sends_messages( messages_kwargs=[{"text": f"suppressed until {T(60)}", "channel": "#some-team"}] ) diff --git a/tests/workspace/test_workflows.py b/tests/workspace/test_workflows.py index 4ef6f3aa..0353302c 100644 --- a/tests/workspace/test_workflows.py +++ b/tests/workspace/test_workflows.py @@ -3,8 +3,9 @@ from pathlib import Path from unittest.mock import patch -import httpretty import pytest +from mocket import Mocket, Mocketizer, mocketize +from mocket.mockhttp import Entry from workspace.workflows import jobs @@ -36,26 +37,25 @@ def cache_path(tmp_path): @pytest.fixture def mock_airlock_reporter(): - httpretty.enable(allow_net_connect=False) # Workflow IDs and names - httpretty.register_uri( - httpretty.GET, - uri="https://api.github.com/repos/opensafely-core/airlock/actions/workflows?format=json", - match_querystring=True, + Entry.single_register( + Entry.GET, + "https://api.github.com/repos/opensafely-core/airlock/actions/workflows?format=json", body=Path("tests/workspace/workflows.json").read_text(), + match_querystring=True, ) + # Workflow runs - httpretty.register_uri( - httpretty.GET, + Entry.single_register( + Entry.GET, "https://api.github.com/repos/opensafely-core/airlock/actions/runs?per_page=100&format=json", body=Path("tests/workspace/runs.json").read_text(), match_querystring=False, # Test the querystring separately ) - reporter = jobs.RepoWorkflowReporter("opensafely-core/airlock") - reporter.cache = {} # Drop the cache and test _load_cache_for_repo separately - yield reporter - httpretty.disable() - httpretty.reset() + with Mocketizer(strict_mode=True): + reporter = jobs.RepoWorkflowReporter("opensafely-core/airlock") + reporter.cache = {} # Drop the cache and test _load_cache_for_repo separately + yield reporter class MockRepoWorkflowReporter(jobs.RepoWorkflowReporter): @@ -249,12 +249,12 @@ def test_catch_unhandled_error(): ] -@httpretty.activate(allow_net_connect=False) +@mocketize(strict_mode=True) def test_get_workflows(): # get_workflows is called in __init__, so create the instance here - httpretty.register_uri( - httpretty.GET, - uri="https://api.github.com/repos/opensafely-core/airlock/actions/workflows?format=json", + Entry.single_register( + Entry.GET, + "https://api.github.com/repos/opensafely-core/airlock/actions/workflows?format=json", match_querystring=True, body=Path("tests/workspace/workflows.json").read_text(), ) @@ -288,7 +288,7 @@ def test_get_runs_since_last_retrieval(mock_airlock_reporter, cache_path): assert mock_airlock_reporter.cache == CACHE["opensafely-core/airlock"] mock_airlock_reporter.get_runs(since_last_retrieval=True) - assert httpretty.last_request().querystring == { + assert Mocket.last_request().querystring == { "branch": ["main"], "per_page": ["100"], "format": ["json"], @@ -300,7 +300,7 @@ def test_all_workflows_found(mock_airlock_reporter, cache_path): with patch("workspace.workflows.jobs.CACHE_PATH", cache_path): conclusions = mock_airlock_reporter.get_latest_conclusions() assert conclusions == {key: "success" for key in WORKFLOWS_MAIN.keys()} - assert "created" not in httpretty.last_request().querystring + assert "created" not in Mocket.last_request().querystring def test_some_workflows_not_found(mock_airlock_reporter, cache_path): @@ -342,7 +342,7 @@ def test_get_runs_beyond_last_retrieval_if_not_all_successful( 1234: conclusion, } - querystring = httpretty.last_request().querystring + querystring = Mocket.last_request().querystring assert ("created" in querystring) == created_in_querystring @@ -396,7 +396,7 @@ def test_get_summary_block(conclusion, emoji): } -@httpretty.activate(allow_net_connect=False) +@mocketize(strict_mode=True) @pytest.mark.parametrize( "conclusion, reported, emoji", [ @@ -410,9 +410,9 @@ def test_get_summary_block(conclusion, emoji): def test_main_show_repo(mock_conclusions, conclusion, reported, emoji): # Call main for a single repo (opensafely-core/airlock) # No need to mock CACHE_PATH since get_latest_conclusions is mocked - httpretty.register_uri( - httpretty.GET, - uri="https://api.github.com/repos/opensafely-core/airlock/actions/workflows?format=json", + Entry.single_register( + Entry.GET, + "https://api.github.com/repos/opensafely-core/airlock/actions/workflows?format=json", match_querystring=True, body=Path("tests/workspace/workflows.json").read_text(), )