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:
parent
719a1d5aca
commit
e37be0935d
3 changed files with 142 additions and 1 deletions
|
|
@ -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
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
90
tests/services/platforms/test_reddit_comment_strategy.py
Normal file
90
tests/services/platforms/test_reddit_comment_strategy.py
Normal 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
|
||||||
Loading…
Reference in a new issue