fix(reactions): prevent duplicate Like activities when file mtime changes
- Skip republishing Like if target URL unchanged - Add regression tests for both reply and article likes
This commit is contained in:
parent
339214ecff
commit
55c39b7c7a
3 changed files with 204 additions and 4 deletions
|
|
@ -720,10 +720,16 @@ class ActivityPubIntegration(ActivityPubRepliesMixin, StartupSyncMixin):
|
||||||
if not like_of:
|
if not like_of:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Undo any previously published Like for this file
|
|
||||||
old_like_id = self._get_like_id(filepath)
|
old_like_id = self._get_like_id(filepath)
|
||||||
|
old_object = self._get_like_object(filepath) if old_like_id else None
|
||||||
|
|
||||||
|
# Skip if we already have a Like for the same target URL
|
||||||
|
if old_like_id and old_object == like_of:
|
||||||
|
logger.debug("Like already published for %s, skipping", like_of)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Undo any previously published Like for this file (target changed)
|
||||||
if old_like_id:
|
if old_like_id:
|
||||||
old_object = self._get_like_object(filepath)
|
|
||||||
self._publish_undo_like(old_like_id, actor_url, object_url=old_object)
|
self._publish_undo_like(old_like_id, actor_url, object_url=old_object)
|
||||||
|
|
||||||
like_activity = self._publish_like(like_of)
|
like_activity = self._publish_like(like_of)
|
||||||
|
|
|
||||||
|
|
@ -380,10 +380,16 @@ class ActivityPubRepliesMixin(ActivityPubPublishMixin):
|
||||||
if not like_of:
|
if not like_of:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Undo any previously published Like for this file
|
|
||||||
old_like_id = self._get_reply_like_id(filepath)
|
old_like_id = self._get_reply_like_id(filepath)
|
||||||
|
old_object = self._get_reply_like_object(filepath) if old_like_id else None
|
||||||
|
|
||||||
|
# Skip if we already have a Like for the same target URL
|
||||||
|
if old_like_id and old_object == like_of:
|
||||||
|
logger.debug("Like already published for %s, skipping", like_of)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Undo any previously published Like for this file (target changed)
|
||||||
if old_like_id:
|
if old_like_id:
|
||||||
old_object = self._get_reply_like_object(filepath)
|
|
||||||
self._publish_undo_like(old_like_id, actor_url, object_url=old_object)
|
self._publish_undo_like(old_like_id, actor_url, object_url=old_object)
|
||||||
|
|
||||||
like_activity = self._publish_like(like_of)
|
like_activity = self._publish_like(like_of)
|
||||||
|
|
|
||||||
|
|
@ -2283,6 +2283,95 @@ class ReplyLikeActivityTest(unittest.TestCase):
|
||||||
obj = handler.publish_object.call_args[0][0]
|
obj = handler.publish_object.call_args[0][0]
|
||||||
self.assertEqual(obj.type, "Note")
|
self.assertEqual(obj.type, "Note")
|
||||||
|
|
||||||
|
@skip_if_no_pubby
|
||||||
|
def test_reprocess_like_reply_same_target_no_republish(self):
|
||||||
|
"""Reprocessing a like reply with unchanged target does not republish."""
|
||||||
|
tmpdir = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(tmpdir.cleanup)
|
||||||
|
root = Path(tmpdir.name)
|
||||||
|
integration, handler, _, replies_dir = self._make_integration(root)
|
||||||
|
|
||||||
|
art_dir = replies_dir / "some-article"
|
||||||
|
art_dir.mkdir()
|
||||||
|
reply_file = art_dir / "liked-it.md"
|
||||||
|
reply_file.write_text(
|
||||||
|
"[//]: # (like-of: https://remote.social/statuses/42)\n"
|
||||||
|
"[//]: # (published: 2025-07-10)\n"
|
||||||
|
"# Liked\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.publish_object = MagicMock()
|
||||||
|
handler.publish_activity = MagicMock()
|
||||||
|
|
||||||
|
# First publish
|
||||||
|
integration.on_reply_change(self.ChangeType.ADDED, str(reply_file))
|
||||||
|
_join_ap_publish_threads()
|
||||||
|
|
||||||
|
self.assertEqual(handler.publish_activity.call_count, 1)
|
||||||
|
first_like = handler.publish_activity.call_args[0][0]
|
||||||
|
self.assertEqual(first_like["type"], "Like")
|
||||||
|
|
||||||
|
# Reprocess with same target (simulates startup sync with mtime change)
|
||||||
|
handler.publish_activity.reset_mock()
|
||||||
|
integration.on_reply_change(self.ChangeType.EDITED, str(reply_file))
|
||||||
|
_join_ap_publish_threads()
|
||||||
|
|
||||||
|
# Should NOT republish - target unchanged
|
||||||
|
handler.publish_activity.assert_not_called()
|
||||||
|
|
||||||
|
@skip_if_no_pubby
|
||||||
|
def test_reprocess_like_reply_different_target_republishes(self):
|
||||||
|
"""Reprocessing a like reply with changed target republishes with Undo."""
|
||||||
|
tmpdir = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(tmpdir.cleanup)
|
||||||
|
root = Path(tmpdir.name)
|
||||||
|
integration, handler, _, replies_dir = self._make_integration(root)
|
||||||
|
|
||||||
|
art_dir = replies_dir / "some-article"
|
||||||
|
art_dir.mkdir()
|
||||||
|
reply_file = art_dir / "liked-it.md"
|
||||||
|
reply_file.write_text(
|
||||||
|
"[//]: # (like-of: https://remote.social/statuses/42)\n"
|
||||||
|
"[//]: # (published: 2025-07-10)\n"
|
||||||
|
"# Liked\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.publish_object = MagicMock()
|
||||||
|
handler.publish_activity = MagicMock()
|
||||||
|
|
||||||
|
# First publish
|
||||||
|
integration.on_reply_change(self.ChangeType.ADDED, str(reply_file))
|
||||||
|
_join_ap_publish_threads()
|
||||||
|
|
||||||
|
self.assertEqual(handler.publish_activity.call_count, 1)
|
||||||
|
|
||||||
|
# Change target
|
||||||
|
reply_file.write_text(
|
||||||
|
"[//]: # (like-of: https://remote.social/statuses/99)\n"
|
||||||
|
"[//]: # (published: 2025-07-10)\n"
|
||||||
|
"# Liked\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.publish_activity.reset_mock()
|
||||||
|
integration.on_reply_change(self.ChangeType.EDITED, str(reply_file))
|
||||||
|
_join_ap_publish_threads()
|
||||||
|
|
||||||
|
# Should publish Undo for old Like + new Like
|
||||||
|
calls = handler.publish_activity.call_args_list
|
||||||
|
self.assertEqual(len(calls), 2)
|
||||||
|
|
||||||
|
undo_call = next(c for c in calls if c[0][0].get("type") == "Undo")
|
||||||
|
like_call = next(c for c in calls if c[0][0].get("type") == "Like")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
undo_call[0][0]["object"]["object"],
|
||||||
|
"https://remote.social/statuses/42",
|
||||||
|
)
|
||||||
|
self.assertEqual(like_call[0][0]["object"], "https://remote.social/statuses/99")
|
||||||
|
|
||||||
|
|
||||||
class ArticleLikeActivityTest(unittest.TestCase):
|
class ArticleLikeActivityTest(unittest.TestCase):
|
||||||
"""Tests for Like activity publishing from article files."""
|
"""Tests for Like activity publishing from article files."""
|
||||||
|
|
@ -2414,6 +2503,105 @@ class ArticleLikeActivityTest(unittest.TestCase):
|
||||||
"https://remote.social/statuses/99",
|
"https://remote.social/statuses/99",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@skip_if_no_pubby
|
||||||
|
def test_reprocess_article_like_same_target_no_republish(self):
|
||||||
|
"""Reprocessing an article like with unchanged target does not republish."""
|
||||||
|
tmpdir = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(tmpdir.cleanup)
|
||||||
|
root = Path(tmpdir.name)
|
||||||
|
integration, handler, pages_dir = self._make_integration(root)
|
||||||
|
|
||||||
|
article = pages_dir / "like-post.md"
|
||||||
|
article.write_text(
|
||||||
|
"[//]: # (title: I liked this)\n"
|
||||||
|
"[//]: # (published: 2025-07-10)\n"
|
||||||
|
"[//]: # (like-of: https://remote.social/statuses/99)\n"
|
||||||
|
"\n"
|
||||||
|
"# I liked this\n"
|
||||||
|
"This is a great post.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.publish_object = MagicMock()
|
||||||
|
handler.publish_activity = MagicMock()
|
||||||
|
|
||||||
|
# First publish
|
||||||
|
integration.on_content_change(self.ChangeType.ADDED, str(article))
|
||||||
|
_join_ap_publish_threads()
|
||||||
|
|
||||||
|
self.assertEqual(handler.publish_activity.call_count, 1)
|
||||||
|
first_like = handler.publish_activity.call_args[0][0]
|
||||||
|
self.assertEqual(first_like["type"], "Like")
|
||||||
|
|
||||||
|
# Reprocess with same target (simulates startup sync with mtime change)
|
||||||
|
handler.publish_activity.reset_mock()
|
||||||
|
handler.publish_object.reset_mock()
|
||||||
|
integration.on_content_change(self.ChangeType.EDITED, str(article))
|
||||||
|
_join_ap_publish_threads()
|
||||||
|
|
||||||
|
# Article Update published, but Like should NOT be republished
|
||||||
|
handler.publish_object.assert_called_once()
|
||||||
|
handler.publish_activity.assert_not_called()
|
||||||
|
|
||||||
|
@skip_if_no_pubby
|
||||||
|
def test_reprocess_article_like_different_target_republishes(self):
|
||||||
|
"""Reprocessing an article like with changed target republishes with Undo."""
|
||||||
|
tmpdir = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(tmpdir.cleanup)
|
||||||
|
root = Path(tmpdir.name)
|
||||||
|
integration, handler, pages_dir = self._make_integration(root)
|
||||||
|
|
||||||
|
article = pages_dir / "like-post.md"
|
||||||
|
article.write_text(
|
||||||
|
"[//]: # (title: I liked this)\n"
|
||||||
|
"[//]: # (published: 2025-07-10)\n"
|
||||||
|
"[//]: # (like-of: https://remote.social/statuses/99)\n"
|
||||||
|
"\n"
|
||||||
|
"# I liked this\n"
|
||||||
|
"This is a great post.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.publish_object = MagicMock()
|
||||||
|
handler.publish_activity = MagicMock()
|
||||||
|
|
||||||
|
# First publish
|
||||||
|
integration.on_content_change(self.ChangeType.ADDED, str(article))
|
||||||
|
_join_ap_publish_threads()
|
||||||
|
|
||||||
|
self.assertEqual(handler.publish_activity.call_count, 1)
|
||||||
|
|
||||||
|
# Change target
|
||||||
|
article.write_text(
|
||||||
|
"[//]: # (title: I liked this)\n"
|
||||||
|
"[//]: # (published: 2025-07-10)\n"
|
||||||
|
"[//]: # (like-of: https://remote.social/statuses/200)\n"
|
||||||
|
"\n"
|
||||||
|
"# I liked this\n"
|
||||||
|
"Actually, I like this one more.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.publish_activity.reset_mock()
|
||||||
|
handler.publish_object.reset_mock()
|
||||||
|
integration.on_content_change(self.ChangeType.EDITED, str(article))
|
||||||
|
_join_ap_publish_threads()
|
||||||
|
|
||||||
|
# Should publish Undo for old Like + new Like
|
||||||
|
calls = handler.publish_activity.call_args_list
|
||||||
|
self.assertEqual(len(calls), 2)
|
||||||
|
|
||||||
|
undo_call = next(c for c in calls if c[0][0].get("type") == "Undo")
|
||||||
|
like_call = next(c for c in calls if c[0][0].get("type") == "Like")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
undo_call[0][0]["object"]["object"],
|
||||||
|
"https://remote.social/statuses/99",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
like_call[0][0]["object"], "https://remote.social/statuses/200"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class StandaloneLikeContentNegotiationTest(unittest.TestCase):
|
class StandaloneLikeContentNegotiationTest(unittest.TestCase):
|
||||||
"""Tests for AP content negotiation returning Like activity for standalone likes."""
|
"""Tests for AP content negotiation returning Like activity for standalone likes."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue