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"}