Repository: bookwyrm-social/bookwyrm

Issue:
Boosts from remote users are shown twice
**Describe the bug**
In some cases (maybe all) where a remote user boosts a post, the boost is shown twice.

**Screenshots**
Screenshot where the booster and op are both from a remote instance:
![image](https://user-images.githubusercontent.com/66644064/113485516-2f141e00-94ae-11eb-94ca-312d21d3d8df.png)
Screenshot where the booster is from a remote instance and op is from the local instance:
![image](https://user-images.githubusercontent.com/66644064/113485481-fe33e900-94ad-11eb-90a7-c4d5c76c2bd6.png)


**Additional context**
Liking the post and reloading the page will show the like on both boosts.



Discussion:
interesting! this will probably be fixed by #855 
I think this is fixed? I'm hoping it's fixed.
There were no federated boosts so far but I'll keep you posted!
it did not fix it! intriguing. (That the boost is blank has a fix which isn't deployed yet)
<img width="761" alt="Screen Shot 2021-04-06 at 9 13 37 AM" src="https://user-images.githubusercontent.com/1807695/113743372-766dfa80-96b8-11eb-84c8-4e6eabb694dd.png">

I logged into my .social account and in that feed, the post from your screenshot only appears once.
interesting! that's good to know.
I think this is resolved - feel free to re-open if you see it happen again
I jinxed it by saying it was gone! I just replicated it

Resolving patch:
diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py
--- a/bookwyrm/models/activitypub_mixin.py
+++ b/bookwyrm/models/activitypub_mixin.py
@@ -204,7 +204,9 @@ def save(self, *args, created=None, **kwargs):
         created = created or not bool(self.id)
         # first off, we want to save normally no matter what
         super().save(*args, **kwargs)
-        if not broadcast:
+        if not broadcast or (
+            hasattr(self, "status_type") and self.status_type == "Announce"
+        ):
             return
 
         # this will work for objects owned by a user (lists, shelves)
diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py
--- a/bookwyrm/models/status.py
+++ b/bookwyrm/models/status.py
@@ -351,6 +351,16 @@ class Boost(ActivityMixin, Status):
 
     def save(self, *args, **kwargs):
         """ save and notify """
+        # This constraint can't work as it would cross tables.
+        # class Meta:
+        #     unique_together = ('user', 'boosted_status')
+        if (
+            Boost.objects.filter(boosted_status=self.boosted_status, user=self.user)
+            .exclude(id=self.id)
+            .exists()
+        ):
+            return
+
         super().save(*args, **kwargs)
         if not self.boosted_status.user.local or self.boosted_status.user == self.user:
             return


Test patch:
diff --git a/bookwyrm/tests/views/inbox/test_inbox_announce.py b/bookwyrm/tests/views/inbox/test_inbox_announce.py
--- a/bookwyrm/tests/views/inbox/test_inbox_announce.py
+++ b/bookwyrm/tests/views/inbox/test_inbox_announce.py
@@ -51,7 +51,7 @@ def setUp(self):
         models.SiteSettings.objects.create()
 
     @patch("bookwyrm.activitystreams.ActivityStream.add_status")
-    def test_handle_boost(self, _):
+    def test_boost(self, redis_mock):
         """ boost a status """
         self.assertEqual(models.Notification.objects.count(), 0)
         activity = {
@@ -66,16 +66,23 @@ def test_handle_boost(self, _):
         with patch("bookwyrm.models.status.Status.ignore_activity") as discarder:
             discarder.return_value = False
             views.inbox.activity_task(activity)
+
+        # boost added to redis activitystreams
+        self.assertTrue(redis_mock.called)
+
+        # boost created of correct status
         boost = models.Boost.objects.get()
         self.assertEqual(boost.boosted_status, self.status)
+
+        # notification sent to original poster
         notification = models.Notification.objects.get()
         self.assertEqual(notification.user, self.local_user)
         self.assertEqual(notification.related_status, self.status)
 
     @responses.activate
     @patch("bookwyrm.activitystreams.ActivityStream.add_status")
-    def test_handle_boost_remote_status(self, redis_mock):
-        """ boost a status """
+    def test_boost_remote_status(self, redis_mock):
+        """ boost a status from a remote server """
         work = models.Work.objects.create(title="work title")
         book = models.Edition.objects.create(
             title="Test",
@@ -123,7 +130,7 @@ def test_handle_boost_remote_status(self, redis_mock):
         self.assertEqual(boost.boosted_status.comment.book, book)
 
     @responses.activate
-    def test_handle_discarded_boost(self):
+    def test_discarded_boost(self):
         """ test a boost of a mastodon status that will be discarded """
         status = models.Status(
             content="hi",
@@ -146,7 +153,7 @@ def test_handle_discarded_boost(self):
         views.inbox.activity_task(activity)
         self.assertEqual(models.Boost.objects.count(), 0)
 
-    def test_handle_unboost(self):
+    def test_unboost(self):
         """ undo a boost """
         with patch("bookwyrm.activitystreams.ActivityStream.add_status"):
             boost = models.Boost.objects.create(
@@ -175,7 +182,7 @@ def test_handle_unboost(self):
             self.assertTrue(redis_mock.called)
         self.assertFalse(models.Boost.objects.exists())
 
-    def test_handle_unboost_unknown_boost(self):
+    def test_unboost_unknown_boost(self):
         """ undo a boost """
         activity = {
             "type": "Undo",
diff --git a/bookwyrm/tests/views/test_interaction.py b/bookwyrm/tests/views/test_interaction.py
--- a/bookwyrm/tests/views/test_interaction.py
+++ b/bookwyrm/tests/views/test_interaction.py
@@ -1,4 +1,5 @@
 """ test for app action functionality """
+import json
 from unittest.mock import patch
 from django.test import TestCase
 from django.test.client import RequestFactory
@@ -39,7 +40,7 @@ def setUp(self):
             parent_work=work,
         )
 
-    def test_handle_favorite(self, _):
+    def test_favorite(self, _):
         """ create and broadcast faving a status """
         view = views.Favorite.as_view()
         request = self.factory.post("")
@@ -57,7 +58,7 @@ def test_handle_favorite(self, _):
         self.assertEqual(notification.user, self.local_user)
         self.assertEqual(notification.related_user, self.remote_user)
 
-    def test_handle_unfavorite(self, _):
+    def test_unfavorite(self, _):
         """ unfav a status """
         view = views.Unfavorite.as_view()
         request = self.factory.post("")
@@ -74,7 +75,7 @@ def test_handle_unfavorite(self, _):
         self.assertEqual(models.Favorite.objects.count(), 0)
         self.assertEqual(models.Notification.objects.count(), 0)
 
-    def test_handle_boost(self, _):
+    def test_boost(self, _):
         """ boost a status """
         view = views.Boost.as_view()
         request = self.factory.post("")
@@ -85,6 +86,7 @@ def test_handle_boost(self, _):
             view(request, status.id)
 
         boost = models.Boost.objects.get()
+
         self.assertEqual(boost.boosted_status, status)
         self.assertEqual(boost.user, self.remote_user)
         self.assertEqual(boost.privacy, "public")
@@ -95,7 +97,7 @@ def test_handle_boost(self, _):
         self.assertEqual(notification.related_user, self.remote_user)
         self.assertEqual(notification.related_status, status)
 
-    def test_handle_self_boost(self, _):
+    def test_self_boost(self, _):
         """ boost your own status """
         view = views.Boost.as_view()
         request = self.factory.post("")
@@ -103,7 +105,14 @@ def test_handle_self_boost(self, _):
         with patch("bookwyrm.activitystreams.ActivityStream.add_status"):
             status = models.Status.objects.create(user=self.local_user, content="hi")
 
-            view(request, status.id)
+            with patch(
+                "bookwyrm.models.activitypub_mixin.broadcast_task.delay"
+            ) as broadcast_mock:
+                view(request, status.id)
+
+        self.assertEqual(broadcast_mock.call_count, 1)
+        activity = json.loads(broadcast_mock.call_args[0][1])
+        self.assertEqual(activity["type"], "Announce")
 
         boost = models.Boost.objects.get()
         self.assertEqual(boost.boosted_status, status)
@@ -112,7 +121,7 @@ def test_handle_self_boost(self, _):
 
         self.assertFalse(models.Notification.objects.exists())
 
-    def test_handle_boost_unlisted(self, _):
+    def test_boost_unlisted(self, _):
         """ boost a status """
         view = views.Boost.as_view()
         request = self.factory.post("")
@@ -127,7 +136,7 @@ def test_handle_boost_unlisted(self, _):
         boost = models.Boost.objects.get()
         self.assertEqual(boost.privacy, "unlisted")
 
-    def test_handle_boost_private(self, _):
+    def test_boost_private(self, _):
         """ boost a status """
         view = views.Boost.as_view()
         request = self.factory.post("")
@@ -140,7 +149,7 @@ def test_handle_boost_private(self, _):
             view(request, status.id)
         self.assertFalse(models.Boost.objects.exists())
 
-    def test_handle_boost_twice(self, _):
+    def test_boost_twice(self, _):
         """ boost a status """
         view = views.Boost.as_view()
         request = self.factory.post("")
@@ -152,7 +161,7 @@ def test_handle_boost_twice(self, _):
             view(request, status.id)
         self.assertEqual(models.Boost.objects.count(), 1)
 
-    def test_handle_unboost(self, _):
+    def test_unboost(self, _):
         """ undo a boost """
         view = views.Unboost.as_view()
         request = self.factory.post("")