Repository: zestedesavoir/zds-site

Issue:
Impossible de mettre en bêta un contenu avec « Auteur externe » dans les auteurs
**Description du bug**

Lorsqu'on tente de mettre en bêta un contenu qui a un compte bot (par exemple, Auteur externe) dans les auteurs, une erreur interne est renvoyée. Cela est dû à une exception `NotReachableError` qui n'est pas gérée.

**Comment reproduire ?**

La liste des étapes qui permet de reproduire le bug :

1. Créer un contenu avec un compte bot comme auteur
2. Mettre en bêta ce contenu

**Comportement attendu**

Soit ignorer cette erreur, soit afficher un message dans lequel on demande à ce que « Auteur externe » soit enlevé des auteurs.




Discussion:
On peut ignorer l'erreur en ignorant l'auteur externe et s'inspirer de la PR #6356.
Et également faire le fix pour la route `contenus/enlever-auteur/{pk}/` qui soulève la même erreur lorsqu'on essaie d'enlever l'auteur externe d'un contenu (rapporté par Sentry).

Resolving patch:
diff --git a/zds/tutorialv2/views/authors.py b/zds/tutorialv2/views/authors.py
--- a/zds/tutorialv2/views/authors.py
+++ b/zds/tutorialv2/views/authors.py
@@ -1,7 +1,6 @@
-from django.conf import settings
 from django.contrib import messages
 from django.contrib.auth.models import User
-from django.shortcuts import get_object_or_404, redirect
+from django.shortcuts import redirect
 from django.template.loader import render_to_string
 from django.urls import reverse
 from django.utils.text import format_lazy
@@ -10,6 +9,7 @@
 from zds.gallery.models import UserGallery, GALLERY_WRITE
 from zds.member.decorator import LoggedWithReadWriteHability
 from zds.member.utils import get_bot_account
+from zds.mp.models import is_reachable
 from zds.tutorialv2 import signals
 
 from zds.tutorialv2.forms import AuthorForm, RemoveAuthorForm
@@ -127,7 +127,7 @@ def form_valid(self, form):
             if RemoveAuthorFromContent.remove_author(self.object, user):
                 if user.pk == self.request.user.pk:
                     current_user = True
-                else:
+                elif is_reachable(user):
                     send_mp(
                         bot,
                         [user],
@@ -152,9 +152,9 @@ def form_valid(self, form):
             else:  # if user is incorrect or alone
                 messages.error(
                     self.request,
-                    _(
-                        "Vous êtes le seul auteur de {} ou le membre sélectionné " "en a déjà quitté la rédaction."
-                    ).format(_type[0]),
+                    _("Vous êtes le seul auteur de {} ou le membre sélectionné en a déjà quitté la rédaction.").format(
+                        _type[0]
+                    ),
                 )
                 return redirect(self.object.get_absolute_url())
 
diff --git a/zds/tutorialv2/views/beta.py b/zds/tutorialv2/views/beta.py
--- a/zds/tutorialv2/views/beta.py
+++ b/zds/tutorialv2/views/beta.py
@@ -10,6 +10,7 @@
 from zds.forum.models import Topic, Forum, mark_read
 from zds.member.decorator import LoggedWithReadWriteHability
 from zds.member.utils import get_bot_account
+from zds.mp.models import filter_reachable
 from zds.notification.models import TopicAnswerSubscription
 from zds.tutorialv2 import signals
 from zds.tutorialv2.forms import BetaForm
@@ -139,9 +140,10 @@ def form_valid(self, form):
                         },
                     )
                     if not self.object.validation_private_message:
+                        recipients = filter_reachable(self.object.authors.all())
                         self.object.validation_private_message = send_mp(
                             bot,
-                            self.object.authors.all(),
+                            recipients,
                             self.object.validation_message_title,
                             beta_version.title,
                             msg_pm,


Test patch:
diff --git a/zds/tutorialv2/tests/tests_views/tests_addauthor.py b/zds/tutorialv2/tests/tests_views/tests_addauthor.py
new file mode 100644
--- /dev/null
+++ b/zds/tutorialv2/tests/tests_views/tests_addauthor.py
@@ -0,0 +1,70 @@
+from unittest.mock import patch
+
+from django.conf import settings
+from django.contrib.auth.models import Group
+from django.test import TestCase
+from django.urls import reverse
+
+from zds.gallery.models import UserGallery, GALLERY_WRITE
+from zds.gallery.tests.factories import UserGalleryFactory
+from zds.member.tests.factories import ProfileFactory, UserFactory
+from zds.tutorialv2.models.database import PublishableContent
+from zds.tutorialv2.tests import override_for_contents, TutorialTestMixin
+from zds.tutorialv2.tests.factories import PublishableContentFactory
+
+
+@override_for_contents()
+class AddAuthorTest(TutorialTestMixin, TestCase):
+    def setUp(self):
+        self.bot_group = Group(name=settings.ZDS_APP["member"]["bot_group"])
+        self.bot_group.save()
+
+        self.overridden_zds_app["member"]["bot_account"] = ProfileFactory().user.username
+
+        self.user_author = ProfileFactory().user
+        self.user_guest = ProfileFactory().user
+        self.external = UserFactory(username=self.overridden_zds_app["member"]["external_account"])
+        self.external.groups.add(self.bot_group)
+        self.external.save()
+
+        self.tuto = PublishableContentFactory(type="TUTORIAL", author_list=[self.user_author])
+        UserGalleryFactory(gallery=self.tuto.gallery, user=self.user_author, mode="W")
+
+        self.client.force_login(self.user_author)
+
+    @patch("zds.tutorialv2.signals.authors_management")
+    def test_nominal(self, authors_management):
+
+        result = self.client.post(
+            reverse("content:add-author", args=[self.tuto.pk]), {"username": self.user_guest.username}, follow=False
+        )
+
+        self.assertEqual(result.status_code, 302)
+        self.assertEqual(PublishableContent.objects.get(pk=self.tuto.pk).authors.count(), 2)
+        gallery = UserGallery.objects.filter(gallery=self.tuto.gallery, user=self.user_guest).first()
+        self.assertIsNotNone(gallery)
+        self.assertEqual(GALLERY_WRITE, gallery.mode)
+        self.assertEqual(authors_management.send.call_count, 1)
+        self.assertEqual(authors_management.send.call_args[1]["action"], "add")
+
+    @patch("zds.tutorialv2.signals.authors_management")
+    def test_not_existing_user(self, authors_management):
+
+        result = self.client.post(
+            reverse("content:add-author", args=[self.tuto.pk]), {"username": "unknown"}, follow=False
+        )
+
+        self.assertEqual(result.status_code, 302)
+        self.assertEqual(PublishableContent.objects.get(pk=self.tuto.pk).authors.count(), 1)
+        self.assertEqual(authors_management.send.call_count, 0)
+
+    @patch("zds.tutorialv2.signals.authors_management")
+    def test_bot(self, authors_management):
+
+        result = self.client.post(
+            reverse("content:add-author", args=[self.tuto.pk]), {"username": self.external.username}, follow=False
+        )
+
+        self.assertEqual(result.status_code, 302)
+        self.assertEqual(PublishableContent.objects.get(pk=self.tuto.pk).authors.count(), 1)
+        self.assertEqual(authors_management.send.call_count, 0)
diff --git a/zds/tutorialv2/tests/tests_views/tests_beta.py b/zds/tutorialv2/tests/tests_views/tests_beta.py
new file mode 100644
--- /dev/null
+++ b/zds/tutorialv2/tests/tests_views/tests_beta.py
@@ -0,0 +1,325 @@
+from unittest.mock import patch
+
+from django.contrib.auth.models import Group
+from django.urls import reverse
+from django.test import TestCase
+from django.utils.translation import gettext_lazy as _
+
+from zds.forum.tests.factories import ForumFactory, ForumCategoryFactory
+from zds.forum.models import Topic, Post, TopicRead
+from zds.gallery.tests.factories import UserGalleryFactory
+from zds.member.tests.factories import ProfileFactory, UserFactory
+from zds.mp.models import PrivateTopic
+from zds.notification.models import TopicAnswerSubscription
+from zds.tutorialv2.tests.factories import (
+    PublishableContentFactory,
+    ContainerFactory,
+    ExtractFactory,
+    HelpWritingFactory,
+)
+from zds.tutorialv2.models.database import PublishableContent
+from zds.tutorialv2.tests import TutorialTestMixin, override_for_contents
+from zds.utils.models import Tag
+
+
+@override_for_contents()
+class BetaTests(TutorialTestMixin, TestCase):
+    def setUp(self):
+        self.mas = ProfileFactory().user
+        self.overridden_zds_app["member"]["bot_account"] = self.mas.username
+
+        bot_group = Group(name=self.overridden_zds_app["member"]["bot_group"])
+        bot_group.save()
+
+        self.user_author = ProfileFactory().user
+        self.user_guest = ProfileFactory().user
+
+        self.external = UserFactory(username=self.overridden_zds_app["member"]["external_account"], password="anything")
+        self.external.groups.add(bot_group)
+
+        self.tuto = PublishableContentFactory(type="TUTORIAL")
+        self.tuto.authors.add(self.user_author)
+        UserGalleryFactory(gallery=self.tuto.gallery, user=self.user_author, mode="W")
+        self.tuto.save()
+
+        self.beta_forum = ForumFactory(
+            pk=self.overridden_zds_app["forum"]["beta_forum_id"],
+            category=ForumCategoryFactory(position=1),
+            position_in_category=1,
+        )  # ensure that the forum, for the beta versions, is created
+
+        self.tuto_draft = self.tuto.load_version()
+        self.part1 = ContainerFactory(parent=self.tuto_draft, db_object=self.tuto)
+        self.chapter1 = ContainerFactory(parent=self.part1, db_object=self.tuto)
+        self.extract1 = ExtractFactory(container=self.chapter1, db_object=self.tuto)
+
+    @patch("zds.tutorialv2.signals.beta_management")
+    def test_beta_workflow(self, beta_management):
+        """Test beta workflow (access and update)"""
+
+        # login with guest and test the non-access
+        self.client.force_login(self.user_guest)
+
+        result = self.client.get(reverse("content:view", args=[self.tuto.pk, self.tuto.slug]), follow=False)
+        self.assertEqual(result.status_code, 403)  # (get 403 since he is not part of the authors)
+
+        # login with author
+        self.client.force_login(self.user_author)
+        sometag = Tag(title="randomizeit")
+        sometag.save()
+        self.tuto.tags.add(sometag)
+        # create second author and add to tuto
+        second_author = ProfileFactory().user
+        self.tuto.authors.add(second_author)
+        self.tuto.save()
+
+        # activ beta:
+        tuto = PublishableContent.objects.get(pk=self.tuto.pk)
+        current_sha_beta = tuto.sha_draft
+        result = self.client.post(
+            reverse("content:set-beta", kwargs={"pk": tuto.pk, "slug": tuto.slug}),
+            {"version": current_sha_beta},
+            follow=False,
+        )
+        tuto = PublishableContent.objects.get(pk=self.tuto.pk)
+        self.assertEqual(result.status_code, 302)
+
+        # check if there is a pm corresponding to the tutorial in beta
+        self.assertEqual(Topic.objects.filter(forum=self.beta_forum).count(), 1)
+        self.assertTrue(PublishableContent.objects.get(pk=self.tuto.pk).beta_topic is not None)
+        self.assertEqual(PrivateTopic.objects.filter(author=self.user_author).count(), 1)
+        # check if there is a new topic
+        beta_topic = PublishableContent.objects.get(pk=self.tuto.pk).beta_topic
+        self.assertIsNotNone(TopicAnswerSubscription.objects.get_existing(self.user_author, beta_topic, is_active=True))
+        self.assertEqual(Post.objects.filter(topic=beta_topic).count(), 1)
+        self.assertEqual(beta_topic.tags.count(), 1)
+        self.assertEqual(beta_topic.tags.first().title, sometag.title)
+        # check signal is emitted
+        self.assertEqual(beta_management.send.call_count, 1)
+        self.assertEqual(beta_management.send.call_args[1]["action"], "activate")
+
+        # test if second author follow the topic
+        self.assertIsNotNone(TopicAnswerSubscription.objects.get_existing(second_author, beta_topic, is_active=True))
+        self.assertEqual(TopicRead.objects.filter(topic__pk=beta_topic.pk, user__pk=second_author.pk).count(), 1)
+
+        # test access for public
+        self.client.logout()
+
+        result = self.client.get(
+            reverse("content:view", args=[self.tuto.pk, self.tuto.slug]) + "?version=" + current_sha_beta, follow=False
+        )
+        self.assertEqual(result.status_code, 302)  # (get 302: no access to beta for public)
+
+        # test access for guest;
+        self.client.force_login(self.user_guest)
+
+        # get 200 everywhere :)
+        result = self.client.get(
+            reverse("content:view", args=[tuto.pk, tuto.slug]) + "?version=" + current_sha_beta, follow=False
+        )
+        self.assertEqual(result.status_code, 200)
+
+        result = self.client.get(
+            reverse(
+                "content:view-container", kwargs={"pk": tuto.pk, "slug": tuto.slug, "container_slug": self.part1.slug}
+            )
+            + "?version="
+            + current_sha_beta,
+            follow=False,
+        )
+        self.assertEqual(result.status_code, 200)
+
+        result = self.client.get(
+            reverse(
+                "content:view-container",
+                kwargs={
+                    "pk": tuto.pk,
+                    "slug": tuto.slug,
+                    "parent_container_slug": self.part1.slug,
+                    "container_slug": self.chapter1.slug,
+                },
+            )
+            + "?version="
+            + current_sha_beta,
+            follow=False,
+        )
+        self.assertEqual(result.status_code, 200)
+
+        # change beta version
+        self.client.logout()
+        self.client.force_login(self.user_author)
+
+        result = self.client.post(
+            reverse(
+                "content:edit-container", kwargs={"pk": tuto.pk, "slug": tuto.slug, "container_slug": self.part1.slug}
+            ),
+            {
+                "title": "Un autre titre",
+                "introduction": "Introduire la chose",
+                "conclusion": "Et terminer sur un truc bien",
+                "last_hash": self.part1.compute_hash(),
+            },
+            follow=False,
+        )
+        self.assertEqual(result.status_code, 302)
+
+        tuto = PublishableContent.objects.get(pk=tuto.pk)
+        self.assertNotEqual(current_sha_beta, tuto.sha_draft)
+
+        # add third author:
+        third_author = ProfileFactory().user
+        tuto.authors.add(third_author)
+        tuto.save()
+
+        self.assertIsNone(TopicAnswerSubscription.objects.get_existing(third_author, beta_topic, is_active=True))
+        self.assertEqual(TopicRead.objects.filter(topic__pk=beta_topic.pk, user__pk=third_author.pk).count(), 0)
+
+        # change beta:
+        old_sha_beta = current_sha_beta
+        current_sha_beta = tuto.sha_draft
+        result = self.client.post(
+            reverse("content:set-beta", kwargs={"pk": tuto.pk, "slug": tuto.slug}),
+            {"version": current_sha_beta},
+            follow=False,
+        )
+        self.assertEqual(result.status_code, 302)
+
+        tuto = PublishableContent.objects.get(pk=tuto.pk)
+        self.assertEqual(tuto.sha_beta, current_sha_beta)
+
+        # No new message added since the last time, because the last message
+        # was posted since less than 15 minutes ago.
+
+        self.assertEqual(Post.objects.filter(topic=beta_topic).count(), 1)
+
+        # test if third author follow the topic
+        self.assertIsNotNone(TopicAnswerSubscription.objects.get_existing(third_author, beta_topic, is_active=True))
+        self.assertEqual(TopicRead.objects.filter(topic__pk=beta_topic.pk, user__pk=third_author.pk).count(), 1)
+
+        # then test for guest
+        self.client.logout()
+        self.client.force_login(self.user_guest)
+
+        result = self.client.get(
+            reverse("content:view", args=[tuto.pk, tuto.slug]) + "?version=" + old_sha_beta, follow=False
+        )
+        self.assertEqual(result.status_code, 403)  # no access using the old version
+
+        result = self.client.get(
+            reverse("content:view", args=[tuto.pk, tuto.slug]) + "?version=" + current_sha_beta, follow=False
+        )
+        self.assertEqual(result.status_code, 200)  # ok for the new version
+
+        # inactive beta
+        self.client.logout()
+        self.client.force_login(self.user_author)
+
+        result = self.client.post(
+            reverse("content:inactive-beta", kwargs={"pk": tuto.pk, "slug": tuto.slug}),
+            {"version": current_sha_beta},
+            follow=False,
+        )
+        self.assertEqual(result.status_code, 302)
+
+        self.assertEqual(Post.objects.filter(topic=beta_topic).count(), 2)  # a new message was added !
+        self.assertTrue(Topic.objects.get(pk=beta_topic.pk).is_locked)  # beta was inactived, so topic is locked !
+        # check signal is emitted
+        self.assertEqual(beta_management.send.call_count, 3)
+        self.assertEqual(beta_management.send.call_args[1]["action"], "deactivate")
+
+        # then test for guest
+        self.client.logout()
+        self.client.force_login(self.user_guest)
+
+        result = self.client.get(
+            reverse("content:view", args=[tuto.pk, tuto.slug]) + "?version=" + current_sha_beta, follow=False
+        )
+        self.assertEqual(result.status_code, 403)  # no access anymore
+
+        # reactive beta
+        self.client.logout()
+        self.client.force_login(self.user_author)
+
+        result = self.client.post(
+            reverse("content:set-beta", kwargs={"pk": tuto.pk, "slug": tuto.slug}),
+            {"version": old_sha_beta},  # with a different version than the draft one
+            follow=False,
+        )
+        self.assertEqual(result.status_code, 302)
+
+        tuto = PublishableContent.objects.get(pk=tuto.pk)
+        self.assertEqual(tuto.sha_beta, old_sha_beta)
+
+        # No new message added since the last time, because the last message
+        # was posted since less than 15 minutes ago.
+
+        self.assertEqual(Post.objects.filter(topic=beta_topic).count(), 3)  # a new message was added !
+        self.assertFalse(Topic.objects.get(pk=beta_topic.pk).is_locked)  # not locked anymore
+
+        # then test for guest
+        self.client.logout()
+        self.client.force_login(self.user_guest)
+
+        result = self.client.get(
+            reverse("content:view", args=[tuto.pk, tuto.slug]) + "?version=" + tuto.sha_draft, follow=False
+        )
+        self.assertEqual(result.status_code, 403)  # no access on the non-beta version (of course)
+
+        result = self.client.get(
+            reverse("content:view", args=[tuto.pk, tuto.slug]) + "?version=" + old_sha_beta, follow=False
+        )
+        self.assertEqual(result.status_code, 200)  # access granted
+
+    def test_beta_helps(self):
+        """Check that editorial helps are visible on the beta"""
+
+        # login with author
+        self.client.force_login(self.user_author)
+
+        # create and add help
+        help = HelpWritingFactory()
+        help.save()
+
+        tuto = PublishableContent.objects.get(pk=self.tuto.pk)
+        tuto.helps.add(help)
+        tuto.save()
+
+        # activate beta
+        result = self.client.post(
+            reverse("content:set-beta", kwargs={"pk": tuto.pk, "slug": tuto.slug}),
+            {"version": tuto.sha_draft},
+            follow=False,
+        )
+        self.assertEqual(result.status_code, 302)
+
+        # check that the information is displayed on the beta page
+        result = self.client.get(reverse("content:beta-view", args=[tuto.pk, tuto.slug]), follow=False)
+        self.assertEqual(result.status_code, 200)
+        self.assertContains(result, _("L’auteur de ce contenu recherche"))
+        # and on a container
+        result = self.client.get(
+            reverse(
+                "content:beta-view-container",
+                kwargs={"pk": tuto.pk, "slug": tuto.slug, "container_slug": self.part1.slug},
+            ),
+            follow=False,
+        )
+        self.assertEqual(result.status_code, 200)
+        self.assertContains(result, _("L’auteur de ce contenu recherche"))
+
+    def test_beta_external(self):
+        """
+        Activating the beta shall work even with a bot in the authors.
+        Check a non-regression on a bug where exceptions were raised when activating
+        the beta with "Auteur externe" among the authors.
+        """
+        self.client.force_login(self.user_author)
+
+        self.tuto.authors.add(self.external)
+
+        result = self.client.post(
+            reverse("content:set-beta", kwargs={"pk": self.tuto.pk, "slug": self.tuto.slug}),
+            {"version": self.tuto.sha_draft},
+            follow=False,
+        )
+        self.assertEqual(result.status_code, 302)
diff --git a/zds/tutorialv2/tests/tests_views/tests_content.py b/zds/tutorialv2/tests/tests_views/tests_content.py
--- a/zds/tutorialv2/tests/tests_views/tests_content.py
+++ b/zds/tutorialv2/tests/tests_views/tests_content.py
@@ -13,17 +13,15 @@
 from django.http import HttpResponseNotAllowed
 from django.urls import reverse
 from django.test import TestCase
-from django.utils.translation import gettext_lazy as _
 
 from zds.forum.tests.factories import ForumFactory, ForumCategoryFactory
 from zds.forum.models import Topic, Post, TopicRead
 from zds.gallery.tests.factories import UserGalleryFactory
-from zds.gallery.models import GALLERY_WRITE, UserGallery, Gallery
+from zds.gallery.models import UserGallery, Gallery
 from zds.gallery.models import Image
 from zds.member.tests.factories import ProfileFactory, StaffProfileFactory, UserFactory
 from zds.mp.models import PrivateTopic, PrivatePost
 from zds.notification.models import (
-    TopicAnswerSubscription,
     ContentReactionAnswerSubscription,
     NewPublicationSubscription,
     Notification,
@@ -49,7 +47,6 @@
     ZMarkdownEpubPublicator,
 )
 from zds.tutorialv2.tests import TutorialTestMixin, override_for_contents
-from zds.utils.models import Tag
 from zds.tutorialv2.models.help_requests import HelpWriting
 from zds.utils.tests.factories import SubCategoryFactory, LicenceFactory
 from zds import json_handler
@@ -630,260 +627,6 @@ def test_basic_tutorial_workflow(self):
         beta_topic = Topic.objects.get(pk=beta_topic.pk)
         self.assertTrue(beta_topic.is_locked)
 
-    @patch("zds.tutorialv2.signals.beta_management")
-    def test_beta_workflow(self, beta_management):
-        """Test beta workflow (access and update)"""
-
-        # login with guest and test the non-access
-        self.client.force_login(self.user_guest)
-
-        result = self.client.get(reverse("content:view", args=[self.tuto.pk, self.tuto.slug]), follow=False)
-        self.assertEqual(result.status_code, 403)  # (get 403 since he is not part of the authors)
-
-        # login with author
-        self.client.force_login(self.user_author)
-        sometag = Tag(title="randomizeit")
-        sometag.save()
-        self.tuto.tags.add(sometag)
-        # create second author and add to tuto
-        second_author = ProfileFactory().user
-        self.tuto.authors.add(second_author)
-        self.tuto.save()
-
-        # activ beta:
-        tuto = PublishableContent.objects.get(pk=self.tuto.pk)
-        current_sha_beta = tuto.sha_draft
-        result = self.client.post(
-            reverse("content:set-beta", kwargs={"pk": tuto.pk, "slug": tuto.slug}),
-            {"version": current_sha_beta},
-            follow=False,
-        )
-        tuto = PublishableContent.objects.get(pk=self.tuto.pk)
-        self.assertEqual(result.status_code, 302)
-
-        # check if there is a pm corresponding to the tutorial in beta
-        self.assertEqual(Topic.objects.filter(forum=self.beta_forum).count(), 1)
-        self.assertTrue(PublishableContent.objects.get(pk=self.tuto.pk).beta_topic is not None)
-        self.assertEqual(PrivateTopic.objects.filter(author=self.user_author).count(), 1)
-        # check if there is a new topic
-        beta_topic = PublishableContent.objects.get(pk=self.tuto.pk).beta_topic
-        self.assertIsNotNone(TopicAnswerSubscription.objects.get_existing(self.user_author, beta_topic, is_active=True))
-        self.assertEqual(Post.objects.filter(topic=beta_topic).count(), 1)
-        self.assertEqual(beta_topic.tags.count(), 1)
-        self.assertEqual(beta_topic.tags.first().title, sometag.title)
-        # check signal is emitted
-        self.assertEqual(beta_management.send.call_count, 1)
-        self.assertEqual(beta_management.send.call_args[1]["action"], "activate")
-
-        # test if second author follow the topic
-        self.assertIsNotNone(TopicAnswerSubscription.objects.get_existing(second_author, beta_topic, is_active=True))
-        self.assertEqual(TopicRead.objects.filter(topic__pk=beta_topic.pk, user__pk=second_author.pk).count(), 1)
-
-        # test access for public
-        self.client.logout()
-
-        result = self.client.get(
-            reverse("content:view", args=[self.tuto.pk, self.tuto.slug]) + "?version=" + current_sha_beta, follow=False
-        )
-        self.assertEqual(result.status_code, 302)  # (get 302: no access to beta for public)
-
-        # test access for guest;
-        self.client.force_login(self.user_guest)
-
-        # get 200 everywhere :)
-        result = self.client.get(
-            reverse("content:view", args=[tuto.pk, tuto.slug]) + "?version=" + current_sha_beta, follow=False
-        )
-        self.assertEqual(result.status_code, 200)
-
-        result = self.client.get(
-            reverse(
-                "content:view-container", kwargs={"pk": tuto.pk, "slug": tuto.slug, "container_slug": self.part1.slug}
-            )
-            + "?version="
-            + current_sha_beta,
-            follow=False,
-        )
-        self.assertEqual(result.status_code, 200)
-
-        result = self.client.get(
-            reverse(
-                "content:view-container",
-                kwargs={
-                    "pk": tuto.pk,
-                    "slug": tuto.slug,
-                    "parent_container_slug": self.part1.slug,
-                    "container_slug": self.chapter1.slug,
-                },
-            )
-            + "?version="
-            + current_sha_beta,
-            follow=False,
-        )
-        self.assertEqual(result.status_code, 200)
-
-        # change beta version
-        self.client.logout()
-        self.client.force_login(self.user_author)
-
-        result = self.client.post(
-            reverse(
-                "content:edit-container", kwargs={"pk": tuto.pk, "slug": tuto.slug, "container_slug": self.part1.slug}
-            ),
-            {
-                "title": "Un autre titre",
-                "introduction": "Introduire la chose",
-                "conclusion": "Et terminer sur un truc bien",
-                "last_hash": self.part1.compute_hash(),
-            },
-            follow=False,
-        )
-        self.assertEqual(result.status_code, 302)
-
-        tuto = PublishableContent.objects.get(pk=tuto.pk)
-        self.assertNotEqual(current_sha_beta, tuto.sha_draft)
-
-        # add third author:
-        third_author = ProfileFactory().user
-        tuto.authors.add(third_author)
-        tuto.save()
-
-        self.assertIsNone(TopicAnswerSubscription.objects.get_existing(third_author, beta_topic, is_active=True))
-        self.assertEqual(TopicRead.objects.filter(topic__pk=beta_topic.pk, user__pk=third_author.pk).count(), 0)
-
-        # change beta:
-        old_sha_beta = current_sha_beta
-        current_sha_beta = tuto.sha_draft
-        result = self.client.post(
-            reverse("content:set-beta", kwargs={"pk": tuto.pk, "slug": tuto.slug}),
-            {"version": current_sha_beta},
-            follow=False,
-        )
-        self.assertEqual(result.status_code, 302)
-
-        tuto = PublishableContent.objects.get(pk=tuto.pk)
-        self.assertEqual(tuto.sha_beta, current_sha_beta)
-
-        # No new message added since the last time, because the last message
-        # was posted since less than 15 minutes ago.
-
-        self.assertEqual(Post.objects.filter(topic=beta_topic).count(), 1)
-
-        # test if third author follow the topic
-        self.assertIsNotNone(TopicAnswerSubscription.objects.get_existing(third_author, beta_topic, is_active=True))
-        self.assertEqual(TopicRead.objects.filter(topic__pk=beta_topic.pk, user__pk=third_author.pk).count(), 1)
-
-        # then test for guest
-        self.client.logout()
-        self.client.force_login(self.user_guest)
-
-        result = self.client.get(
-            reverse("content:view", args=[tuto.pk, tuto.slug]) + "?version=" + old_sha_beta, follow=False
-        )
-        self.assertEqual(result.status_code, 403)  # no access using the old version
-
-        result = self.client.get(
-            reverse("content:view", args=[tuto.pk, tuto.slug]) + "?version=" + current_sha_beta, follow=False
-        )
-        self.assertEqual(result.status_code, 200)  # ok for the new version
-
-        # inactive beta
-        self.client.logout()
-        self.client.force_login(self.user_author)
-
-        result = self.client.post(
-            reverse("content:inactive-beta", kwargs={"pk": tuto.pk, "slug": tuto.slug}),
-            {"version": current_sha_beta},
-            follow=False,
-        )
-        self.assertEqual(result.status_code, 302)
-
-        self.assertEqual(Post.objects.filter(topic=beta_topic).count(), 2)  # a new message was added !
-        self.assertTrue(Topic.objects.get(pk=beta_topic.pk).is_locked)  # beta was inactived, so topic is locked !
-        # check signal is emitted
-        self.assertEqual(beta_management.send.call_count, 3)
-        self.assertEqual(beta_management.send.call_args[1]["action"], "deactivate")
-
-        # then test for guest
-        self.client.logout()
-        self.client.force_login(self.user_guest)
-
-        result = self.client.get(
-            reverse("content:view", args=[tuto.pk, tuto.slug]) + "?version=" + current_sha_beta, follow=False
-        )
-        self.assertEqual(result.status_code, 403)  # no access anymore
-
-        # reactive beta
-        self.client.logout()
-        self.client.force_login(self.user_author)
-
-        result = self.client.post(
-            reverse("content:set-beta", kwargs={"pk": tuto.pk, "slug": tuto.slug}),
-            {"version": old_sha_beta},  # with a different version than the draft one
-            follow=False,
-        )
-        self.assertEqual(result.status_code, 302)
-
-        tuto = PublishableContent.objects.get(pk=tuto.pk)
-        self.assertEqual(tuto.sha_beta, old_sha_beta)
-
-        # No new message added since the last time, because the last message
-        # was posted since less than 15 minutes ago.
-
-        self.assertEqual(Post.objects.filter(topic=beta_topic).count(), 3)  # a new message was added !
-        self.assertFalse(Topic.objects.get(pk=beta_topic.pk).is_locked)  # not locked anymore
-
-        # then test for guest
-        self.client.logout()
-        self.client.force_login(self.user_guest)
-
-        result = self.client.get(
-            reverse("content:view", args=[tuto.pk, tuto.slug]) + "?version=" + tuto.sha_draft, follow=False
-        )
-        self.assertEqual(result.status_code, 403)  # no access on the non-beta version (of course)
-
-        result = self.client.get(
-            reverse("content:view", args=[tuto.pk, tuto.slug]) + "?version=" + old_sha_beta, follow=False
-        )
-        self.assertEqual(result.status_code, 200)  # access granted
-
-    def test_beta_helps(self):
-        """Check that editorial helps are visible on the beta"""
-
-        # login with author
-        self.client.force_login(self.user_author)
-
-        # create and add help
-        help = HelpWritingFactory()
-        help.save()
-
-        tuto = PublishableContent.objects.get(pk=self.tuto.pk)
-        tuto.helps.add(help)
-        tuto.save()
-
-        # activate beta
-        result = self.client.post(
-            reverse("content:set-beta", kwargs={"pk": tuto.pk, "slug": tuto.slug}),
-            {"version": tuto.sha_draft},
-            follow=False,
-        )
-        self.assertEqual(result.status_code, 302)
-
-        # check that the information is displayed on the beta page
-        result = self.client.get(reverse("content:beta-view", args=[tuto.pk, tuto.slug]), follow=False)
-        self.assertEqual(result.status_code, 200)
-        self.assertContains(result, _("L’auteur de ce contenu recherche"))
-        # and on a container
-        result = self.client.get(
-            reverse(
-                "content:beta-view-container",
-                kwargs={"pk": tuto.pk, "slug": tuto.slug, "container_slug": self.part1.slug},
-            ),
-            follow=False,
-        )
-        self.assertEqual(result.status_code, 200)
-        self.assertContains(result, _("L’auteur de ce contenu recherche"))
-
     def test_history_navigation(self):
         """ensure that, if the title (and so the slug) of the content change, its content remain accessible"""
         # login with author
@@ -2698,72 +2441,6 @@ def test_help_tutorials_are_sorted_by_update_date(self):
         self.assertEqual(contents[0], tutoriel_1)
         self.assertEqual(contents[1], tutoriel_2)
 
-    @patch("zds.tutorialv2.signals.authors_management")
-    def test_add_author(self, authors_management):
-        self.client.force_login(self.user_author)
-        result = self.client.post(
-            reverse("content:add-author", args=[self.tuto.pk]), {"username": self.user_guest.username}, follow=False
-        )
-        self.assertEqual(result.status_code, 302)
-        self.assertEqual(PublishableContent.objects.get(pk=self.tuto.pk).authors.count(), 2)
-        gallery = UserGallery.objects.filter(gallery=self.tuto.gallery, user=self.user_guest).first()
-        self.assertIsNotNone(gallery)
-        self.assertEqual(GALLERY_WRITE, gallery.mode)
-        self.assertEqual(authors_management.send.call_count, 1)
-        self.assertEqual(authors_management.send.call_args[1]["action"], "add")
-        # add unexisting user
-        result = self.client.post(
-            reverse("content:add-author", args=[self.tuto.pk]), {"username": "unknown"}, follow=False
-        )
-        self.assertEqual(result.status_code, 302)
-        self.assertEqual(PublishableContent.objects.get(pk=self.tuto.pk).authors.count(), 2)
-        self.assertEqual(authors_management.send.call_count, 1)
-
-    @patch("zds.tutorialv2.signals.authors_management")
-    def test_remove_author(self, authors_management):
-        self.client.force_login(self.user_author)
-        tuto = PublishableContentFactory(author_list=[self.user_author, self.user_guest])
-        result = self.client.post(
-            reverse("content:remove-author", args=[tuto.pk]), {"username": self.user_guest.username}, follow=False
-        )
-        self.assertEqual(result.status_code, 302)
-        self.assertEqual(PublishableContent.objects.get(pk=tuto.pk).authors.count(), 1)
-        self.assertEqual(authors_management.send.call_count, 1)
-        self.assertEqual(authors_management.send.call_args[1]["action"], "remove")
-
-        self.assertIsNone(UserGallery.objects.filter(gallery=self.tuto.gallery, user=self.user_guest).first())
-        # remove unexisting user
-        result = self.client.post(
-            reverse("content:remove-author", args=[tuto.pk]), {"username": "unknown"}, follow=False
-        )
-        self.assertEqual(result.status_code, 302)
-        self.assertEqual(PublishableContent.objects.get(pk=tuto.pk).authors.count(), 1)
-        self.assertEqual(authors_management.send.call_count, 1)
-
-        # remove last author must lead to no change
-        result = self.client.post(
-            reverse("content:remove-author", args=[tuto.pk]), {"username": self.user_author.username}, follow=False
-        )
-        self.assertEqual(result.status_code, 302)
-        self.assertEqual(PublishableContent.objects.get(pk=tuto.pk).authors.count(), 1)
-        self.assertEqual(authors_management.send.call_count, 1)
-
-        # re-add guest
-        result = self.client.post(
-            reverse("content:add-author", args=[tuto.pk]), {"username": self.user_guest.username}, follow=False
-        )
-        self.assertEqual(result.status_code, 302)
-        self.assertEqual(PublishableContent.objects.get(pk=tuto.pk).authors.count(), 2)
-
-        # then, make `user_author` remove himself
-        result = self.client.post(
-            reverse("content:remove-author", args=[tuto.pk]), {"username": self.user_author.username}, follow=False
-        )
-        self.assertEqual(result.status_code, 302)
-        self.assertTrue(reverse("tutorial:find-tutorial", args=[self.user_author.username]) in result.url)
-        self.assertEqual(PublishableContent.objects.get(pk=tuto.pk).authors.count(), 1)
-        self.assertEqual(PublishableContent.objects.get(pk=tuto.pk).authors.filter(pk=self.user_author.pk).count(), 0)
-
     def test_warn_typo(self):
         """
         Add a non-regression test about warning the author(s) of a typo in tutorial
diff --git a/zds/tutorialv2/tests/tests_views/tests_removeauthor.py b/zds/tutorialv2/tests/tests_views/tests_removeauthor.py
new file mode 100644
--- /dev/null
+++ b/zds/tutorialv2/tests/tests_views/tests_removeauthor.py
@@ -0,0 +1,105 @@
+from unittest.mock import patch
+
+from django.conf import settings
+from django.contrib.auth.models import Group
+from django.test import TestCase
+from django.urls import reverse
+
+from zds.gallery.models import UserGallery
+from zds.member.tests.factories import ProfileFactory
+from zds.tutorialv2.models.database import PublishableContent
+from zds.tutorialv2.tests import override_for_contents, TutorialTestMixin
+from zds.tutorialv2.tests.factories import PublishableContentFactory
+
+
+@override_for_contents()
+class RemoveAuthor(TutorialTestMixin, TestCase):
+    def setUp(self):
+        self.bot_group = Group(name=settings.ZDS_APP["member"]["bot_group"])
+        self.bot_group.save()
+
+        self.overridden_zds_app["member"]["bot_account"] = ProfileFactory().user.username
+
+        self.author = ProfileFactory().user
+        self.other_author = ProfileFactory().user
+
+        self.external = ProfileFactory().user
+        self.external.username = self.overridden_zds_app["member"]["external_account"]
+        self.external.save()
+        self.external.groups.add(self.bot_group)
+
+        self.tuto = PublishableContentFactory(type="TUTORIAL", author_list=[self.author, self.other_author])
+
+        self.client.force_login(self.author)
+
+    @patch("zds.tutorialv2.signals.authors_management")
+    def test_nominal(self, authors_management):
+        """Removing a member effectively being an author."""
+        result = self.client.post(
+            reverse("content:remove-author", args=[self.tuto.pk]),
+            {"username": self.other_author.username},
+            follow=False,
+        )
+        self.assertEqual(result.status_code, 302)
+        self.assertEqual(PublishableContent.objects.get(pk=self.tuto.pk).authors.count(), 1)
+        self.assertEqual(authors_management.send.call_count, 1)
+        self.assertEqual(authors_management.send.call_args[1]["action"], "remove")
+
+        self.assertIsNone(UserGallery.objects.filter(gallery=self.tuto.gallery, user=self.other_author).first())
+
+    @patch("zds.tutorialv2.signals.authors_management")
+    def test_not_existing(self, authors_management):
+        """Attempting to remove a user who is not author shall do nothing."""
+        result = self.client.post(
+            reverse("content:remove-author", args=[self.tuto.pk]), {"username": "unknown"}, follow=False
+        )
+        self.assertEqual(result.status_code, 302)
+        self.assertEqual(PublishableContent.objects.get(pk=self.tuto.pk).authors.count(), 2)
+        self.assertEqual(authors_management.send.call_count, 0)
+
+    @patch("zds.tutorialv2.signals.authors_management")
+    def test_last_remaining(self, authors_management):
+        """Attempting to remove the last author shall lead to no change."""
+        self.tuto.authors.remove(self.other_author)
+        self.assertEqual(PublishableContent.objects.get(pk=self.tuto.pk).authors.count(), 1)
+
+        result = self.client.post(
+            reverse("content:remove-author", args=[self.tuto.pk]), {"username": self.author.username}, follow=False
+        )
+        self.assertEqual(result.status_code, 302)
+        self.assertEqual(PublishableContent.objects.get(pk=self.tuto.pk).authors.count(), 1)
+        self.assertEqual(authors_management.send.call_count, 0)
+
+    @patch("zds.tutorialv2.signals.authors_management")
+    def test_self(self, authors_management):
+        """An author removes themselves."""
+        result = self.client.post(
+            reverse("content:remove-author", args=[self.tuto.pk]), {"username": self.author.username}, follow=False
+        )
+
+        self.assertEqual(result.status_code, 302)
+        tuto = PublishableContent.objects.get(pk=self.tuto.pk)
+        self.assertEqual(tuto.authors.count(), 1)
+        self.assertFalse(tuto.authors.filter(pk=self.author.pk).exists())
+        self.assertEqual(authors_management.send.call_count, 1)
+        self.assertEqual(authors_management.send.call_args[1]["action"], "remove")
+
+    @patch("zds.tutorialv2.signals.authors_management")
+    def test_bot(self, authors_management):
+        """
+        Bots can be removed from the authors.
+        Check a non-regression on a bug where exceptions were raised when removing "Auteur externe" from the authors.
+        """
+        self.tuto.authors.add(self.external)
+        self.assertEqual(PublishableContent.objects.get(pk=self.tuto.pk).authors.count(), 3)
+
+        result = self.client.post(
+            reverse("content:remove-author", args=[self.tuto.pk]), {"username": self.external.username}, follow=False
+        )
+
+        self.assertEqual(result.status_code, 302)
+        tuto = PublishableContent.objects.get(pk=self.tuto.pk)
+        self.assertEqual(tuto.authors.count(), 2)
+        self.assertFalse(tuto.authors.filter(pk=self.external.pk).exists())
+        self.assertEqual(authors_management.send.call_count, 1)
+        self.assertEqual(authors_management.send.call_args[1]["action"], "remove")