feat: implement RedditCommentStrategy and register in platform registry

Adds RedditCommentStrategy to app/services/platforms/reddit_comment.py,
resolving thread_id via thread_url_override or _find_sticky title search,
falling back to reconstructed URL when client.comment() returns empty string.
Registers the strategy under "reddit_comment" in the platform _REGISTRY.
7 new tests confirm all execution paths: url override, title pattern lookup,
not-found error, missing-extra error, empty-URL reconstruction, dupe guard,
and registry presence. Full suite: 34/34 passing.
This commit is contained in:
pyr0ball 2026-04-27 12:00:08 -07:00
parent 719a1d5aca
commit e37be0935d
3 changed files with 142 additions and 1 deletions

View file

@ -2,12 +2,13 @@ from __future__ import annotations
from app.services.platforms.base import PostingStrategy, PostResult from app.services.platforms.base import PostingStrategy, PostResult
from app.services.platforms.reddit_post import RedditPostStrategy from app.services.platforms.reddit_post import RedditPostStrategy
from app.services.platforms.reddit_comment import RedditCommentStrategy
_REGISTRY: dict[str, PostingStrategy] = { _REGISTRY: dict[str, PostingStrategy] = {
s.campaign_type: s() s.campaign_type: s()
for s in [ for s in [
RedditPostStrategy, RedditPostStrategy,
# RedditCommentStrategy — added in Plan B RedditCommentStrategy,
# BlogPostStrategy — added in Plan C # BlogPostStrategy — added in Plan C
] ]
} }

View file

@ -7,6 +7,8 @@ from datetime import date, timedelta
import httpx import httpx
from app.services.platforms.base import PostingStrategy, PostResult from app.services.platforms.base import PostingStrategy, PostResult
from app.services.reddit.client import RedditClient
from app.core.config import get_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -85,3 +87,51 @@ def _find_sticky(
if pattern_lower in title.lower(): if pattern_lower in title.lower():
return post.get("id") return post.get("id")
return None return None
class RedditCommentStrategy(PostingStrategy):
campaign_type = "reddit_comment"
def supports_dupe_guard(self) -> bool:
return False # comment threads may appear multiple times
def execute(
self,
*,
target: str,
title: str,
body: str,
flair: str | None = None,
extra: dict | None = None,
) -> PostResult:
extra = extra or {}
thread_url_override = extra.get("thread_url_override")
thread_title_pattern = extra.get("thread_title_pattern")
session_file = get_settings().reddit_session_file
# Resolve thread_id
if thread_url_override:
thread_id = _extract_thread_id_from_url(thread_url_override)
elif thread_title_pattern:
thread_id = _find_sticky(
sub=target,
title_pattern=thread_title_pattern,
session_file=session_file,
)
if thread_id is None:
raise ValueError(
f"No thread matching {thread_title_pattern!r} found in r/{target}"
)
else:
raise ValueError(
"RedditCommentStrategy requires thread_url_override or thread_title_pattern in extra"
)
client = RedditClient(session_file=session_file)
comment_url = client.comment(thread_id=thread_id, body=body)
# Reddit comment() may return empty URL; reconstruct from thread_id
if not comment_url:
comment_url = f"https://www.reddit.com/r/{target}/comments/{thread_id}/"
return PostResult(url=comment_url, metadata={"thread_id": thread_id})

View file

@ -0,0 +1,90 @@
from unittest.mock import patch, MagicMock
import pytest
from app.services.platforms.reddit_comment import RedditCommentStrategy
from app.services.platforms.base import PostResult
@pytest.fixture
def strategy():
return RedditCommentStrategy()
def test_execute_with_url_override(strategy, monkeypatch):
"""Uses thread_url_override to get thread_id, calls client.comment()"""
mock_client = MagicMock()
mock_client.comment.return_value = "https://www.reddit.com/r/flipping/comments/abc123/_/xyz789/"
with patch("app.services.platforms.reddit_comment.RedditClient", return_value=mock_client):
with patch("app.services.platforms.reddit_comment.get_settings") as mock_settings:
mock_settings.return_value.reddit_session_file = "/fake/session.json"
result = strategy.execute(
target="flipping",
title="ignored",
body="Hello thread!",
extra={"thread_url_override": "https://www.reddit.com/r/flipping/comments/abc123/weekly/"},
)
assert result.url == "https://www.reddit.com/r/flipping/comments/abc123/_/xyz789/"
assert result.metadata["thread_id"] == "abc123"
mock_client.comment.assert_called_once_with(thread_id="abc123", body="Hello thread!")
def test_execute_with_title_pattern_found(strategy):
"""Uses _find_sticky to locate thread, posts comment"""
with patch("app.services.platforms.reddit_comment._find_sticky", return_value="def456"):
with patch("app.services.platforms.reddit_comment.RedditClient") as MockClient:
with patch("app.services.platforms.reddit_comment.get_settings") as mock_settings:
mock_settings.return_value.reddit_session_file = "/fake/session.json"
MockClient.return_value.comment.return_value = ""
result = strategy.execute(
target="cscareerquestions",
title="ignored",
body="Job search tool",
extra={"thread_title_pattern": "Monthly Resume"},
)
assert "def456" in result.url
assert result.metadata["thread_id"] == "def456"
def test_execute_thread_not_found(strategy):
"""Raises ValueError when _find_sticky returns None"""
with patch("app.services.platforms.reddit_comment._find_sticky", return_value=None):
with patch("app.services.platforms.reddit_comment.get_settings") as mock_settings:
mock_settings.return_value.reddit_session_file = "/fake/session.json"
with pytest.raises(ValueError, match="No thread matching"):
strategy.execute(
target="cscareerquestions",
title="ignored",
body="body",
extra={"thread_title_pattern": "Monthly Resume"},
)
def test_execute_no_extra_raises(strategy):
"""Raises ValueError when neither thread_url_override nor thread_title_pattern provided"""
with patch("app.services.platforms.reddit_comment.get_settings") as mock_settings:
mock_settings.return_value.reddit_session_file = "/fake/session.json"
with pytest.raises(ValueError, match="requires thread_url_override or thread_title_pattern"):
strategy.execute(target="flipping", title="t", body="b", extra={})
def test_reconstructed_url_on_empty_comment_url(strategy):
"""When client.comment() returns empty string, reconstructs URL from thread_id"""
with patch("app.services.platforms.reddit_comment.RedditClient") as MockClient:
with patch("app.services.platforms.reddit_comment.get_settings") as mock_settings:
mock_settings.return_value.reddit_session_file = "/fake/session.json"
MockClient.return_value.comment.return_value = ""
result = strategy.execute(
target="flipping",
title="t",
body="b",
extra={"thread_url_override": "https://www.reddit.com/r/flipping/comments/abc123/weekly/"},
)
assert result.url == "https://www.reddit.com/r/flipping/comments/abc123/"
def test_supports_dupe_guard_false(strategy):
assert strategy.supports_dupe_guard() is False
def test_registry_contains_reddit_comment():
from app.services.platforms import _REGISTRY
assert "reddit_comment" in _REGISTRY