AI Implementation feature(1087): Mastodon Post Composition and Publishing (#6)
This commit was merged in pull request #6.
This commit is contained in:
@@ -142,7 +142,7 @@ def test_load_config_applies_optional_defaults(env_setup, monkeypatch) -> None:
|
||||
monkeypatch.delenv("TZ", raising=False)
|
||||
|
||||
config = load_config()
|
||||
assert config.throwback_prefix == "Throwback:"
|
||||
assert config.throwback_prefix == "Heute vor 10 Jahren:"
|
||||
assert config.max_retries == 3
|
||||
assert config.tz == "Europe/Berlin"
|
||||
assert config.visibility == "public"
|
||||
|
||||
@@ -11,6 +11,7 @@ from tenbackward.matching import (
|
||||
MatchedPost,
|
||||
find_anniversary_matches,
|
||||
iter_anniversary_paths,
|
||||
slugify_title,
|
||||
)
|
||||
|
||||
|
||||
@@ -64,6 +65,7 @@ def test_match_uses_frontmatter_date_and_title(tmp_path: Path) -> None:
|
||||
assert match.title == "Foo"
|
||||
assert match.date == date(2014, 8, 4)
|
||||
assert match.url == "https://chaospott.de/2014/08/04/foo/"
|
||||
assert match.url.endswith(slugify_title(match.title) + "/")
|
||||
|
||||
|
||||
def test_match_falls_back_to_filename_when_frontmatter_missing(tmp_path: Path) -> None:
|
||||
@@ -87,6 +89,7 @@ def test_match_falls_back_to_filename_when_frontmatter_missing(tmp_path: Path) -
|
||||
assert match.title == "no-frontmatter"
|
||||
assert match.date == date(2015, 3, 10)
|
||||
assert match.url == "https://chaospott.de/2015/03/10/no-frontmatter/"
|
||||
assert match.url.endswith(slugify_title(match.title) + "/")
|
||||
|
||||
|
||||
def test_unpublished_post_is_skipped(tmp_path: Path) -> None:
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
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"}
|
||||
@@ -8,9 +8,22 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from datetime import date
|
||||
|
||||
from tenbackward import main as main_module
|
||||
from tenbackward.logging_setup import JsonFormatter
|
||||
from tenbackward.main import main
|
||||
from tenbackward.matching import MatchedPost
|
||||
from tenbackward.publishing import PublishError
|
||||
|
||||
|
||||
def _match(path: str) -> MatchedPost:
|
||||
return MatchedPost(
|
||||
path=path,
|
||||
title=path,
|
||||
date=date(2016, 8, 4),
|
||||
url=f"https://blog.example.com/2016/08/04/{path.split('-', 3)[-1].removesuffix('.md')}/",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -38,7 +51,11 @@ def _seed_state(data_dir: Path, ids: list[str]) -> None:
|
||||
def test_run_emits_one_info_summary_on_success(env_setup, data_dir, capture_logger, monkeypatch) -> None:
|
||||
monkeypatch.setenv("DATA_DIR", str(data_dir))
|
||||
|
||||
monkeypatch.setattr(main_module, "_iter_candidates", lambda config: ["new-1", "already-1"])
|
||||
def _stub_publish(config, posts, **_kwargs):
|
||||
return "stubbed"
|
||||
|
||||
monkeypatch.setattr(main_module, "_iter_candidates", lambda config: [_match("new-1"), _match("already-1")])
|
||||
monkeypatch.setattr(main_module, "publish_mastodon", _stub_publish)
|
||||
|
||||
_seed_state(data_dir, ["already-1"])
|
||||
|
||||
@@ -100,7 +117,12 @@ def test_run_distinguishes_posted_from_skipped_via_ids(env_setup, data_dir, capt
|
||||
monkeypatch.setattr(
|
||||
main_module,
|
||||
"_iter_candidates",
|
||||
lambda config: ["alpha", "beta", "gamma"],
|
||||
lambda config: [_match("alpha"), _match("beta"), _match("gamma")],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main_module,
|
||||
"publish_mastodon",
|
||||
lambda config, posts, **_kwargs: "stubbed",
|
||||
)
|
||||
_seed_state(data_dir, ["beta"])
|
||||
|
||||
@@ -114,3 +136,28 @@ def test_run_distinguishes_posted_from_skipped_via_ids(env_setup, data_dir, capt
|
||||
assert "beta" not in payload["posted_ids"]
|
||||
assert payload["skipped"] == 1
|
||||
assert payload["posted"] == 2
|
||||
|
||||
|
||||
def test_run_publish_failure_leaves_state_untouched(
|
||||
env_setup, data_dir, capture_logger, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("DATA_DIR", str(data_dir))
|
||||
|
||||
def _boom(config, posts, **_kwargs):
|
||||
raise PublishError("publish_failed: stub")
|
||||
|
||||
monkeypatch.setattr(main_module, "_iter_candidates", lambda config: [_match("alpha")])
|
||||
monkeypatch.setattr(main_module, "publish_mastodon", _boom)
|
||||
|
||||
rc = main()
|
||||
assert rc != 0
|
||||
|
||||
from tenbackward.state import load_posted
|
||||
|
||||
assert load_posted(data_dir) == []
|
||||
error_lines = [
|
||||
line
|
||||
for line in _run_lines(capture_logger)
|
||||
if line.get("level") == "ERROR"
|
||||
]
|
||||
assert any(line.get("event") == "pipeline_error" for line in error_lines)
|
||||
|
||||
Reference in New Issue
Block a user