From 7c9287e3872938c36b921aad1f5de86fd806790c Mon Sep 17 00:00:00 2001
From: Yohann D'ANELLO <yohann.danello@gmail.com>
Date: Tue, 1 Sep 2020 15:54:56 +0200
Subject: [PATCH] Test and cover note app

---
 apps/note/admin.py                            |  30 +-
 apps/note/api/serializers.py                  |  11 +-
 apps/note/api/views.py                        |  15 +-
 apps/note/signals.py                          |   6 +-
 apps/note/tests/test_transactions.py          | 365 ++++++++++++++++++
 apps/note/views.py                            |   5 +-
 .../tests/test_permission_denied.py           |   6 +
 7 files changed, 407 insertions(+), 31 deletions(-)
 create mode 100644 apps/note/tests/test_transactions.py

diff --git a/apps/note/admin.py b/apps/note/admin.py
index 433ef2dc..eee49feb 100644
--- a/apps/note/admin.py
+++ b/apps/note/admin.py
@@ -119,10 +119,6 @@ class TransactionAdmin(PolymorphicParentModelAdmin):
     list_display = ('created_at', 'poly_source', 'poly_destination',
                     'quantity', 'amount', 'valid')
     list_filter = ('valid',)
-    readonly_fields = (
-        'source',
-        'destination',
-    )
 
     def poly_source(self, obj):
         """
@@ -145,10 +141,7 @@ class TransactionAdmin(PolymorphicParentModelAdmin):
         Only valid can be edited after creation
         Else the amount of money would not be transferred
         """
-        if obj:  # user is editing an existing object
-            return 'created_at', 'source', 'destination', 'quantity', \
-                   'amount'
-        return []
+        return 'created_at', 'source', 'destination', 'quantity', 'amount' if obj else ()
 
 
 @admin.register(MembershipTransaction, site=admin_site)
@@ -157,6 +150,13 @@ class MembershipTransactionAdmin(PolymorphicChildModelAdmin):
     Admin customisation for MembershipTransaction
     """
 
+    def get_readonly_fields(self, request, obj=None):
+        """
+        Only valid can be edited after creation
+        Else the amount of money would not be transferred
+        """
+        return ('created_at', 'source', 'destination', 'quantity', 'amount') if obj else ()
+
 
 @admin.register(RecurrentTransaction, site=admin_site)
 class RecurrentTransactionAdmin(PolymorphicChildModelAdmin):
@@ -164,6 +164,13 @@ class RecurrentTransactionAdmin(PolymorphicChildModelAdmin):
     Admin customisation for RecurrentTransaction
     """
 
+    def get_readonly_fields(self, request, obj=None):
+        """
+        Only valid can be edited after creation
+        Else the amount of money would not be transferred
+        """
+        return ('created_at', 'source', 'destination', 'quantity', 'amount') if obj else ()
+
 
 @admin.register(SpecialTransaction, site=admin_site)
 class SpecialTransactionAdmin(PolymorphicChildModelAdmin):
@@ -171,6 +178,13 @@ class SpecialTransactionAdmin(PolymorphicChildModelAdmin):
     Admin customisation for SpecialTransaction
     """
 
+    def get_readonly_fields(self, request, obj=None):
+        """
+        Only valid can be edited after creation
+        Else the amount of money would not be transferred
+        """
+        return ('created_at', 'source', 'destination', 'quantity', 'amount') if obj else ()
+
 
 @admin.register(TransactionTemplate, site=admin_site)
 class TransactionTemplateAdmin(admin.ModelAdmin):
diff --git a/apps/note/api/serializers.py b/apps/note/api/serializers.py
index d6572c62..a9c2a107 100644
--- a/apps/note/api/serializers.py
+++ b/apps/note/api/serializers.py
@@ -1,6 +1,7 @@
 # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
 # SPDX-License-Identifier: GPL-3.0-or-later
 
+from django.conf import settings
 from django.utils.translation import gettext_lazy as _
 from rest_framework import serializers
 from rest_framework.exceptions import ValidationError
@@ -124,9 +125,9 @@ class ConsumerSerializer(serializers.ModelSerializer):
         Display information about the associated note
         """
         # If the user has no right to see the note, then we only display the note identifier
-        if PermissionBackend.check_perm(get_current_authenticated_user(), "note.view_note", obj.note):
-            return NotePolymorphicSerializer().to_representation(obj.note)
-        return dict(id=obj.note.id, name=str(obj.note))
+        return NotePolymorphicSerializer().to_representation(obj.note)\
+            if PermissionBackend.check_perm(get_current_authenticated_user(), "note.view_note", obj.note)\
+            else dict(id=obj.note.id, name=str(obj.note))
 
     def get_email_confirmed(self, obj):
         if isinstance(obj.note, NoteUser):
@@ -231,12 +232,10 @@ class TransactionPolymorphicSerializer(PolymorphicSerializer):
         SpecialTransaction: SpecialTransactionSerializer,
     }
 
-    try:
+    if "activity" in settings.INSTALLED_APPS:
         from activity.models import GuestTransaction
         from activity.api.serializers import GuestTransactionSerializer
         model_serializer_mapping[GuestTransaction] = GuestTransactionSerializer
-    except ImportError:  # Activity app is not loaded
-        pass
 
     def validate(self, attrs):
         resource_type = attrs.pop(self.resource_type_field_name)
diff --git a/apps/note/api/views.py b/apps/note/api/views.py
index 488e0d07..9b213025 100644
--- a/apps/note/api/views.py
+++ b/apps/note/api/views.py
@@ -45,7 +45,7 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet):
             | Q(alias__normalized_name__iregex="^" + alias.lower())
         )
 
-        return queryset
+        return queryset.order_by("id")
 
 
 class AliasViewSet(ReadProtectedModelViewSet):
@@ -72,7 +72,6 @@ class AliasViewSet(ReadProtectedModelViewSet):
         try:
             self.perform_destroy(instance)
         except ValidationError as e:
-            print(e)
             return Response({e.code: e.message}, status.HTTP_400_BAD_REQUEST)
         return Response(status=status.HTTP_204_NO_CONTENT)
 
@@ -101,7 +100,7 @@ class AliasViewSet(ReadProtectedModelViewSet):
                 ),
                 all=True)
 
-        return queryset
+        return queryset.order_by("name")
 
 
 class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
@@ -120,7 +119,7 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
         queryset = super().get_queryset()
 
         alias = self.request.query_params.get("alias", ".*")
-        queryset = queryset.order_by('name').prefetch_related('note')
+        queryset = queryset.prefetch_related('note')
         # We match first an alias if it is matched without normalization,
         # then if the normalized pattern matches a normalized alias.
         queryset = queryset.filter(
@@ -138,7 +137,7 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
             ),
             all=True)
 
-        return queryset.distinct()
+        return queryset.order_by('name').distinct()
 
 
 class TemplateCategoryViewSet(ReadProtectedModelViewSet):
@@ -147,7 +146,7 @@ class TemplateCategoryViewSet(ReadProtectedModelViewSet):
     The djangorestframework plugin will get all `TemplateCategory` objects, serialize it to JSON with the given serializer,
     then render it on /api/note/transaction/category/
     """
-    queryset = TemplateCategory.objects.all()
+    queryset = TemplateCategory.objects.order_by("name").all()
     serializer_class = TemplateCategorySerializer
     filter_backends = [SearchFilter]
     search_fields = ['$name', ]
@@ -159,7 +158,7 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet):
     The djangorestframework plugin will get all `TransactionTemplate` objects, serialize it to JSON with the given serializer,
     then render it on /api/note/transaction/template/
     """
-    queryset = TransactionTemplate.objects.all()
+    queryset = TransactionTemplate.objects.order_by("name").all()
     serializer_class = TransactionTemplateSerializer
     filter_backends = [SearchFilter, DjangoFilterBackend]
     filterset_fields = ['name', 'amount', 'display', 'category', ]
@@ -172,7 +171,7 @@ class TransactionViewSet(ReadProtectedModelViewSet):
     The djangorestframework plugin will get all `Transaction` objects, serialize it to JSON with the given serializer,
     then render it on /api/note/transaction/transaction/
     """
-    queryset = Transaction.objects.all()
+    queryset = Transaction.objects.order_by("-created_at").all()
     serializer_class = TransactionPolymorphicSerializer
     filter_backends = [SearchFilter]
     search_fields = ['$reason', ]
diff --git a/apps/note/signals.py b/apps/note/signals.py
index 0baa39e6..06bb480b 100644
--- a/apps/note/signals.py
+++ b/apps/note/signals.py
@@ -6,11 +6,7 @@ def save_user_note(instance, raw, **_kwargs):
     """
     Hook to create and save a note when an user is updated
     """
-    if raw:
-        # When provisionning data, do not try to autocreate
-        return
-
-    if instance.is_superuser or instance.profile.registration_valid:
+    if not raw and (instance.is_superuser or instance.profile.registration_valid):
         # Create note only when the registration is validated
         from note.models import NoteUser
         NoteUser.objects.get_or_create(user=instance)
diff --git a/apps/note/tests/test_transactions.py b/apps/note/tests/test_transactions.py
new file mode 100644
index 00000000..7192d8ed
--- /dev/null
+++ b/apps/note/tests/test_transactions.py
@@ -0,0 +1,365 @@
+# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from django.contrib.auth.models import User
+from django.contrib.contenttypes.models import ContentType
+from django.test import TestCase
+from django.urls import reverse
+from member.models import Club, Membership
+from note.models import NoteUser, Transaction, TemplateCategory, TransactionTemplate, RecurrentTransaction, \
+    MembershipTransaction, SpecialTransaction, NoteSpecial, Alias
+from permission.models import Role
+
+
+class TestTransactions(TestCase):
+    fixtures = ('initial', )
+
+    def setUp(self) -> None:
+        self.user = User.objects.create_superuser(
+            username="toto",
+            password="totototo",
+            email="toto@example.com",
+        )
+
+        sess = self.client.session
+        sess["permission_mask"] = 42
+        sess.save()
+        self.client.force_login(self.user)
+
+        membership = Membership.objects.create(club=Club.objects.get(name="BDE"), user=self.user)
+        membership.roles.add(Role.objects.get(name="Respo info"))
+        membership.save()
+        Membership.objects.create(club=Club.objects.get(name="Kfet"), user=self.user)
+        self.user.note.refresh_from_db()
+
+        self.second_user = User.objects.create(
+            username="toto2",
+        )
+        # Non superusers have no note until the registration get validated
+        NoteUser.objects.create(user=self.second_user)
+
+        self.club = Club.objects.create(
+            name="clubtoto",
+        )
+
+        self.transaction = Transaction.objects.create(
+            source=self.second_user.note,
+            destination=self.user.note,
+            amount=4200,
+            reason="Test transaction",
+        )
+        self.user.note.refresh_from_db()
+        self.second_user.note.refresh_from_db()
+
+        self.category = TemplateCategory.objects.create(name="Test")
+        self.template = TransactionTemplate.objects.create(
+            name="Test",
+            destination=self.club.note,
+            category=self.category,
+            amount=100,
+            description="Test template",
+        )
+
+    def test_admin_pages(self):
+        """
+        Load some admin pages to check that they render successfully.
+        """
+        response = self.client.get(reverse("admin:index") + "note/note/")
+        self.assertEqual(response.status_code, 200)
+        response = self.client.get(reverse("admin:index") + "note/transaction/")
+        self.assertEqual(response.status_code, 200)
+        response = self.client.get(reverse("admin:index") + "note/transaction/" + str(self.transaction.pk) + "/change/")
+        self.assertEqual(response.status_code, 200)
+        response = self.client.get(reverse("admin:index") + "note/transaction/add/?ct_id="
+                                   + str(ContentType.objects.get_for_model(Transaction).id))
+        self.assertEqual(response.status_code, 200)
+        response = self.client.get(reverse("admin:index") + "note/transaction/add/?ct_id="
+                                   + str(ContentType.objects.get_for_model(RecurrentTransaction).id))
+        self.assertEqual(response.status_code, 200)
+        response = self.client.get(reverse("admin:index") + "note/transaction/add/?ct_id="
+                                   + str(ContentType.objects.get_for_model(MembershipTransaction).id))
+        self.assertEqual(response.status_code, 200)
+        response = self.client.get(reverse("admin:index") + "note/transaction/add/?ct_id="
+                                   + str(ContentType.objects.get_for_model(SpecialTransaction).id))
+        self.assertEqual(response.status_code, 200)
+        response = self.client.get(reverse("admin:index") + "note/transactiontemplate/")
+        self.assertEqual(response.status_code, 200)
+        response = self.client.get(reverse("admin:index") + "note/templatecategory/")
+        self.assertEqual(response.status_code, 200)
+
+    def test_render_transfer_page(self):
+        response = self.client.get(reverse("note:transfer"))
+        self.assertEqual(response.status_code, 200)
+
+    def test_transfer_api(self):
+        old_user_balance = self.user.note.balance
+        old_second_user_balance = self.second_user.note.balance
+        quantity = 3
+        amount = 314
+        total = quantity * amount
+        response = self.client.post("/api/note/transaction/transaction/", data=dict(
+            quantity=quantity,
+            amount=amount,
+            reason="Transaction through API",
+            valid=True,
+            polymorphic_ctype=ContentType.objects.get_for_model(Transaction).id,
+            resourcetype="Transaction",
+            source=self.user.note.id,
+            source_alias=self.user.username,
+            destination=self.second_user.note.id,
+            destination_alias=self.second_user.username,
+        ))
+        self.assertEqual(response.status_code, 201)  # 201 = Created
+        self.assertTrue(Transaction.objects.filter(reason="Transaction through API").exists())
+
+        self.user.note.refresh_from_db()
+        self.second_user.note.refresh_from_db()
+
+        self.assertTrue(self.user.note.balance == old_user_balance - total)
+        self.assertTrue(self.second_user.note.balance == old_second_user_balance + total)
+
+        self.test_render_transfer_page()
+
+    def test_credit_api(self):
+        old_user_balance = self.user.note.balance
+        amount = 4242
+        special_type = NoteSpecial.objects.first()
+        response = self.client.post("/api/note/transaction/transaction/", data=dict(
+            quantity=1,
+            amount=amount,
+            reason="Credit through API",
+            valid=True,
+            polymorphic_ctype=ContentType.objects.get_for_model(SpecialTransaction).id,
+            resourcetype="SpecialTransaction",
+            source=special_type.id,
+            source_alias=str(special_type),
+            destination=self.user.note.id,
+            destination_alias=self.user.username,
+            last_name="TOTO",
+            first_name="Toto",
+        ))
+
+        self.assertEqual(response.status_code, 201)  # 201 = Created
+        self.assertTrue(Transaction.objects.filter(reason="Credit through API").exists())
+        self.user.note.refresh_from_db()
+        self.assertTrue(self.user.note.balance == old_user_balance + amount)
+
+        self.test_render_transfer_page()
+
+    def test_debit_api(self):
+        old_user_balance = self.user.note.balance
+        amount = 4242
+        special_type = NoteSpecial.objects.first()
+        response = self.client.post("/api/note/transaction/transaction/", data=dict(
+            quantity=1,
+            amount=amount,
+            reason="Debit through API",
+            valid=True,
+            polymorphic_ctype=ContentType.objects.get_for_model(SpecialTransaction).id,
+            resourcetype="SpecialTransaction",
+            source=self.user.note.id,
+            source_alias=self.user.username,
+            destination=special_type.id,
+            destination_alias=str(special_type),
+            last_name="TOTO",
+            first_name="Toto",
+        ))
+        self.assertEqual(response.status_code, 201)  # 201 = Created
+        self.assertTrue(Transaction.objects.filter(reason="Debit through API").exists())
+        self.user.note.refresh_from_db()
+        self.assertTrue(self.user.note.balance == old_user_balance - amount)
+
+        self.test_render_transfer_page()
+
+    def test_render_consos_page(self):
+        response = self.client.get(reverse("note:consos"))
+        self.assertEqual(response.status_code, 200)
+
+    def test_consumption_api(self):
+        old_user_balance = self.user.note.balance
+        old_club_balance = self.club.note.balance
+        quantity = 2
+        template = self.template
+        total = quantity * template.amount
+        response = self.client.post("/api/note/transaction/transaction/", data=dict(
+            quantity=quantity,
+            amount=template.amount,
+            reason="Consumption through API (" + template.name + ")",
+            valid=True,
+            polymorphic_ctype=ContentType.objects.get_for_model(RecurrentTransaction).id,
+            resourcetype="RecurrentTransaction",
+            source=self.user.note.id,
+            source_alias=self.user.username,
+            destination=self.club.note.id,
+            destination_alias=self.second_user.username,
+            template=template.id,
+        ))
+        self.assertEqual(response.status_code, 201)  # 201 = Created
+        self.assertTrue(Transaction.objects.filter(destination=self.club.note).exists())
+
+        self.user.note.refresh_from_db()
+        self.club.note.refresh_from_db()
+
+        self.assertTrue(self.user.note.balance == old_user_balance - total)
+        self.assertTrue(self.club.note.balance == old_club_balance + total)
+
+        self.test_render_consos_page()
+
+    def test_invalidate_transaction(self):
+        old_second_user_balance = self.second_user.note.balance
+        old_user_balance = self.user.note.balance
+        total = self.transaction.total
+        response = self.client.patch("/api/note/transaction/transaction/" + str(self.transaction.pk) + "/", data=dict(
+            valid=False,
+            resourcetype="Transaction",
+            invalidity_reason="Test invalidate",
+        ), content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+        self.assertTrue(Transaction.objects.filter(valid=False, invalidity_reason="Test invalidate").exists())
+
+        self.second_user.note.refresh_from_db()
+        self.user.note.refresh_from_db()
+
+        self.assertTrue(self.second_user.note.balance == old_second_user_balance + total)
+        self.assertTrue(self.user.note.balance == old_user_balance - total)
+
+        self.test_render_transfer_page()
+        self.test_render_consos_page()
+
+        # Now we check if we can revalidate
+        old_second_user_balance = self.second_user.note.balance
+        old_user_balance = self.user.note.balance
+        total = self.transaction.total
+        response = self.client.patch("/api/note/transaction/transaction/" + str(self.transaction.pk) + "/", data=dict(
+            valid=True,
+            resourcetype="Transaction",
+        ), content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+        self.assertTrue(Transaction.objects.filter(valid=True, pk=self.transaction.pk).exists())
+
+        self.second_user.note.refresh_from_db()
+        self.user.note.refresh_from_db()
+
+        self.assertTrue(self.second_user.note.balance == old_second_user_balance - total)
+        self.assertTrue(self.user.note.balance == old_user_balance + total)
+
+        self.test_render_transfer_page()
+        self.test_render_consos_page()
+
+    def test_render_template_list(self):
+        response = self.client.get(reverse("note:template_list") + "?search=test")
+        self.assertEqual(response.status_code, 200)
+
+    def test_render_template_create(self):
+        response = self.client.get(reverse("note:template_create"))
+        self.assertEqual(response.status_code, 200)
+        response = self.client.post(reverse("note:template_create"), data=dict(
+            name="Test create button",
+            destination=self.club.note.pk,
+            category=self.category.pk,
+            amount=4200,
+            description="We have created a button",
+            highlighted=True,
+            display=True,
+        ))
+        self.assertRedirects(response, reverse("note:template_list"), 302, 200)
+        self.assertTrue(TransactionTemplate.objects.filter(name="Test create button").exists())
+
+    def test_render_template_update(self):
+        response = self.client.get(self.template.get_absolute_url())
+        self.assertEqual(response.status_code, 200)
+        response = self.client.post(self.template.get_absolute_url(), data=dict(
+            name="Test update button",
+            destination=self.club.note.pk,
+            category=self.category.pk,
+            amount=4200,
+            description="We have updated a button",
+            highlighted=True,
+            display=True,
+        ))
+        self.assertRedirects(response, reverse("note:template_list"), 302, 200)
+        self.assertTrue(TransactionTemplate.objects.filter(name="Test update button", pk=self.template.pk).exists())
+
+        # Check that the price history renders properly
+        response = self.client.post(self.template.get_absolute_url(), data=dict(
+            name="Test price history",
+            destination=self.club.note.pk,
+            category=self.category.pk,
+            amount=4200,
+            description="We have updated a button",
+            highlighted=True,
+            display=True,
+        ))
+        self.assertRedirects(response, reverse("note:template_list"), 302, 200)
+        self.assertTrue(TransactionTemplate.objects.filter(name="Test price history", pk=self.template.pk).exists())
+        response = self.client.get(reverse("note:template_update", args=(self.template.pk,)))
+        self.assertEqual(response.status_code, 200)
+
+    def test_render_search_transactions(self):
+        response = self.client.get(reverse("note:transactions", args=(self.user.note.pk,)), data=dict(
+            source=self.second_user.note.alias_set.first().id,
+            destination=self.user.note.alias_set.first().id,
+            type=[ContentType.objects.get_for_model(Transaction).id],
+            reason="test",
+            valid=True,
+            amount_gte=0,
+            amount_lte=42424242,
+            created_after="2000-01-01 00:00",
+            created_before="2042-12-31 21:42",
+        ))
+        self.assertEqual(response.status_code, 200)
+
+    def test_delete_transaction(self):
+        # Transactions can't be deleted with a normal usage, but it is possible through the admin interface.
+        old_second_user_balance = self.second_user.note.balance
+        old_user_balance = self.user.note.balance
+        total = self.transaction.total
+
+        self.transaction.delete()
+        self.second_user.note.refresh_from_db()
+        self.user.note.refresh_from_db()
+
+        self.assertTrue(self.second_user.note.balance == old_second_user_balance + total)
+        self.assertTrue(self.user.note.balance == old_user_balance - total)
+
+    def test_calculate_last_negative_duration(self):
+        self.assertIsNone(self.user.note.last_negative_duration)
+        self.assertIsNotNone(self.second_user.note.last_negative_duration)
+        self.assertIsNone(self.club.note.last_negative_duration)
+
+        Transaction.objects.create(
+            source=self.club.note,
+            destination=self.user.note,
+            amount=2 * self.club.note.balance + 100,
+            reason="Club balance is negative",
+        )
+
+        self.club.note.refresh_from_db()
+        self.assertIsNotNone(self.club.note.last_negative_duration)
+
+    def test_api_search(self):
+        response = self.client.get("/api/note/note/")
+        self.assertEqual(response.status_code, 200)
+        response = self.client.get("/api/note/alias/?alias=.*")
+        self.assertEqual(response.status_code, 200)
+        response = self.client.get("/api/note/consumer/")
+        self.assertEqual(response.status_code, 200)
+        response = self.client.get("/api/note/transaction/transaction/")
+        self.assertEqual(response.status_code, 200)
+        response = self.client.get("/api/note/transaction/template/")
+        self.assertEqual(response.status_code, 200)
+
+    def test_api_alias(self):
+        response = self.client.post("/api/note/alias/", data=dict(
+            name="testalias",
+            note=self.user.note.id,
+        ))
+        self.assertEqual(response.status_code, 201)
+        self.assertTrue(Alias.objects.filter(name="testalias").exists())
+        alias = Alias.objects.get(name="testalias")
+        response = self.client.patch("/api/note/alias/" + str(alias.pk) + "/", dict(name="test_updated_alias"),
+                                     content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+        self.assertTrue(Alias.objects.filter(name="test_updated_alias").exists())
+        response = self.client.delete("/api/note/alias/" + str(alias.pk) + "/")
+        self.assertEqual(response.status_code, 204)
diff --git a/apps/note/views.py b/apps/note/views.py
index 9b383b1f..f5e18290 100644
--- a/apps/note/views.py
+++ b/apps/note/views.py
@@ -206,10 +206,7 @@ class TransactionSearchView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView
         context["form"] = form
 
         form.full_clean()
-        if form.is_valid():
-            data = form.cleaned_data
-        else:
-            data = {}
+        data = form.cleaned_data if form.is_valid() else {}
 
         transactions = Transaction.objects.annotate(total_amount=F("quantity") * F("amount")).filter(
             PermissionBackend.filter_queryset(self.request.user, Transaction, "view"))\
diff --git a/apps/permission/tests/test_permission_denied.py b/apps/permission/tests/test_permission_denied.py
index 6dc8b888..95cc14cd 100644
--- a/apps/permission/tests/test_permission_denied.py
+++ b/apps/permission/tests/test_permission_denied.py
@@ -149,3 +149,9 @@ class TestPermissionDenied(TestCase):
     def test_list_soge_credits(self):
         response = self.client.get(reverse("treasury:soge_credits"))
         self.assertEqual(response.status_code, 403)
+
+
+class TestLoginRedirect(TestCase):
+    def test_consos_page(self):
+        response = self.client.get(reverse("note:consos"))
+        self.assertRedirects(response, reverse("login") + "?next=" + reverse("note:consos"), 302, 200)
-- 
GitLab