diff options
| author | Dennis Fink | 2026-08-21 10:37:56 +0200 |
|---|---|---|
| committer | Dennis Fink | 2026-08-21 10:37:56 +0200 |
| commit | c5492398f100ccb154fa557d657bae2322f95cf7 (patch) | |
| tree | 21efa67e00c26ab0733a931e6a5810b4c933388b /tests/tasks | |
| parent | 5bfcc3c06ebe6d6694221c25f07a4901572f97f5 (diff) | |
| download | webmentions-ssg-c5492398f100ccb154fa557d657bae2322f95cf7.tar.gz webmentions-ssg-c5492398f100ccb154fa557d657bae2322f95cf7.zip | |
test(core): align tests with module ownership
Move direct validator, model, and Huey extension coverage into dedicated
test modules and mock dependencies where behavior belongs to another
module.
Expand Huey extension typing and coverage while removing duplicate
assertions from scanner, sender, form, and view tests.
Diffstat (limited to '')
| -rw-r--r-- | tests/tasks/test_extension.py | 175 | ||||
| -rw-r--r-- | tests/tasks/test_receiver.py | 5 | ||||
| -rw-r--r-- | tests/tasks/test_scanner.py | 97 | ||||
| -rw-r--r-- | tests/tasks/test_sender.py | 261 |
4 files changed, 322 insertions, 216 deletions
diff --git a/tests/tasks/test_extension.py b/tests/tasks/test_extension.py new file mode 100644 index 0000000..5e9cdfa --- /dev/null +++ b/tests/tasks/test_extension.py @@ -0,0 +1,175 @@ +# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> +# +# SPDX-License-Identifier: BSD-3-Clause + +from collections.abc import Callable +from typing import Any + +import huey as huey_package +import pytest +from flask import Flask, has_app_context +from huey import crontab + +from webmentions_ssg.tasks.extension import Huey + + +def test_init_without_app() -> None: + huey = Huey() + assert huey.app is None + with pytest.raises(RuntimeError, match="Huey has not been initialized"): + _ = huey.huey + + +def test_init_with_app(app: Flask) -> None: + huey = Huey(app) + assert huey.app is app + assert huey.huey.name == app.import_name + assert huey.huey.results is True + assert huey.huey.store_none is False + assert huey.huey.utc is True + assert huey.huey.immediate is True + assert app.extensions["huey"] is huey + + +def test_init_app_uses_huey_configuration(app: Flask) -> None: + app.config["HUEY"] = { + "url": "memory://", + "name": "custom-name", + "results": False, + "store_none": True, + "utc": False, + "immediate": False, + } + + huey = Huey() + huey.init_app(app) + + assert huey.huey.name == "custom-name" + assert huey.huey.results is False + assert huey.huey.store_none is True + assert huey.huey.utc is False + assert huey.huey.immediate is False + + +def test_huey_url_takes_precedence_over_huey_configuration(app: Flask) -> None: + app.config["HUEY"] = {"url": "blackhole://"} + app.config["HUEY_URL"] = "memory://" + + huey = Huey(app) + + assert type(huey.huey).__name__ == "MemoryHuey" + + +def task_decorator(huey: Huey, periodic: bool) -> Callable[[Callable[..., Any]], Any]: + if periodic: + return huey.periodic_task(crontab(minute="*")) + + return huey.task() + + +@pytest.mark.parametrize("periodic", [False, True], ids=["task", "periodic-task"]) +def test_task_runs_in_app_context(app: Flask, periodic: bool) -> None: + huey = Huey(app) + + @task_decorator(huey, periodic) + def add(a: int, b: int) -> int: + assert has_app_context() + return a + b + + assert add.call_local(2, 3) == 5 + + +@pytest.mark.parametrize("periodic", [False, True], ids=["task", "periodic-task"]) +def test_task_raises_without_app(app: Flask, periodic: bool) -> None: + huey = Huey(app) + + @task_decorator(huey, periodic) + def task() -> None: + pass + + huey.app = None + + with pytest.raises(RuntimeError, match="Flask app is not available"): + task.call_local() + + +def test_getattr_delegates_to_huey(app: Flask) -> None: + huey = Huey(app) + assert huey.immediate is True + + +@pytest.mark.parametrize( + ("url", "backend_name", "storage_kwargs"), + [ + ("redis://localhost:6379/0", "RedisHuey", {"url": "redis://localhost:6379/0"}), + ( + "rediss://localhost:6379/0", + "RedisHuey", + {"url": "rediss://localhost:6379/0"}, + ), + ( + "redis+priority://localhost:6379/0", + "PriorityRedisHuey", + {"url": "redis://localhost:6379/0"}, + ), + ( + "redis+expire://localhost:6379/0", + "RedisExpireHuey", + {"url": "redis://localhost:6379/0"}, + ), + ( + "redis+priority+expire://localhost:6379/0", + "PriorityRedisExpireHuey", + {"url": "redis://localhost:6379/0"}, + ), + ("sqlite:///var/huey.db", "SqliteHuey", {"filename": "var/huey.db"}), + ("file:///var/huey-queue", "FileHuey", {"path": "var/huey-queue"}), + ( + "postgres://user:password@localhost/database", + "PostgresHuey", + {"dsn": "postgres://user:password@localhost/database"}, + ), + ( + "postgresql://user:password@localhost/database", + "PostgresHuey", + {"dsn": "postgresql://user:password@localhost/database"}, + ), + ("memory://", "MemoryHuey", {}), + ("blackhole://", "BlackHoleHuey", {}), + ], +) +def test_backend_from_url( + monkeypatch: pytest.MonkeyPatch, + url: str, + backend_name: str, + storage_kwargs: dict[str, str], +) -> None: + backend_class = type(f"Test{backend_name}", (), {}) + + monkeypatch.setattr(huey_package, backend_name, backend_class) + + actual_class, actual_storage_kwargs = Huey.backend_from_url(url) + + assert actual_class is backend_class + assert actual_storage_kwargs == storage_kwargs + + +@pytest.mark.parametrize( + ("url", "message"), + [ + ( + "sqlite://var/huey.db", + "SQLite Huey URLs must look like sqlite:///var/huey.db", + ), + ("sqlite:///", "SQLite Huey URL must include a database path"), + ( + "file://var/huey-queue", + "File Huey URLs must look like file:///var/huey-queue", + ), + ("file:///", "File Huey URL must include a directory path"), + ("amqp://localhost", "Unsupported HUEY_URL scheme: 'amqp'"), + ], +) +def test_backend_from_url_rejects_invalid_url(url: str, message: str) -> None: + with pytest.raises(RuntimeError, match=message): + Huey.backend_from_url(url) diff --git a/tests/tasks/test_receiver.py b/tests/tasks/test_receiver.py index 17dd45a..daa271d 100644 --- a/tests/tasks/test_receiver.py +++ b/tests/tasks/test_receiver.py @@ -54,9 +54,7 @@ def create_webmention( def get_webmention_state(app: Flask, identifier: uuid.UUID) -> tuple[str, str | None]: with app.app_context(): webmention = db.session.get(ReceivedWebmention, identifier) - assert webmention is not None - return (webmention.status, webmention.failure_reason) @@ -433,9 +431,7 @@ def test_verify_webmention_marks_row_verifying_before_check( return True source_mentions_target.side_effect = verify_source - receiver.verify_webmention.call_local(identifier) - assert get_webmention_state(app, identifier) == ("verified", None) @@ -521,7 +517,6 @@ def test_ensure_public_request_accepts_public_url( is_public_url: Mock, receiver: ModuleType ) -> None: receiver.ensure_public_request(httpx.Request("GET", SOURCE_URL)) - is_public_url.assert_called_once_with(SOURCE_URL) diff --git a/tests/tasks/test_scanner.py b/tests/tasks/test_scanner.py index 332b8bf..3de5e16 100644 --- a/tests/tasks/test_scanner.py +++ b/tests/tasks/test_scanner.py @@ -4,6 +4,7 @@ from pathlib import Path from types import ModuleType +from unittest.mock import Mock from uuid import UUID import pytest @@ -20,13 +21,11 @@ SOURCE_URL = f"{BASE_URL}example/" @pytest.fixture -def scanner_module(app: Flask) -> ModuleType: - # Importing scanner registers Huey tasks. The dependency on the - # app fixture guarantees that Huey has been initialized first. +def scanner_module(app: Flask, monkeypatch: pytest.MonkeyPatch) -> ModuleType: _ = app - from webmentions_ssg.tasks import scanner + monkeypatch.setattr(scanner, "is_http_url", Mock(return_value=True)) return scanner @@ -96,11 +95,6 @@ def configure_scanner(app: Flask, root: Path, *, base_url: str | None = None) -> app.config["WEBMENTIONS_SSG_SOURCE_BASE_URL"] = base_url -# --------------------------------------------------------------------------- -# Microformats parsing -# --------------------------------------------------------------------------- - - def test_parse_entry_returns_microformats_entry(scanner_module: ModuleType) -> None: document = parse_html( f""" @@ -277,11 +271,6 @@ def test_primary_entry_rejects_nonmatching_u_url(scanner_module: ModuleType) -> scanner_module.primary_entry(document, SOURCE_URL) -# --------------------------------------------------------------------------- -# e-content -# --------------------------------------------------------------------------- - - def test_content_element_returns_e_content(scanner_module: ModuleType) -> None: document = parse_html( """ @@ -346,11 +335,6 @@ def test_content_element_rejects_multiple_e_content(scanner_module: ModuleType) scanner_module.content_element(entry) -# --------------------------------------------------------------------------- -# Canonical URLs -# --------------------------------------------------------------------------- - - def test_canonical_url_returns_absolute_url(scanner_module: ModuleType) -> None: document = parse_html( f""" @@ -393,6 +377,8 @@ def test_canonical_url_returns_none_when_missing(scanner_module: ModuleType) -> def test_canonical_url_resolves_relative_url_with_base_url( scanner_module: ModuleType, ) -> None: + scanner_module.is_http_url.side_effect = [False, True] + document = parse_html( """ <html> @@ -414,6 +400,8 @@ def test_canonical_url_resolves_relative_url_with_base_url( def test_relative_canonical_requires_base_url(scanner_module: ModuleType) -> None: + scanner_module.is_http_url.return_value = False + document = parse_html( """ <html> @@ -453,11 +441,6 @@ def test_empty_canonical_is_rejected(scanner_module: ModuleType) -> None: ) -# --------------------------------------------------------------------------- -# Target extraction -# --------------------------------------------------------------------------- - - def test_scan_source_uses_canonical_without_base_url( scanner_module: ModuleType, tmp_path: Path ) -> None: @@ -657,24 +640,6 @@ def test_scan_source_extracts_reaction_properties( assert scanned.targets == frozenset({target}) -def test_scan_source_extracts_anchor_from_e_content( - scanner_module: ModuleType, tmp_path: Path -) -> None: - path = write_post( - tmp_path, - "example", - """ - <a href="https://example.com/target"> - Target - </a> - """, - ) - - scanned = scanner_module.scan_source_file(path, root=tmp_path, base_url=None) - - assert scanned.targets == frozenset({"https://example.com/target"}) - - @pytest.mark.parametrize("tag", ("link", "area")) def test_scan_source_ignores_non_anchor_href_elements( scanner_module: ModuleType, tmp_path: Path, tag: str @@ -714,11 +679,6 @@ def test_scan_source_ignores_self_fragment( assert scanned.targets == frozenset({"https://example.com/target"}) -# --------------------------------------------------------------------------- -# Database reconciliation -# --------------------------------------------------------------------------- - - def test_scan_creates_source_and_webmentions( app: Flask, scanner_module: ModuleType, tmp_path: Path, monkeypatch: MonkeyPatch ) -> None: @@ -900,7 +860,6 @@ def test_updated_source_increments_revision( assert webmention.desired_revision == 2 assert webmention.processed_revision == 1 assert webmention.sent_revision == 1 - assert webmention.pending identifier = webmention.uuid @@ -972,7 +931,6 @@ def test_new_target_is_added_on_update( assert webmention.desired_revision == 2 assert webmention.processed_revision is None assert webmention.sent_revision is None - assert webmention.pending identifier = webmention.uuid @@ -1055,7 +1013,6 @@ def test_removed_sent_target_is_queued_again( assert webmention.desired_revision == 2 assert webmention.processed_revision == 1 assert webmention.sent_revision == 1 - assert webmention.pending assert queued == [identifier] @@ -1124,16 +1081,10 @@ def test_removed_unsent_target_is_not_queued( assert webmention.desired_revision == 2 assert webmention.processed_revision == 2 assert webmention.sent_revision is None - assert not webmention.pending assert queued == [] -# --------------------------------------------------------------------------- -# Source deletion/restoration -# --------------------------------------------------------------------------- - - def test_deleted_source_queues_previously_sent_webmention( app: Flask, scanner_module: ModuleType, tmp_path: Path, monkeypatch: MonkeyPatch ) -> None: @@ -1186,7 +1137,6 @@ def test_deleted_source_queues_previously_sent_webmention( assert webmention.desired_revision == 2 assert webmention.processed_revision == 1 assert webmention.sent_revision == 1 - assert webmention.pending assert queued == [identifier] @@ -1231,7 +1181,6 @@ def test_deleted_source_does_not_queue_unsent_webmention( assert webmention.desired_revision == 2 assert webmention.processed_revision == 2 assert webmention.sent_revision is None - assert not webmention.pending assert queued == [] @@ -1298,18 +1247,12 @@ def test_restored_source_creates_new_revision( assert webmention.active assert webmention.desired_revision == 3 assert webmention.sent_revision == 1 - assert webmention.pending identifier = webmention.uuid assert queued == [identifier] -# --------------------------------------------------------------------------- -# Queue recovery and invalid sources -# --------------------------------------------------------------------------- - - def test_pending_webmention_is_requeued_on_next_scan( app: Flask, scanner_module: ModuleType, tmp_path: Path, monkeypatch: MonkeyPatch ) -> None: @@ -1436,6 +1379,8 @@ def test_scan_sources_accepts_valid_base_url( def test_scan_sources_rejects_invalid_base_url( app: Flask, scanner_module: ModuleType, tmp_path: Path ) -> None: + scanner_module.is_http_url.return_value = False + configure_scanner(app, tmp_path, base_url="not-a-url") with pytest.raises(RuntimeError, match="absolute HTTP or HTTPS URL"): @@ -1565,6 +1510,8 @@ def test_scan_source_ignores_reaction_to_matching_hostname( def test_canonical_url_rejects_non_http_resolved_url( scanner_module: ModuleType, ) -> None: + scanner_module.is_http_url.return_value = False + document = parse_html( """ <html> @@ -1634,19 +1581,21 @@ def test_parse_entry_rejects_invalid_parser_output( scanner_module.parse_entry(element, SOURCE_URL) -@pytest.mark.parametrize( - "value", - [ - pytest.param(" ", id="empty"), - pytest.param("mailto:example@example.com", id="non-http"), - ], -) -def test_normalize_target_ignores_invalid_target( - scanner_module: ModuleType, value: str +def test_normalize_target_ignores_empty_target(scanner_module: ModuleType) -> None: + assert ( + scanner_module.normalize_target(" ", base_url=SOURCE_URL, source_url=SOURCE_URL) + is None + ) + + +def test_normalize_target_ignores_url_rejected_by_validator( + scanner_module: ModuleType, ) -> None: + scanner_module.is_http_url.return_value = False + assert ( scanner_module.normalize_target( - value, base_url=SOURCE_URL, source_url=SOURCE_URL + "https://example.com/target", base_url=SOURCE_URL, source_url=SOURCE_URL ) is None ) diff --git a/tests/tasks/test_sender.py b/tests/tasks/test_sender.py index dac56a5..6e33887 100644 --- a/tests/tasks/test_sender.py +++ b/tests/tasks/test_sender.py @@ -90,16 +90,60 @@ def mock_sender_requests( @pytest.fixture def sender_module(app: Flask) -> ModuleType: - # Importing sender registers Huey tasks. The dependency on the - # app fixture guarantees that Huey has been initialized first. _ = app - from webmentions_ssg.tasks import sender return sender @pytest.mark.parametrize( + ("status", "expected"), + [ + pytest.param(200, False, id="success"), + pytest.param(400, False, id="client-error"), + pytest.param(408, True, id="request-timeout"), + pytest.param(425, True, id="too-early"), + pytest.param(429, True, id="too-many-requests"), + pytest.param(500, True, id="server-error-lower-bound"), + pytest.param(599, True, id="server-error-upper-bound"), + pytest.param(600, False, id="outside-server-error-range"), + ], +) +def test_temporary_http_status( + sender_module: ModuleType, status: int, expected: bool +) -> None: + assert sender_module.temporary_http_status(status) is expected + + +@pytest.mark.parametrize( + ("desired_revision", "processed_revision", "revision", "expected"), + [ + pytest.param(2, None, 2, True, id="unprocessed-current"), + pytest.param(2, 1, 2, True, id="older-processed-current"), + pytest.param(2, 2, 2, False, id="already-processed"), + pytest.param(2, 3, 2, False, id="newer-processed"), + pytest.param(3, 1, 2, False, id="superseded"), + ], +) +def test_attempt_is_current( + app: Flask, + sender_module: ModuleType, + desired_revision: int, + processed_revision: int | None, + revision: int, + expected: bool, +) -> None: + identifier = create_sent_webmention( + app, desired_revision=desired_revision, processed_revision=processed_revision + ) + + with app.app_context(): + webmention = db.session.get(SentWebmention, identifier) + assert webmention is not None + assert sender_module.attempt_is_current(webmention, revision) is expected + + +@pytest.mark.parametrize( ("value", "expected"), ( ( @@ -119,8 +163,10 @@ def sender_module(app: Flask) -> ModuleType: ("https://example.com/webmention", {"webmention", "alternate"}), ), ( - "<https://example.com/webmention>; " - 'type="text/html"; rel="webmention"; title="Endpoint"', + ( + '<https://example.com/webmention>; type="text/html"; ' + 'rel="webmention"; title="Endpoint"' + ), ("https://example.com/webmention", {"webmention"}), ), ( @@ -159,7 +205,10 @@ def test_parse_link_value_rejects_malformed_value( ( ( "Link", - "<https://example.com/test/2/webmention?head=true>; rel=webmention", + ( + "<https://example.com/test/2/webmention?head=true>;" + "rel=webmention" + ), ), ), "", @@ -223,7 +272,10 @@ def test_parse_link_value_rejects_malformed_value( ( ( "LinK", - "<https://example.com/test/7/webmention?head=true>; rel=webmention", + ( + "<https://example.com/test/7/webmention?head=true>; " + "rel=webmention" + ), ), ), "", @@ -235,7 +287,10 @@ def test_parse_link_value_rejects_malformed_value( ( ( "Link", - '<https://example.com/test/8/webmention?head=true>; rel="webmention"', + ( + "<https://example.com/test/8/webmention?head=true>; " + 'rel="webmention"' + ), ), ), "", @@ -259,8 +314,10 @@ def test_parse_link_value_rejects_malformed_value( ( ( "Link", - "<https://example.com/test/10/webmention?head=true>; " - 'rel="webmention somethingelse"', + ( + "<https://example.com/test/10/webmention?head=true>; " + 'rel="webmention somethingelse"' + ), ), ), "", @@ -407,8 +464,10 @@ def test_parse_link_value_rejects_malformed_value( ("Link", '<https://example.com/test/18/webmention/error>; rel="other"'), ( "Link", - "<https://example.com/test/18/webmention?head=true>; " - 'rel="webmention"', + ( + "<https://example.com/test/18/webmention?head=true>; " + 'rel="webmention"' + ), ), ), "", @@ -420,10 +479,12 @@ def test_parse_link_value_rejects_malformed_value( ( ( "Link", - "<https://example.com/test/19/webmention/error>; " - 'rel="other", ' - "<https://example.com/test/19/webmention?head=true>; " - 'rel="webmention"', + ( + "<https://example.com/test/19/webmention/error>; " + 'rel="other", ' + "<https://example.com/test/19/webmention?head=true>; " + 'rel="webmention"' + ), ), ), "", @@ -625,7 +686,6 @@ def test_send_webmention_persists_success( assert webmention.status_url == STATUS_URL assert webmention.last_attempted_at is not None assert webmention.last_sent_at is not None - assert not webmention.pending def test_send_webmention_marks_unsupported_target_processed( @@ -663,7 +723,6 @@ def test_send_webmention_marks_unsupported_target_processed( assert webmention.response_status is None assert webmention.status_url is None assert webmention.last_attempted_at is not None - assert not webmention.pending def test_send_webmention_persists_permanent_failure( @@ -693,7 +752,6 @@ def test_send_webmention_persists_permanent_failure( assert webmention.endpoint == ENDPOINT_URL assert webmention.response_status == 400 assert webmention.status_url is None - assert not webmention.pending def test_send_webmention_persists_temporary_failure_and_reraises( @@ -727,7 +785,6 @@ def test_send_webmention_persists_temporary_failure_and_reraises( assert webmention.endpoint == ENDPOINT_URL assert webmention.response_status == 503 assert webmention.status_url is None - assert webmention.pending def test_send_webmention_ignores_processed_revision( @@ -750,14 +807,17 @@ def test_send_webmention_ignores_processed_revision( post.assert_not_called() -def test_send_webmention_ignores_result_for_superseded_revision( - app: Flask, sender_module: ModuleType, monkeypatch: pytest.MonkeyPatch +@pytest.mark.parametrize( + "outcome", ["success", "unsupported", "temporary-failure", "permanent-failure"] +) +def test_send_webmention_ignores_superseded_attempt( + app: Flask, sender_module: ModuleType, monkeypatch: pytest.MonkeyPatch, outcome: str ) -> None: identifier = create_sent_webmention(app) - _, _, _, post = mock_sender_requests(sender_module, monkeypatch) + _, _, discover, post = mock_sender_requests(sender_module, monkeypatch) - def supersede_revision(*args, **kwargs) -> tuple[int, None]: + def supersede_revision() -> None: webmention = db.session.get(SentWebmention, identifier) assert webmention is not None @@ -765,9 +825,40 @@ def test_send_webmention_ignores_result_for_superseded_revision( webmention.desired_revision = 2 db.session.commit() - return (202, None) + match outcome: + case "success": + + def post_success(*args, **kwargs) -> tuple[int, None]: + supersede_revision() + return (202, None) + + post.side_effect = post_success + + case "unsupported": + + def discover_unsupported(*args, **kwargs) -> None: + supersede_revision() + + discover.side_effect = discover_unsupported - post.side_effect = supersede_revision + case "temporary-failure": + + def post_temporary_failure(*args, **kwargs) -> None: + supersede_revision() + raise sender_module.TemporarySenderError("Temporary failure") + + post.side_effect = post_temporary_failure + + case "permanent-failure": + + def post_permanent_failure(*args, **kwargs) -> tuple[int, None]: + supersede_revision() + return (400, None) + + post.side_effect = post_permanent_failure + + case _: + raise AssertionError(f"Unexpected outcome: {outcome}") sender_module.send_webmention.call_local(identifier) @@ -779,17 +870,14 @@ def test_send_webmention_ignores_result_for_superseded_revision( assert webmention.processed_revision is None assert webmention.sent_revision is None assert webmention.status is None - assert webmention.pending def test_send_webmention_ignores_unknown_identifier( sender_module: ModuleType, caplog: pytest.LogCaptureFixture ) -> None: identifier = uuid.uuid7() - with caplog.at_level(logging.WARNING): sender_module.send_webmention.call_local(identifier) - assert f"Cannot send unknown SentWebmention {identifier}" in caplog.text @@ -798,9 +886,7 @@ def test_ensure_public_request_accepts_public_url( ) -> None: is_public_url = Mock(return_value=True) monkeypatch.setattr(sender_module, "is_public_url", is_public_url) - sender_module.ensure_public_request(httpx.Request("GET", TARGET_URL)) - is_public_url.assert_called_once_with(TARGET_URL) @@ -809,13 +895,11 @@ def test_ensure_public_request_rejects_non_public_address( ) -> None: is_public_url = Mock(return_value=False) monkeypatch.setattr(sender_module, "is_public_url", is_public_url) - with pytest.raises( sender_module.PermanentSenderError, match="Request resolves to a non-public address", ): sender_module.ensure_public_request(httpx.Request("GET", TARGET_URL)) - is_public_url.assert_called_once_with(TARGET_URL) @@ -826,23 +910,25 @@ def test_ensure_public_request_maps_dns_failure_to_temporary_error( side_effect=AddressResolutionError("Could not resolve hostname") ) monkeypatch.setattr(sender_module, "is_public_url", is_public_url) - with pytest.raises( sender_module.TemporarySenderError, match="Request hostname could not be resolved", ): sender_module.ensure_public_request(httpx.Request("GET", TARGET_URL)) - is_public_url.assert_called_once_with(TARGET_URL) -def test_resolve_endpoint_rejects_non_http_url(sender_module: ModuleType) -> None: +def test_resolve_endpoint_rejects_invalid_url( + sender_module: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + is_http_url = Mock(return_value=False) + monkeypatch.setattr(sender_module, "is_http_url", is_http_url) response = httpx.Response(200, request=httpx.Request("GET", TARGET_URL)) - with pytest.raises( sender_module.PermanentSenderError, match="Invalid Webmention endpoint" ): - sender_module.resolve_endpoint(response, "mailto:example@example.com") + sender_module.resolve_endpoint(response, "/webmention") + is_http_url.assert_called_once_with(ENDPOINT_URL) def test_endpoint_from_headers_ignores_malformed_link( @@ -853,7 +939,6 @@ def test_endpoint_from_headers_ignores_malformed_link( headers=[("Link", "not-a-link"), ("Link", "</webmention>; rel=webmention")], request=httpx.Request("GET", TARGET_URL), ) - assert sender_module.endpoint_from_headers(response) == ENDPOINT_URL @@ -1065,101 +1150,3 @@ def test_post_webmention( assert form["source"] == SOURCE_URL assert form["target"] == TARGET_URL - - -def test_send_webmention_ignores_unsupported_result_for_superseded_revision( - app: Flask, sender_module: ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - identifier = create_sent_webmention(app) - - _, _, discover, post = mock_sender_requests( - sender_module, monkeypatch, endpoint=None - ) - - def supersede_revision(*args, **kwargs) -> None: - webmention = db.session.get(SentWebmention, identifier) - - assert webmention is not None - - webmention.desired_revision = 2 - db.session.commit() - - discover.side_effect = supersede_revision - - sender_module.send_webmention.call_local(identifier) - - post.assert_not_called() - - with app.app_context(): - webmention = db.session.get(SentWebmention, identifier) - - assert webmention is not None - assert webmention.desired_revision == 2 - assert webmention.processed_revision is None - assert webmention.sent_revision is None - assert webmention.status is None - assert webmention.pending - - -def test_send_webmention_ignores_temporary_failure_for_superseded_revision( - app: Flask, sender_module: ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - identifier = create_sent_webmention(app) - - _, _, _, post = mock_sender_requests(sender_module, monkeypatch) - - def supersede_revision(*args, **kwargs) -> tuple[int, None]: - webmention = db.session.get(SentWebmention, identifier) - - assert webmention is not None - - webmention.desired_revision = 2 - db.session.commit() - - raise sender_module.TemporarySenderError("Temporary failure") - - post.side_effect = supersede_revision - - sender_module.send_webmention.call_local(identifier) - - with app.app_context(): - webmention = db.session.get(SentWebmention, identifier) - - assert webmention is not None - assert webmention.desired_revision == 2 - assert webmention.processed_revision is None - assert webmention.sent_revision is None - assert webmention.status is None - assert webmention.pending - - -def test_send_webmention_ignores_permanent_failure_for_superseded_revision( - app: Flask, sender_module: ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - identifier = create_sent_webmention(app) - - _, _, _, post = mock_sender_requests(sender_module, monkeypatch) - - def supersede_revision(*args, **kwargs) -> tuple[int, None]: - webmention = db.session.get(SentWebmention, identifier) - - assert webmention is not None - - webmention.desired_revision = 2 - db.session.commit() - - return (400, None) - - post.side_effect = supersede_revision - - sender_module.send_webmention.call_local(identifier) - - with app.app_context(): - webmention = db.session.get(SentWebmention, identifier) - - assert webmention is not None - assert webmention.desired_revision == 2 - assert webmention.processed_revision is None - assert webmention.sent_revision is None - assert webmention.status is None - assert webmention.pending |
