# SPDX-FileCopyrightText: 2026 Dennis Fink # # SPDX-License-Identifier: BSD-3-Clause import uuid from datetime import UTC, datetime from types import ModuleType from unittest.mock import Mock, call import pytest import sqlalchemy as sa from flask import Flask from flask.testing import FlaskClient from webmentions_ssg import DATABASE as db from webmentions_ssg.models import ( ReceivedWebmention, SentWebmention, SentWebmentionStatus, Source, User, ) SOURCE_URL = "https://source.example/post" TARGET_URL = "https://dennisfink.me/blog/example/" @pytest.fixture def views_module(app: Flask) -> ModuleType: _ = app from webmentions_ssg import views return views def mock_endpoint_form( views_module: ModuleType, monkeypatch: pytest.MonkeyPatch, *, valid: bool, source: str = SOURCE_URL, target: str = TARGET_URL, errors: dict[str, list[str]] | None = None, ) -> Mock: form = Mock() form.validate_on_submit.return_value = valid form.source.data = source form.target.data = target form.errors = errors or {} monkeypatch.setattr(views_module.forms, "EndpointForm", Mock(return_value=form)) return form def log_in(app: Flask, client: FlaskClient) -> None: with app.app_context(): user = User(username="admin", password="test") db.session.add(user) db.session.commit() user_id = user.id with client.session_transaction() as session: session["_user_id"] = str(user_id) session["_fresh"] = True def create_sent_source(app: Flask) -> uuid.UUID: now = datetime.now(UTC) with app.app_context(): source = Source( path="blog/example/index.html", url="https://dennisfink.me/blog/example/", content_hash="0" * 32, revision=2, last_seen_at=now, revised_at=now, ) source.sent_webmentions.extend( [ SentWebmention( target="https://example.com/b", active=True, desired_revision=2, processed_revision=2, sent_revision=2, status=SentWebmentionStatus.SENT, endpoint="https://example.com/webmention", response_status=202, ), SentWebmention( target="https://example.com/a", active=True, desired_revision=2, processed_revision=2, sent_revision=1, status=SentWebmentionStatus.FAILED, failure_reason="Webmention endpoint returned HTTP 400", endpoint="https://example.com/webmention", response_status=400, ), ] ) db.session.add(source) db.session.commit() return source.uuid def test_endpoint_only_accepts_post(client: FlaskClient) -> None: response = client.get("/endpoint") assert response.status_code == 405 def test_endpoint_returns_form_errors( client: FlaskClient, views_module: ModuleType, monkeypatch: pytest.MonkeyPatch ) -> None: errors = {"source": ["Invalid source"]} mock_endpoint_form(views_module, monkeypatch, valid=False, errors=errors) response = client.post("/endpoint") assert response.status_code == 400 assert response.get_json() == errors def test_endpoint_creates_webmention( app: Flask, client: FlaskClient, views_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: form = mock_endpoint_form(views_module, monkeypatch, valid=True) verify_webmention = Mock() monkeypatch.setattr(views_module, "verify_webmention", verify_webmention) response = client.post("/endpoint") assert response.status_code == 201 form.validate_on_submit.assert_called_once_with() with app.app_context(): webmention = db.session.scalar(sa.select(ReceivedWebmention)) assert webmention is not None assert webmention.source == SOURCE_URL assert webmention.target == TARGET_URL assert webmention.status == "received" assert webmention.failure_reason is None identifier = webmention.uuid assert response.headers["Location"].endswith(str(identifier)) verify_webmention.assert_called_once_with(identifier) def test_endpoint_is_idempotent( app: Flask, client: FlaskClient, views_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: mock_endpoint_form(views_module, monkeypatch, valid=True) verify_webmention = Mock() monkeypatch.setattr(views_module, "verify_webmention", verify_webmention) first_response = client.post("/endpoint") second_response = client.post("/endpoint") assert first_response.status_code == 201 assert second_response.status_code == 201 assert first_response.headers["Location"] == second_response.headers["Location"] with app.app_context(): webmentions = db.session.scalars(sa.select(ReceivedWebmention)).all() assert len(webmentions) == 1 webmention = webmentions[0] assert webmention.source == SOURCE_URL assert webmention.target == TARGET_URL assert webmention.status == "received" assert webmention.failure_reason is None identifier = webmention.uuid assert verify_webmention.call_args_list == [call(identifier), call(identifier)] def test_resending_resets_failure_state( app: Flask, client: FlaskClient, views_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: mock_endpoint_form(views_module, monkeypatch, valid=True) verify_webmention = Mock() monkeypatch.setattr(views_module, "verify_webmention", verify_webmention) with app.app_context(): existing = ReceivedWebmention( uuid=uuid.uuid7(), source=SOURCE_URL, target=TARGET_URL, status="failed", failure_reason="Previous failure", ) db.session.add(existing) db.session.commit() identifier = existing.uuid response = client.post("/endpoint") assert response.status_code == 201 with app.app_context(): webmention = db.session.get(ReceivedWebmention, identifier) assert webmention is not None assert webmention.status == "received" assert webmention.failure_reason is None verify_webmention.assert_called_once_with(identifier) @pytest.mark.parametrize("path", ["/sent", f"/sent/{uuid.uuid7()}"]) def test_sent_views_require_login(client: FlaskClient, path: str) -> None: response = client.get(path) assert response.status_code == 302 assert "/login" in response.headers["Location"] def test_sent_lists_sources(app: Flask, client: FlaskClient) -> None: log_in(app, client) identifier = create_sent_source(app) response = client.get("/sent") assert response.status_code == 200 html = response.get_data(as_text=True) assert "blog/example/index.html" in html assert "https://dennisfink.me/blog/example/" in html assert f"/sent/{identifier}" in html def test_sent_source_lists_webmentions(app: Flask, client: FlaskClient) -> None: log_in(app, client) identifier = create_sent_source(app) response = client.get(f"/sent/{identifier}") assert response.status_code == 200 html = response.get_data(as_text=True) assert "blog/example/index.html" in html assert "https://dennisfink.me/blog/example/" in html first_target = "https://example.com/a" second_target = "https://example.com/b" assert first_target in html assert second_target in html assert html.index(first_target) < html.index(second_target) assert "Webmention endpoint returned HTTP 400" in html assert "https://example.com/webmention" in html def test_sent_source_returns_404_for_unknown_source( app: Flask, client: FlaskClient ) -> None: log_in(app, client) response = client.get(f"/sent/{uuid.uuid7()}") assert response.status_code == 404