From 7b485d6dffb1816815049f5e42fe6a113ead5548 Mon Sep 17 00:00:00 2001 From: frcroth Date: Mon, 16 Oct 2023 23:36:36 +0200 Subject: [PATCH] Add more tests (#395) --- myhpi/core/markdown/extensions.py | 18 ++- myhpi/polls/models.py | 17 ++- myhpi/tests/core/setup.py | 67 ++++++++++- myhpi/tests/core/test_auth.py | 49 ++++++++ myhpi/tests/core/test_email_utils.py | 41 +++++++ myhpi/tests/core/test_polls.py | 105 +++++++++++++++++ myhpi/tests/core/test_view_permissions.py | 13 +++ myhpi/tests/core/test_widgets.py | 28 +++++ myhpi/tests/core/utils.py | 3 + myhpi/tests/files/test_image.jpg | Bin 0 -> 32509 bytes myhpi/tests/test_markdown_extensions.py | 130 ++++++++++++++++++++++ myhpi/tests/test_minutes_extensions.py | 29 ----- 12 files changed, 457 insertions(+), 43 deletions(-) create mode 100644 myhpi/tests/core/test_auth.py create mode 100644 myhpi/tests/core/test_email_utils.py create mode 100644 myhpi/tests/core/test_polls.py create mode 100644 myhpi/tests/core/test_widgets.py create mode 100644 myhpi/tests/files/test_image.jpg create mode 100644 myhpi/tests/test_markdown_extensions.py delete mode 100644 myhpi/tests/test_minutes_extensions.py diff --git a/myhpi/core/markdown/extensions.py b/myhpi/core/markdown/extensions.py index 15ec98e9..2e51d4da 100644 --- a/myhpi/core/markdown/extensions.py +++ b/myhpi/core/markdown/extensions.py @@ -75,7 +75,7 @@ def breakify(self, match): ) -class QuorumPrepocessor(MinutesBasePreprocessor): +class QuorumPreprocessor(MinutesBasePreprocessor): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.patterns = [ @@ -138,11 +138,11 @@ class HeadingLevelPreprocessor(MinutesBasePreprocessor): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.patterns = [ - (r"^#{1,5}", self.decrease), + (r"^#{1,5} ", self.decrease), ] def decrease(self, match): - return f"{match.group(0)}#" + return f"#{match.group(0)}" class InternalLinkPattern(LinkInlineProcessor): @@ -158,6 +158,9 @@ def handleMatch(self, m, data=None): def url(self, id): return Page.objects.get(id=id).localized.get_url() + def default_pattern(): + return r"\[(?P[^\[]+)\]\(page:(?P<id>\d+)\)" + class ImagePattern(LinkInlineProcessor): def handleMatch(self, m, data=None): @@ -174,6 +177,9 @@ def handleMatch(self, m, data=None): def url(self, id): return Image.objects.get(id=id).get_rendition("width-800").url + def default_pattern(): + return r"!\[(?P<title>[^\[]+)\]\(image:(?P<id>\d+)\)" + class MinuteExtension(Extension): def extendMarkdown(self, md): @@ -181,16 +187,16 @@ def extendMarkdown(self, md): md.preprocessors.register(VotePreprocessor(md), "votify", 200) md.preprocessors.register(StartEndPreprocessor(md), "start_or_endify", 200) md.preprocessors.register(BreakPreprocessor(md), "breakify", 200) - md.preprocessors.register(QuorumPrepocessor(md), "quorumify", 200) + md.preprocessors.register(QuorumPreprocessor(md), "quorumify", 200) md.preprocessors.register(EnterLeavePreprocessor(md), "enter_or_leavify", 200) md.preprocessors.register(HeadingLevelPreprocessor(md), "decrease", 200) md.inlinePatterns.register( - InternalLinkPattern(r"\[(?P<title>[^\[]+)\]\(page:(?P<id>\d+)\)", md), + InternalLinkPattern(InternalLinkPattern.default_pattern(), md), "InternalLinkPattern", 200, ) md.inlinePatterns.register( - ImagePattern(r"!\[(?P<title>[^\[]+)\]\(image:(?P<id>\d+)\)", md), + ImagePattern(ImagePattern.default_pattern(), md), "ImagePattern", 200, ) diff --git a/myhpi/polls/models.py b/myhpi/polls/models.py index 317a89ba..d43680d4 100644 --- a/myhpi/polls/models.py +++ b/myhpi/polls/models.py @@ -65,18 +65,25 @@ def serve(self, request, *args, **kwargs): elif request.method == "POST" and self.can_vote(request.user): choices = request.POST.getlist("choice") if len(choices) == 0: - messages.error(request, "You must at least select one choice.") + messages.error(request, "You must select at least one choice.") elif len(choices) > self.max_allowed_answers: messages.error( request, "You can only select up to {} options.".format(self.max_allowed_answers), ) else: + confirmed_choices = 0 for choice_id in choices: - choice = self.choices.filter(id=choice_id) - choice.update(votes=F("votes") + 1) - self.participants.add(request.user) - messages.success(request, "Your vote has been counted.") + choice = self.choices.filter(id=choice_id).first() + if choice and choice.page == self: + choice.votes += 1 + choice.save() + confirmed_choices += 1 + else: + messages.error(request, "Invalid choice.") + if confirmed_choices > 0: + self.participants.add(request.user) + messages.success(request, "Your vote has been counted.") return redirect(self.relative_url(self.get_site())) return super().serve(request, *args, **kwargs) diff --git a/myhpi/tests/core/setup.py b/myhpi/tests/core/setup.py index eb0eb575..f13e71f0 100644 --- a/myhpi/tests/core/setup.py +++ b/myhpi/tests/core/setup.py @@ -1,5 +1,6 @@ -from django.contrib.auth.models import Group, User -from wagtail.models import Site +from django.contrib.auth.models import Group, Permission, User +from wagtail.documents.models import Document +from wagtail.models import Collection, GroupCollectionPermission, Site from myhpi.core.models import ( FirstLevelMenuItem, @@ -41,7 +42,7 @@ def create_users(): def create_groups(users): superuser, student, student_representative = users - students = Group.objects.create(name="Students") + students = Group.objects.create(name="Student") fsr = Group.objects.create(name="Student Representative Group") students.user_set.add(superuser) @@ -228,12 +229,70 @@ def setup_minutes(group, students_group, parent, user): return minutes, minutes_list +def create_collections(groups): + root_collection = Collection.get_first_root_node() + for group in groups: + group_collection = root_collection.add_child(name=f"{group.name} collection") + group_collection.save() + GroupCollectionPermission.objects.create( + group=group, + collection=group_collection, + permission=Permission.objects.get( + content_type__app_label="wagtaildocs", codename="add_document" + ), + ) + GroupCollectionPermission.objects.create( + group=group, + collection=group_collection, + permission=Permission.objects.get( + content_type__app_label="wagtaildocs", codename="change_document" + ), + ) + GroupCollectionPermission.objects.create( + group=group, + collection=group_collection, + permission=Permission.objects.get( + content_type__app_label="wagtaildocs", codename="choose_document" + ), + ) + yield group_collection + + +def get_test_image_file(): + from django.core.files.uploadedfile import SimpleUploadedFile + + image_file = SimpleUploadedFile( + name="test_image.jpg", + content=open("myhpi/tests/files/test_image.jpg", "rb").read(), + content_type="image/jpeg", + ) + return image_file + + +def create_documents(collections): + documents = [ + Document.objects.create( + title="First document", + file=get_test_image_file(), + collection=collections[0], + ), + Document.objects.create( + title="Second document", + file=get_test_image_file(), + collection=collections[1], + ), + ] + return documents + + def setup_data(): users = create_users() groups = create_groups(users) basic_pages = create_basic_page_structure() information_pages = create_information_pages(groups, basic_pages["information_menu"]) minutes, minutes_list = setup_minutes(groups[1], groups[0], basic_pages["fsr_menu"], users[2]) + collections = list(create_collections(groups)) + documents = create_documents(collections) return { "basic_pages": basic_pages, @@ -242,4 +301,6 @@ def setup_data(): "pages": information_pages, "minutes": minutes, "minutes_list": minutes_list, + "collections": collections, + "documents": documents, } diff --git a/myhpi/tests/core/test_auth.py b/myhpi/tests/core/test_auth.py new file mode 100644 index 00000000..d81e7b4b --- /dev/null +++ b/myhpi/tests/core/test_auth.py @@ -0,0 +1,49 @@ +from myhpi.core.auth import MyHPIOIDCAB +from myhpi.tests.core.utils import MyHPIPageTestCase + + +class AuthTests(MyHPIPageTestCase): + def setUp(self): + super().setUp() + self.auth_backend = MyHPIOIDCAB() + + def test_create_user(self): + claims = { + "email": "ali.gator@example.org", + "given_name": "Ali", + "family_name": "Gator", + "sub": "ali.gator", + } + user = self.auth_backend.create_user(claims) + self.assertEqual(user.username, "ali.gator") + self.assertFalse(user.groups.filter(name="Student").exists()) + + matching_users = self.auth_backend.filter_users_by_claims(claims) + self.assertEqual(len(matching_users), 1) + + def test_create_student(self): + claims = { + "email": "grace.hopper@student.uni-potsdam.example.com", + "given_name": "Grace", + "family_name": "Hopper", + "sub": "grace.hopper", + } + user = self.auth_backend.create_user(claims) + self.assertEqual(user.username, "grace.hopper") + self.assertEqual(user.email, "grace.hopper@student.example.com") + self.assertTrue(user.groups.filter(name="Student").exists()) + + def test_update_user(self): + claims = { + "email": "jw.goethe@weimar.de", + "given_name": "Johann Wolfgang", + "family_name": "Goethe", + "sub": "jw.goethe", + } + user = self.auth_backend.create_user(claims) + self.assertEqual(user.username, "jw.goethe") + self.assertEqual(user.last_name, "Goethe") + claims["family_name"] = "von Goethe" + user = self.auth_backend.update_user(user, claims) + self.assertEqual(user.first_name, "Johann Wolfgang") + self.assertEqual(user.last_name, "von Goethe") diff --git a/myhpi/tests/core/test_email_utils.py b/myhpi/tests/core/test_email_utils.py new file mode 100644 index 00000000..5ac5d74f --- /dev/null +++ b/myhpi/tests/core/test_email_utils.py @@ -0,0 +1,41 @@ +from unittest import TestCase + +from myhpi import settings +from myhpi.core.utils import ( + alternative_emails, + email_belongs_to_domain, + replace_email_domain, + toggle_institution, +) + + +class EmailUtilTest(TestCase): + def test_email_belongs_to_domain(self): + emails = ["abc@example.com", "abc@myhpi.de"] + domains = ["example.com", "myhpi.de"] + for email, domain in zip(emails, domains): + self.assertTrue(email_belongs_to_domain(email, domain)) + self.assertFalse(email_belongs_to_domain(emails[0], domains[1])) + + def test_replace_email_domain(self): + email = "abc@example.com" + original_domain = "example.com" + new_domain = "myhpi.de" + self.assertEqual(replace_email_domain(email, original_domain, new_domain), "abc@myhpi.de") + + def test_toggle_institution(self): + emails = ["user1@hpi.uni-potsdam.de", "user2@unrelated.com", "user3@hpi.de"] + expected = ["user1@hpi.de", "user2@unrelated.com", "user3@hpi.uni-potsdam.de"] + for email, expected_email in zip(emails, expected): + toggled = list(toggle_institution(email)) + if not "unrelated" in email: + self.assertEqual(toggled[0], expected_email) + + def test_alternative_emails(self): + email = "user@hpi.de" + alternatives = [ + "user@hpi.uni-potsdam.de", + "user@student.hpi.de", + "user@student.hpi.uni-potsdam.de", + ] + self.assertSetEqual(set(alternative_emails(email)), set(alternatives)) diff --git a/myhpi/tests/core/test_polls.py b/myhpi/tests/core/test_polls.py new file mode 100644 index 00000000..5b844ed7 --- /dev/null +++ b/myhpi/tests/core/test_polls.py @@ -0,0 +1,105 @@ +from datetime import datetime, timedelta + +from myhpi.polls.models import Poll, PollChoice, PollList +from myhpi.tests.core.utils import MyHPIPageTestCase + + +class PollTests(MyHPIPageTestCase): + def setUp(self): + super().setUp() + self.poll_list = PollList( + title="Polls", + slug="polls", + path="0001000200010005", + depth=4, + is_public=True, + ) + self.information_menu.add_child(instance=self.poll_list) + + self.poll = Poll( + title="How are you?", + slug="how-are-you", + question="How are you?", + description="This is a poll to check how you are.", + start_date=datetime.now() - timedelta(days=1), + end_date=datetime.now() + timedelta(days=1), + max_allowed_answers=1, + results_visible=False, + is_public=True, + ) + + self.poll_list.add_child(instance=self.poll) + + self.choice_good = PollChoice( + text="Good", + page=self.poll, + votes=0, + ) + self.choice_good.save() + self.choice_bad = PollChoice( + text="Bad", + page=self.poll, + votes=0, + ) + self.choice_bad.save() + + def test_can_vote_once(self): + self.sign_in_as_student() + self.assertTrue(self.poll.can_vote(self.student)) + self.poll.participants.add(self.student) + self.assertFalse(self.poll.can_vote(self.student)) + + def test_post_vote(self): + self.sign_in_as_student() + self.assertTrue(self.poll.can_vote(self.student)) + self.client.post( + self.poll.url, + data={"choice": [self.choice_good.id]}, + ) + self.choice_good.refresh_from_db() + self.assertEqual(self.choice_good.votes, 1) + self.assertEqual(self.choice_good.percentage(), 100) + self.assertEqual(self.choice_bad.percentage(), 0) + self.assertFalse(self.poll.can_vote(self.student)) + + def test_post_vote_invalid_choice(self): + self.sign_in_as_student() + self.assertTrue(self.poll.can_vote(self.student)) + self.client.post( + self.poll.url, + data={"choice": [self.choice_good.id + 9999]}, + ) + self.choice_good.refresh_from_db() + self.assertEqual(self.choice_good.votes, 0) + self.assertTrue(self.poll.can_vote(self.student)) + + def test_post_vote_no_choice(self): + self.sign_in_as_student() + self.assertTrue(self.poll.can_vote(self.student)) + response = self.client.post( + self.poll.url, + data={"choice": []}, + ) + self.assertContains(response, "You must select at least one choice.") + self.assertTrue(self.poll.can_vote(self.student)) + + def test_post_vote_too_many_choices(self): + self.sign_in_as_student() + self.assertTrue(self.poll.can_vote(self.student)) + response = self.client.post( + self.poll.url, + data={"choice": [self.choice_good.id, self.choice_bad.id]}, + ) + self.assertContains(response, "You can only select up to 1 options.", 1) + self.assertTrue(self.poll.can_vote(self.student)) + + def test_post_vote_before_start_date(self): + self.sign_in_as_student() + self.poll.start_date = datetime.now() + timedelta(days=1) + self.poll.save() + self.assertTrue(self.poll.can_vote(self.student)) + response = self.client.post( + self.poll.url, data={"choice": [self.choice_good.id]}, follow=True + ) + self.assertContains(response, "This poll has not yet started.") + self.assertTrue(self.poll.can_vote(self.student)) diff --git a/myhpi/tests/core/test_view_permissions.py b/myhpi/tests/core/test_view_permissions.py index 5164424d..3125d500 100644 --- a/myhpi/tests/core/test_view_permissions.py +++ b/myhpi/tests/core/test_view_permissions.py @@ -52,3 +52,16 @@ def test_super_user_can_view_all_pages(self): self.assertEqual(response.status_code, 200) response = self.client.get(self.private_page.url, follow=True) self.assertEqual(response.status_code, 200) + + def test_document_view(self): + self.common_page.attachments.add(self.first_document) + self.common_page.save() + self.private_page.attachments.add(self.second_document) + self.private_page.save() + + self.sign_in_as_student() + response = self.client.get(self.first_document.url, follow=True) + self.assertEqual(response.status_code, 200) + + response = self.client.get(self.second_document.url, follow=True) + self.assertEqual(response.status_code, 403) diff --git a/myhpi/tests/core/test_widgets.py b/myhpi/tests/core/test_widgets.py new file mode 100644 index 00000000..85e69e73 --- /dev/null +++ b/myhpi/tests/core/test_widgets.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass + +from django.forms.models import ModelChoiceIteratorValue + +from myhpi.core.widgets import AttachmentSelectWidget +from myhpi.tests.core.utils import MyHPIPageTestCase + + +class WidgetTests(MyHPIPageTestCase): + def setUp(self): + super().setUp() + + def test_attachment_select_widget(self): + self.sign_in_as_student() + # student has access to document 1, not document 2 + choices = list( + map( + lambda doc: (ModelChoiceIteratorValue(doc.id, doc), doc.title), + self.test_data["documents"], + ) + ) + widget = AttachmentSelectWidget(user=self.student, choices=choices) + optgroups = widget.optgroups("attachments", []) + self.assertEqual(len(optgroups), 1) + + widget = AttachmentSelectWidget(user=self.student_representative, choices=choices) + optgroups = widget.optgroups("attachments", []) + self.assertEqual(len(optgroups), 2) diff --git a/myhpi/tests/core/utils.py b/myhpi/tests/core/utils.py index a7f03af4..396dbf48 100644 --- a/myhpi/tests/core/utils.py +++ b/myhpi/tests/core/utils.py @@ -27,6 +27,9 @@ def setUp(self): self.student = self.test_data["users"][1] self.student_representative = self.test_data["users"][2] + self.first_document = self.test_data["documents"][0] + self.second_document = self.test_data["documents"][1] + def sign_in_as_student(self): self.client.force_login(self.student) diff --git a/myhpi/tests/files/test_image.jpg b/myhpi/tests/files/test_image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..056b24f2d8dd0f267eb93e6281e7f560b15dffb1 GIT binary patch literal 32509 zcmeFZeS90|btZadI6wv<nG7Kamc%4GU<O)11#F6<VOgz{0Yusug6u*N<x7gWUO)mI zGQrr2tRznFZhu3FVDzzVM<WQet!+DGP+9)?Z6TC9Np91I`bZ;~&0See6GBLuCcQ~N zvu(0Xx4CKR`@E3tbnh?i{;_}FAfW^TGw;0Toaa2}IcG+Gee?A{CTZef?P20Lj#Bs^ zz5WgQPvcK7JWix(yGSIWw~>$QAr9}@&koMj{rd>s55DpKgLof);}{<AcXK@T<L~46 z8Nqu${x<Nl@Cl)NZWZs@3zzHu{^sA0Xj2(^<lf!G_wE_RnZqM{_U#_txBGs1_vpSo zqx**M$I+bq<GrvoUjIhlf0;jV;@H0X?t9{?d-H`Sj}-4c`lNo})A?if?Y?*TKKi-$ zJ$)>H^h3oH@{!^P7M^%$=&yh0b3^h%;h~}b?f$9Zsbl+#^9!k8I9@#X3qL=7^cOyK z^ufZ=``#=6+|&D>){p7M6M6Y*z4XLW`<{MisJr++yk{TZHzaotIq{)~hGwws{ZAe* z%A@y=-a8zF`#w4{gf)&A9^aQ8d-#nN@Y_Q}Z*28rAN$z7AKP>9lgB@B-|hz=eDJ>E zk^4qQVmKo9)W@GVk$*bdBibA_?uQ%8?496Pb_<P$PmF@NOAkDPdD2-m;6G5y%o z)LRSxL?7rn+imygr%sH26es^n9(!u~<Hw5kWs6Te`H|yCi{l?HK5^nbZ=TK~^w!zT zhz~62$GT>~TW?*YaP+NXj(y~Ksr!B5=zYafQO9CWAuhY$Iu_@>b?{HzJi9b3+&A{* z(T}jxwXuhW*j4uxo;<ox*w=kKvbV5jw2*%=c7J^M!Psd2$OEyx5A59?yZ?dF`}geG zQ`|dJ7<qFY?TM#O<exZNWb0t%dl#_C=-$T*!@GAEVxxP9ABc?>@5lMONB6|`#vd3N zeemeW$VmMDH_!h!#}@#jeCeO=hpkh<IuAY&-@E6*y${45Ebht2MjyapM;_mOf9(Fp zA2>Sv;QjaCzkBcAA(=UGUm~MDG&FMW@EgBoO8FCyKY3h#XlQcbiH|(}#;4N@dhy_i zJaf_R_=6AZd0@}TXnf@Uy?aL<eB+2mi^rb=FxXPvv>B2MNB2F9h4P5<f}a0C@xBip zD}JE6`F;D37xO2cJU;#8lck4--b%K+-%SD2h5U*9SpGy2tG$(b({JY9&kT?38^(5F z>%OTc3k#2b{C{>R%c5@RQVUPvvLAmVBMufn{KmV|g51U9zWh;!?uUk+%73(27<%Kw zqx1PEK2SuS0xNe9Tlfi~+f&$`kB=7j#C9JU9*vET#2<|1_wGI#I~qS)*s~}9K=H@} z`8Ush>dD7Xd@O&wnD_v21P=g3UAmFOU8hIhJnm!jMdnfXAhvrrw)_4!eGq@c2lrwB z_pwyGYs!89Un{%c{gbqP^Kfthd;1o(yjT9|>!%+0sRw@QfuDNdrylsJ2mb%(fgd*~ z#V4S2K8Dug^>6YGZU6p%o5}v0hqcLZbY?`nT{@~iC8$LD6DN*mA4$qHvxnuk{V93r z9n?qvigxFZK6NaS$xO1A=qJDa?#ta?i(ZSdwSMw3|Lmvz1vDXO)?^%^9zAya1m5TH zKK`*2$JqO4@ZJx>R>1q0@jiMS8^rrf_W6;!@BfOu|3LRWYZA$Sd^)=yw)yd1{=h$Z zf8?LMKRSP0$1!LJNh#=0V10h(?)w6J|L@p)z4Q_6t@~mfYG3h*LkID<f}eNNBc#z3 zWk{wY^buO15}lwJ{(b_VJVjZ2pZsUe-_bokgR_p{tOYuXGsfsi(kb6PpI8iV3|>F> z?Ng8M8R>q+^(Bd1fAsq6|1E?T`aIE(%-3K4;mYf;|7ew{=c`2PPyG1&CvBp=-^b_X zk3Um>gXkSgM9z&Le|F?oi9YoSqHXPC`Q!QSb$ak8|2W$9?>$e{KTEXb7Lj-EuHWz- z`<_1e6{2bQYVwOj&pt=A^8;A>{r`;Jtml8rZ~x*kZ}{)^4(+Gc|5smFg!^Az<MnGa zAiBQV^JhKW0PzD{&j9!O4GMIp8_W-Hy*M79Z)HP||MA7Weuw&cIO3%q3Gh|P^C<Zk zpeM$Kx~o^Jhl8s85+%!-20M|@`p<B?)_oNtsQQf%xfXXB9^Oda7GB~hEN3$`L!6g_ zDpBH5sxGRbA+!DxO$4x@XiJAqKMMH!&Z)%TUC(Ebmm4yODpaLLFcI!0Ps+xHDzZO{ z3z6yyVx1`AN6cpYDt6+pASE-zmr451IFing-yq)0aVnP+BS@}b!b_KD_(KLsS^q!+ zt567**aQ<M#6yc-F@y1p4O4;C1~XR<<MK&^Uzfb%AtTgFB&_5n8g&W{S=cPTf(6(h z)v4+_{iqOuqkKj~wxb?g6nnsmSPSQ8I6qBX@lG_bVK_o?g;@5&x-(*!_%M~tl_R`a zCz~c3%za^QjD78U8JFO1*iu;VQ$wz2#0&>hIB(MQ^WH07xI)6h0k*YzBIU0jI70?p zRW64^1oz;DOYA!3E4$c6g{`@y!Ql{+ZQ~_a8dQTRSj~F_<f{mD#S3qa!-jFJI8kvK zLKxBCb>k(8nU4?|uy-H@173nJ{QTA&E&#j4tY5MvY~HRIuq=`A^BEF9NZViRWig@h zIW6IBkYw{2e%&~<7{(?Ma_@LZKp2wvMGo_IIBQ0-s}v9--lS#1`GPBo(b^f&i@;<W zp=^NoLlS P!Iw(sSMfB?6!9gs?q@C+OX6v1bUsk^2azj?|#5eOy_8_63GY-wdI zJY@RHkzMN-j(7nsFX#0n*DKg$g2E(`i|u)098u&VL@<%TAw-BlxK4r{UJM75Msl59 zh@si=!!Iur`Oc+W0!#>|;7r4=2sXkvV-O7V0N8#+8F=uhGjNSpW#MPr7hM930}_Rd zCxLa;h`+j++O=*lT*BoXj^x5zZh`??Udg@jJ}dzp5?}V0P5VrD6B0a2suz&<nRLjI z{S=@IFp+5lRTw}tpvo^v{z@pAT(4pyaHXH;4aA$j;m1yUkt)2Qy5PVoFuR^nBi?$H z{HMafM+KK)V+B4e*jNRA2~!j<qY0HVc#mbqLvof=VM7Y=sSYH#6qgEfM-Zbball4- zMMZJrv{cqb<+2Od<id!xQAa+FV|z(M^y9R!3}~&u<GqwLdGpZe<c%<SC7OXvfDx%I zNBmMogj@KGddLtWY<qs5GGaXvmR&Pt<YhM{2%jOB;ODL6GLW$5<qQd7!$nd?oemp* zmn4KkLvVn%A&|{yIB!6<E4?rV>22C&gcPCW64?DTQrtLnIs+#dKq)*hfh6uFj$H8K zg<RKbV4yPzq&+A>@{Y?`+q8Y<a8OHuT3o7E=ECwW<Zdq^|J3kI6`7SI*}teFE0JSi zK%BpUh}8YqFBvaM-sH;!a3?K8^_LC5%6mnig2x+d&*WeBB!D!wLzbdpa2dEu#kw4( z&|N~3ZQex}a1o@vWzY<l<k!ClKZ=y)%?u5hU0O<(fqJ`wlwwJojnIqgP#M|kGZAl4 zLegUBemPn8lH3KU0h|d0Ou$=S&JXejm$*_0HeBHaV~q#31Z6xRL<T1~%>_p%%UQC0 zOHnQnp5d^GplaZELuCl}r2H3-@Rs=!pSglEk@7OahKs3M0rMwsyyPo`W9wUUY8K4G z0mXusb72WU23AloX$Az4)M5mbGIQaG|H7EqC522wcA@IvGJa5)Om-C)HR>>IF@ZM` z?}VDEkjjA_WRxs_HL7OGJAsvF4BJ;(q?IEK_~ne4b;%&i6ey7M1HoWyaI)kg!)r8N zlCp4lFm*@>pW1b*8chPZSP@_ZqlCPIND)ND$fz97FNZIKbH-r-g=FxS7hA*knTadA zZtyE<pJDUj>bVG)z*W^OkCK|oB&JnRZ-~wy2qJm$=QzTRAhOC(2eVbrBu(46pq}x7 zeLx!-Tk<E%p&91CM5_LhlxYZIfi0#bZ`cD^F#%P^s#!p;yNJ}fU&0=PNZYKJU+2C2 z4SdI6GYr3RC4wOPOlE%)!0jb)Z57lHJiAao>sc;kvlIN$gep@QRWL#R0ad_{Uk)3# ze3v~#Tn<EaAt#1~j0mj&KUTU4$35Duq%<S(mLY=EDj^gfLTaK+Oc0C#_u=<4Hb$rm z$aTMgDB%PYi}7wG2o<kujtG#5;7id(iz!^mC9<_B>Qdo6i#an3gh#+|FG-9<U>Km_ zk~2UY7{#s#U4g;sl+UbZk;sgWupScDbt;TSvv6hI%LIr_-i9o^$Q|L!hZc9iX-G`o zV1fq3&XCGt2&ti^z-=lZr-FuzE3Pbd4yRX;C0z1`-&o0KVN-VN5%3uK4O9o4mAEjg zSpr`U%Wzbr3%aTlCjZU4H~A$n5=5BrlJ6Wcz%rpYQW=hgl;C|V!n1;p3kqBURS8}L zw`1W5({c!2o|OeA4KgTYWfot9!--zeJAoK8AY@cHIV=zu*LIx-dy`C9pbn}e@2+tf zJX%Kyt*~WWhKw2<mMH?(2Qz@sLHei!zRIaAmPQ7lI@jTe1g;PA2PN1BC`JO=qKMwD zyFB(myKX?ofiGxrd?<sf$S;ZzQIG_D88HJpqQZ-)*a=7%wg<aHl@Q+G!v<aG<x)tr zXyQ?nd2EEd&|zLMB18vP7waz63)E;FjjGH<1FU1>>+%867I+&)Kn1;E#{%-0_b(>G z6ifqvtlHS{2QD_lat(4a1coq>x?GA;5o*pjc4Sv(Dzfc5mCe+7zA_L-)rB+01r&3^ z4?v=bBM)5yHayWqiwYb<%8?w-a(tOnhsvl@-h>)V)mM&yn#Kh`iFVm&V3&Zr?1gRu zLI}oDolAl5u$n(O=JF;3WHX;vNd?I;YDIm4x&#Pl5g;IJ3l^IaQzQsh7HK5;VFQj# z8un6@A}&a+?g;{m5@KioQ;ZLwb>s+eb}r@3lA47dcobZ}6qdtsgj_Nb1#$)zL4qJ) zDF7vEGsG}gP*(-rsH04<(#NV1p(@rRgfz;i_!BmvYJo<o5_t`KoS3JpbOngA<!DCT z6`(qAT)_spBVB_LT_zO`e;GRx!8TCy5(;y829|pzTeK^{M43^h0srj?M!c*riC|d~ zkP~<Vs?jwAtv6))s})#*O!Es-7YZ7P6dp9{=xE^3%Ne9X1z_r$#t=A8s4wM;0ohZ= zs*Mml3s+SLDddGP5l~HWInaq0HjM!qC=_&sklgSmxE18-!lKZEDeln_m<Pgg$jm~p zOSmSGQ3>X}$U#sG>JYrxn}U4Do&gvZ3l%70DZq85k82TU0Q~Sbhwlle03Quh8;Cr7 zvwH4sRDlK+wIJcW0;Yl@;lj`hC}MIQJ49g(Ck;S~g82l1g+#Qe0!s+VBV9?jAZtWW zdxmq(Q~=!u<c+{eaTk5O{|x9>2s7!_yZuGMMaD}!e~9ITn7v^mZ?BMjMg&lhdwdz2 z+I6GuMOlhOydn$Y${3fR6$(qJ|BUYut8*#pnlHG(x~Q&_&0>s*f)+o>oG2ZVv7}!@ zoUk%V_K;ck3ocgLRN$W5RStj-L1q~>gQT*T!choYF!cbje!<1KAAMj2y@m=1%&-<# z%x1}7UZPd-*e;s!8`uYu1&fQQx&CosB_%-BGKn;l0nKL_o8S^*!X{;U36(|$p!p@j zvE;i99lUE1s0S;uNU}VJ#ym5Td>I`9k_YA@2kTppfOo+>a7G#4VOW*;Q;;el?~svD zhs-W%0;Dd<6(W>)l!ry2Z0i`BAK(&6EHSpgB5*OVm8C31FLEhahCRkO@{<{X{-~D- zjs#)NFwOx5*i{~hZ4yd4)GO-^47&muknn=$LBC*8026gW1!BOBUQ`4yF;g9yN);M_ zX=f(HI_d;UDPP84DEE+0WyA2pqG^|ba&;)IAT>}rB|;B}O|l9T;jJYg42fDn7Iw)M zu<Auw7BY|-e8%8Uq2MqB%M~FCHeq`bvIKkqv)~RuQGi`{kqEsC7=vw+r)=rM7+|0t zz!U)&g3T_2Dl(5PRnCAeB&xgQl_MYuBG$g0VKST@CIJboQ36^g{9`Y|9a;2KW%wUz zgjfsWT4ae#SRHJGv<GkbSs%u_CzE+!&Lj^FBwW2c_vazZ)hsle@FI3tk!{14X*DeK zFRKK9mHjNgWEY`&x&#z?v{(KL2#%#b>YHG*eoG>PgOesJY$|~ZyA>94A%o2eWCzny z9jgIZs?FF16f3|G{91=-X3-D<KXx@aiY6V<trN`Y5=&5piUv8OY+QjV4<-Or_BFsF zLe<luMVFYFG3vP5bQJke@AB_mF$=1Ykf2d`g%RCQ8E|4i#Q)g!z-?eh3D&}8U^K}k z+ZD7wu#M2v!xRP`T+9WY=hv%3G>Z%f$UN}B>jo=cs$E`+!WYoEH1-nev>ze@C7aP& zMudXq11?q*>-<ufaUe?ZU8eC5q|iJ}j1xZ{UQ8evBivXv69^$(gc<`<_Id$xtab&^ z4yMSZW*FF35#Fo_&4CdL^c;>%8VLwEPEDGA036oCXqjvff3>oRg6j{+Yzu&r7ur^p zBPh#2A9kt&Ut}9~;YB%<NKK<mO1v4N5IU8-DtM-W7!%VV|IM)L6uec(2riN1;GT?O zqh_Hw7Er1|1w_+PkS@Xn%)+@)%BQ>#Qy}X_Eoq{qR%Jx38V2N4z%w9h^+KzNya`4H z9oU8|1XuHT1943-nn?AcK*1TRpS6o+1Duu2VCAS9#%>clh5!P)G9e$(yl_k%g8?&) z`ZAKADFPHafC5CtVU9rPYDMpmIUV(i6RR%Se|lO(-bUa8{#@cY|2P^46@w3OhYMUW zz9(rw&Q>uD+6rln%it2+E(5g^yM&VsLVFIxf)rH@eb63)*>E7Mg%Dp86^sF_7lH|< z-Oz&BgdOo2n+LeS?`-FgbC^u9x=Lpf=tMaL#t)(g0Q^fUDtI0%qt|{J?4aU$m|Jna ziUF2nWM}Xz@HUbH-8KWW7s7+}E5>hvbO+Qu)8K4y8IJ)KGzWZ(%*@PO=!Mz@@F$og zNG^6M8#@DRxal$Ei0?A214q<tv=9hRc!uc*STAC~nP4&iUm<}2_cMv^;sS5rbjpS? z#Nind&qxS28<D6kT(r)Z9TKR)v%v-mEr<nBL~qCHESCaULBDl>1bODg(6AEnst7BB z0tyJ@0Mxn+G77|Q*vKk0W#IbDy<mzm8XwFD0K|&y6`}RIG7nl>fCG^)*cBseufZcr zz$KE+<|-kqj+qW+QApAJRJ*OH%#x-77iY0sSPG6rT2-iGM1c8JHURcDY;1pqyorRr zA~QYd2O~lnK>k71!9}2K7bd1?IN%Py0fZrH;0l8!1JF$17yN<xhz;%vFly&-V>?(Y zOhD9783D_z5`xZj8U`;iiiq0ltwcQuE(7#tWEW0`Qh>)9#X=a_l8Y*gf4$>^3)gDE zy|!x+*+4<W)41%N!F<3qV@v}EfF801+&cqgAn#nDR@8BH$ixbZE4XXD4og{62OI<E z6=7H4u6Rc9<fuz#r?aAv4b8wLs01N=MuiH8MNkL?F@W(F`eC9AT;d4Al?5>%!01g? z*#*U?B5+0)bXZ22JXK|cfohCtDO`q0j*MX6sAo7<wOOyoo8tnjWV?7HX*SrEUGV+Z zUcYf67xvPCiwt7o2ui3cBQi{7t4#C4hbYkmvzQ3jWr*Hj3Z+NojR_8d>l(HQC4mwx z6A;Go5IjVFa>-~euiPC7VM2l$6Y*BiStb{m4l}xB&L4swqawSxK;SzRedM9dGev;O zH?8EL;o)u^1cl@6l^9V>M+y0W)SUnzSSPy6FJhF$+wcmC4-6kC1AGj!VfhM#;zMZt ztgj-WZ(-#TyR3Vr)e9<*!?7MCgo*^2fc-OximnEF9;BO4A)1XWE1+l837eI|iH0kf zB7oSiBbbID`0&^+ei8jTAu2BPUozE^#=_PdR39S6M%4i91=T=#KsuwitHDfzH^AD5 zPNBt==`!e);SiP?1TYdNIG9L6Wiy-VVRD@U(ZCLLMt-9X!%^K>z~O9+eX>k>;Xp1K z02{(__*l&#Jjg#302yp5GYldu7=HkdmoqY=d3gr1kCLAU(qVAs3Jil?I1JlIdLa#V z`7sE0od(UR$gIm4u7-hB7n>xT{xcwmiH2WxF;Rf#z$K%SLJ~3u*3s%g3Xw~I7(_K> z6`n&0$L>5T(hpQEBW-YVf?QD*z{I_UJdTGHK^Dlcf`RC6n~85TNu)Hg0`02U+m(?h zW)QeDs81>$!S%AZgZy~7guMmrM!L#_eSj&bkwn?Whww90ia&|`*a{wz_;scRg30x1 z!i))({}kv9Mq-XO14^if85UxwD-sVdj{u<=5x7GTtrZnXABEmPB7xBGJ>1S@H%J@K zhVT6iAcdyMW}}vj=z&o6t*8~Gk_&fq49HakB5D4n+$9w7A<SovLG{Ji{3({<XySa` z0Nc{EONyY2V<-{AsE~k`6bNN7gijJ8!yC-qu-*@X<}txARIeINR?BE%(SBleZvvj> z9+h1o@IO>9rjM+kQH7*{KbXTi1ep~Hly@!%|Km%51#?8!R@kuBL|C!IB-jmvjIshl z1Wyur2~%7Y24dt4J|LOBg91f=Wq>M7a2z0NOI?)%N8tPh1g}fJ3=bbVt&)K)b1JUO z<&b&64(dD(1EzPK0&ETB0PJPSi)s&PgZolEiW?eLpvQGO0~okC>|ezs!Kkx>f$Rc; zfFvx3(vH;`tT1W>2cX!GWBamKgaJcnODZxNNPi`Qa~aE`IcA=OL(r%bT0U?fA|^Fh zy&+8Zpy6;7h>+F!Mu_Xh%D`?GP6Oh+6#^1e07Jm<xP1ifXK6|JkT57f!9g@x-+@vD zA&O)*Y(KV(4WmkWWsDQB21tX;*jIM(pNBSpOOa*l5ab~19dO_QNJFrO*YLA%v&37r zi&X5uM5=OH1`wFBQT~$aIIE5yybw#<w?uXwHQY9ky##K?u$wT5R^hA)T6mNo$nFD} zivdT>iOiLF!Qq^`N99yFQiPZU2J0?@X3E$izOJ(lLe6669KV?13HT0WWOgC~h=OIP zyP$(Xt1_!%eD@2_G;<4=+))vQEUV3!c&RLz7@u@6%k~YeqGoW-LkbDDi$@C};Zw$B zkb;=+a48Rq7#hA+xPkEwn{0!|T%qJTW%_Q}_?SCFTsQ-yxqv7>W2=n$eJoU{YB&r` zAiI*EXJ_E*JSGxU_rtiPpL)SwFa|;z6oc2a%fMAo6&Pp%d#onno}l0G8?YDx1Q9ms zSOr-Sov8EFg>%`jvH)K01&F$ch7m4EM-o;NxVe<Vh!L`dWd^h%etijr5Wxnq)tUK1 z*XXTW4mHw$n%PSb4s3}szm9cjVs)!mhO4}+i%H-P3nYLo*%)eqAcHCoV={z-tzO7Q zVD~ss#oxdp$wSl)f{JN1v~GsCTtdWkz-LqdhtpuC3>KGc9OEDOAQrHr5P6tgU<%DX z^Vv}(1-7wXjik`afMzgH1hxSf86yWYrb}V`Qjv)cRgx&PGw8&-s=_;jJ!Pk<yZ;Mg zyDY!KR1_H)2jY$+?g?QIwQ67^ydRgUyL!1yMW6;+X=tl@823JbN$@3_hA@Y5N5IC^ zjZpcpidRh<pzlby*UM9H&nU<N@pPf-mLaXwYyfu-CV+VaQcLo{1jeJ#CrrqJC{Z(K z_-Pm6u4;(Og|QYa7)YQ8vbV2{?LrN)(NTfWFcU*cg3O`AQT{C4g+Vn$H-UXL<jY=K zOn@8KGlp>mS}b@OZR(k1+4lmb;viZOlnwK7zZ3VB5FUZuiN1`ifrdPlb=?&akS5Ui zhj|I$;4x}~ctd~VqKXivaT(b_DtwKpDzX*{&jw^LB6+AHXL;*o4jczQ3|=-^`9ZSf zI7VXI@gBZh#Ru3Itjn?~vwa4|$_0y<2B<S#yMk2RN?}YY00Y5whS(&~sDgXJ9qhVj zx>#!nJ!bkbIc21WAqeE$8K9TSOPCWbMUJqhtk+kO(TuUo^das*C(~ZIE=mP(NF3k` zKEv+Gzw{zDZ*aJgD!7uTGJ&9`Vh2bi_S%J$*~M^@2l-dW-^Q0vdBNv0E8t$v^C<J3 zn3;e;fR^l@)jgLtr&(198(=~|^9LvcoLn|kq$I3{PB8FfMHTG;%08>pI2ZZP2%TJV z2y?9?cxZ*$fDI#v#GkxBH3O<bX++pST6NFiQ*fdP)q;q?KtIlAWt*yU#0&5-FC+NG zyxzSt$Fk@1GWw`p7*e3JWIP!#5WcM-I;bu12r5Px1r^n)g2F-5c=iO}qX^-V12k6r zAvDScez!5LHf;0*boYg{!P$PaHbNvPBBVoQEXD>DuoxbJ?{Gd2xjci+M88R(PDX4Q z{2-9*9?t|6wh0};ngZr8jEgGRy9=|q2>&I#6<%cbbcrP*<71Eov&}ZpLK)RAZ9z2v zN!X`B72MHb=)vkRP{s&BK#PjU3DBE%0~J(pu7oRsmvPi|6#Frt^sp{&<q?{YQ{<Uu z+{Pk}Pp~PJR~9OPO+4XXzrmvc#EUOOoR@JbjKLHDha=yTCXBbi-P)re!ogh^vH1%G zMg&!4T*ib33y~M}fs3oG5b-9SM?qg0lEauK10w*yl`t?thRv48sExgV67YEr;pSIZ z$W?e3naKk)U<xdk@RyO2Y~pwK+Pz)_r{OGkgiEmNu>X6QBa@4Ghv5Me&KW$hV{lju zswI(xZ?LbxR-7s02Z6B-roI6+LUO&ptdwC3>YId?yqmNPzc?0!5NU~pA-xU!&dAC( zTmsI+3u?8yVt6QHkB7t=kQl~w29!eAJ3$rB#j64ZgeBOm7jJ-I5Pq`-cv5EeUjTy6 zh}a<RR^l^cK3<3pEX1q9!ULm3F1g;lNssZ8#Mib8_zv0Z!f0!#jLsMXO0p}+7<Q5u zI@sV%+$<78GbptTWjqsD*adOBY9XpH3s{Nn-XRA&;vpx&j({TBwHbS5*^MGh8Q~}I z-F6S|Lcfvnc7-3_g^{v>FmCuTT)AKm?!wD9id`lPFHk+Gp5a`$+kxJLf=A#&OjVF= zlJFvnpl8}Z)_xw_hS5kI+^qlztGK*r*RdLVCP%O_TSrgJ)R#nc#Mt0b1`goyrVz$? z1WiqN%*AiO&t6*vO;W`>ltFC73~D=+7#5XmNG};K-<rarAR%M}%J?Y+-!%lz#PAx- z-F*}zil;JcMk9h3<RaR5$_qDcSYG3tGJmRanaQGEhU�kgd2wfNwdV0QwyBHy^uo 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 D#E`AI literal 0 HcmV?d00001 diff --git a/myhpi/tests/test_markdown_extensions.py b/myhpi/tests/test_markdown_extensions.py new file mode 100644 index 00000000..2a418063 --- /dev/null +++ b/myhpi/tests/test_markdown_extensions.py @@ -0,0 +1,130 @@ +import re +from typing import Collection + +import django +from django.test import TestCase +from django.utils.translation import activate +from wagtail.core.models import Page +from wagtail.images.models import Image + +from myhpi.core.markdown.extensions import ImagePattern + +django.setup() + +from myhpi.core.markdown.extensions import ( + BreakPreprocessor, + EnterLeavePreprocessor, + HeadingLevelPreprocessor, + InternalLinkPattern, + QuorumPreprocessor, + StartEndPreprocessor, + VotePreprocessor, +) + + +class TestMarkdownExtensions(TestCase): + def test_vote_preprocessor(self): + vp = VotePreprocessor() + text = ["[2|3|4]", "[a|b|c]"] # Second line is not processed + result = vp.run(text) + self.assertEqual(result, ["**[2|3|4]**", "[a|b|c]"]) + + def test_start_end_preprocessor(self): + activate("en") + sep = StartEndPreprocessor() + text = ["|start|(12:00)", "|end|(16:00)"] + result = sep.run(text) + self.assertEqual(result, ["*Begin of meeting: 12:00* ", "*End of meeting: 16:00* "]) + + def test_break_preprocessor(self): + activate("en") + bp = BreakPreprocessor() + text = ["|break|(12:00)(13:00)"] + result = bp.run(text) + self.assertEqual(result, ["*Meeting break: 12:00 – 13:00*"]) + + def test_quorum_preprocessor(self): + activate("en") + qp = QuorumPreprocessor() + text = ["|quorum|(3/8)", "|quorum|(4/8)"] + result = qp.run(text) + self.assertEqual(result, ["*3/8 present → not quorate* ", "*4/8 present → quorate* "]) + + def test_enter_leave_preprocessor(self): + activate("en") + elp = EnterLeavePreprocessor() + text = [ + "|enter|(12:00)(First Last)", + "|enter|(12:00)(Prof. First Last)", + "|enter|(12:00)(Prof. First Last)(Means)", + "|leave|(12:00)(Prof. First Last)", + ] + result = elp.run(text) + self.assertEqual( + result, + [ + "*12:00: First Last enters the meeting* ", + "*12:00: Prof. First Last enters the meeting* ", + "*12:00: Prof. First Last enters the meeting via Means* ", + "*12:00: Prof. First Last leaves the meeting* ", + ], + ) + + def test_heading_level_preprocessor(self): + hlp = HeadingLevelPreprocessor() + text = [ + "# Heading 1", + "## Heading 2", + "### Heading 3", + "#### Heading 4", + "##### Heading 5", + "###### Heading 6", + ] + result = hlp.run(text) + self.assertEqual( + result, + [ + "## Heading 1", + "### Heading 2", + "#### Heading 3", + "##### Heading 4", + "###### Heading 5", + "###### Heading 6", + ], + ) + + def test_internal_link_preprocessor(self): + ilp = InternalLinkPattern(InternalLinkPattern.default_pattern()) + + from myhpi.tests.core.setup import setup_data + + test_data = setup_data() + test_page = test_data["pages"][0] + text = f"[Page title](page:{test_page.id})" + el, _, _ = ilp.handleMatch(re.match(ilp.pattern, text)) + self.assertEqual(el.attrib["href"], test_page.localized.get_url()) + + def test_image_pattern(self): + activate("en") + from django.core.files.uploadedfile import SimpleUploadedFile + + ip = ImagePattern(ImagePattern.default_pattern()) + + image_file = SimpleUploadedFile( + name="test_image.jpg", + content=open("myhpi/tests/files/test_image.jpg", "rb").read(), + content_type="image/jpeg", + ) + + image = Image.objects.create( + title="Test image", + file=image_file, + ) + + text = f"![Alt text](image:{image.id})" + invalid_text = "![Alt text](image:1234567890)" + el, _, _ = ip.handleMatch(re.match(ip.pattern, text)) + self.assertEqual(el.attrib["src"], image.get_rendition("width-800").url) + + el, _, _ = ip.handleMatch(re.match(ip.pattern, invalid_text)) + self.assertEqual(el.text, "[missing image]") diff --git a/myhpi/tests/test_minutes_extensions.py b/myhpi/tests/test_minutes_extensions.py deleted file mode 100644 index 0bb0e118..00000000 --- a/myhpi/tests/test_minutes_extensions.py +++ /dev/null @@ -1,29 +0,0 @@ -import django -from django.test import TestCase -from django.utils.translation import activate - -django.setup() - -from myhpi.core.markdown.extensions import EnterLeavePreprocessor - - -class TestMinuteExtensions(TestCase): - def test_enter(self): - activate("en") - elp = EnterLeavePreprocessor() - text = [ - "|enter|(12:00)(First Last)", - "|enter|(12:00)(Prof. First Last)", - "|enter|(12:00)(Prof. First Last)(Means)", - "|leave|(12:00)(Prof. First Last)", - ] - result = elp.run(text) - self.assertEqual( - result, - [ - "*12:00: First Last enters the meeting* ", - "*12:00: Prof. First Last enters the meeting* ", - "*12:00: Prof. First Last enters the meeting via Means* ", - "*12:00: Prof. First Last leaves the meeting* ", - ], - )