Files
10Backward/tests/test_publishing.py
T
2026-08-04 18:57:43 +00:00

192 lines
5.9 KiB
Python

from __future__ import annotations
import os
from datetime import date
from pathlib import Path
from typing import Callable
import pytest
from tenbackward.config import load_config
from tenbackward.matching import MatchedPost
from tenbackward.publishing import (
MASTODON_STATUS_LIMIT,
PublishError,
build_status_text,
publish_mastodon,
slugify_title,
validate_status,
)
from tenbackward.state import PostedStore
def _make_match(path: str, title: str, day: int = 4) -> MatchedPost:
return MatchedPost(
path=path,
title=title,
date=date(2016, 8, day),
url=f"https://blog.example.com/2016/08/{day:02d}/{path.split('-', 3)[-1].removesuffix('.md')}/",
)
class _FakeMastodon:
"""Captures ``status_post`` calls and can be configured to raise."""
def __init__(self, *, raise_on_post: Exception | None = None) -> None:
self.raise_on_post = raise_on_post
self.calls: list[tuple[str, dict]] = []
def __call__(self, *, access_token: str, api_base_url: str) -> "_FakeMastodon":
self.access_token = access_token
self.api_base_url = api_base_url
return self
def status_post(self, status: str, **kwargs) -> None:
self.calls.append((status, kwargs))
if self.raise_on_post is not None:
raise self.raise_on_post
@pytest.fixture()
def full_config(env_setup, tmp_path: Path):
os.environ["DATA_DIR"] = str(tmp_path)
return load_config()
def test_slugify_title_basic_ascii() -> None:
assert slugify_title("Hello, World!") == "hello-world"
def test_slugify_title_collapses_repeats_and_trims() -> None:
assert slugify_title("!!!Foo---Bar???") == "foo-bar"
assert slugify_title("---trim---me---") == "trim-me"
def test_build_status_text_single_post_layout() -> None:
match = _make_match("2016/2016-08-04-foo.md", "Foo")
status = build_status_text(
"Heute vor 10 Jahren:", [match], "#throwback,#10backward"
)
assert status == (
"Heute vor 10 Jahren:\n\n"
"Foo\nhttps://blog.example.com/2016/08/04/foo/\n\n"
"#throwback #10backward\n"
)
def test_build_status_text_multiple_posts_combined() -> None:
match_a = _make_match("2016/2016-08-04-a.md", "Alpha")
match_b = _make_match("2016/2016-08-04-b.md", "Bravo")
status = build_status_text("P:", [match_a, match_b], "#x")
assert "Alpha" in status
assert "Bravo" in status
assert match_a.url in status
assert match_b.url in status
assert status.count("Alpha") == 1
assert status.count("Bravo") == 1
assert status.count(match_a.url) == 1
assert status.count(match_b.url) == 1
def test_build_status_text_empty_posts_raises() -> None:
with pytest.raises(PublishError):
build_status_text("P:", [], "#x")
def test_build_status_text_preserves_deterministic_order() -> None:
match_a = _make_match("2016/2016-08-05-a.md", "Alpha", day=5)
match_b = _make_match("2016/2016-08-04-b.md", "Bravo", day=4)
forward = build_status_text("P:", [match_a, match_b], "#x")
reverse = build_status_text("P:", [match_b, match_a], "#x")
assert forward == reverse
assert forward.index("Bravo") < forward.index("Alpha")
def test_validate_status_within_limit() -> None:
validate_status("a" * 480)
def test_validate_status_over_limit_raises_and_does_not_truncate() -> None:
long = "a" * (MASTODON_STATUS_LIMIT + 1)
with pytest.raises(PublishError):
validate_status(long)
assert len(long) == MASTODON_STATUS_LIMIT + 1
def test_publish_mastodon_success_invokes_client_with_composed_status(
full_config,
) -> None:
match = _make_match("2016/2016-08-04-foo.md", "Foo")
fake = _FakeMastodon()
result = publish_mastodon(full_config, [match], client_factory=fake)
assert result.endswith("\n")
assert fake.calls, "status_post must be invoked"
posted_status, posted_kwargs = fake.calls[0]
assert posted_status == result
assert posted_kwargs == {"visibility": "public"}
assert fake.api_base_url == "https://mastodon.example"
assert fake.access_token == "test-token"
def test_publish_mastodon_api_failure_raises_and_does_not_persist(
full_config, tmp_path: Path
) -> None:
match = _make_match("2016/2016-08-04-foo.md", "Foo")
fake = _FakeMastodon(raise_on_post=RuntimeError("boom"))
with pytest.raises(PublishError) as exc_info:
publish_mastodon(full_config, [match], client_factory=fake)
assert "publish_failed" in str(exc_info.value)
assert isinstance(exc_info.value.__cause__, RuntimeError)
store = PostedStore(tmp_path)
assert not store.is_posted(match.path)
def test_publish_mastodon_over_limit_raises_before_api_call(
full_config,
) -> None:
long_title = "T" * (MASTODON_STATUS_LIMIT + 1)
match = MatchedPost(
path="2016/2016-08-04-foo.md",
title=long_title,
date=date(2016, 8, 4),
url="https://blog.example.com/2016/08/04/foo/",
)
fake = _FakeMastodon()
with pytest.raises(PublishError):
publish_mastodon(full_config, [match], client_factory=fake)
assert fake.calls == []
def test_publish_mastodon_combined_status_for_multiple_matches(full_config) -> None:
match_a = _make_match("2016/2016-08-04-a.md", "Alpha")
match_b = _make_match("2016/2016-08-04-b.md", "Bravo")
fake = _FakeMastodon()
publish_mastodon(full_config, [match_a, match_b], client_factory=fake)
assert len(fake.calls) == 1
status, _ = fake.calls[0]
assert "Alpha" in status
assert "Bravo" in status
assert match_a.url in status
assert match_b.url in status
def test_publish_mastodon_uses_config_visibility(env_setup, tmp_path: Path) -> None:
os.environ["VISIBILITY"] = "unlisted"
os.environ["DATA_DIR"] = str(tmp_path)
config = load_config()
match = _make_match("2016/2016-08-04-foo.md", "Foo")
fake = _FakeMastodon()
publish_mastodon(config, [match], client_factory=fake)
assert fake.calls[0][1] == {"visibility": "unlisted"}