Skip to content

Commit da8b489

Browse files
authored
Increase test coverage in Hypha (#4800)
LLMs are really good at creating tests. I cleaned out the ones I found to be pointless. Some unneeded are likely remaning but that is a very minor issue. This increased the test coverage significantly. Most important I believe is test around fund and project permissions.
1 parent 603e7f4 commit da8b489

39 files changed

Lines changed: 6416 additions & 830 deletions

.github/workflows/hypha-ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ jobs:
102102
103103
strategy:
104104
matrix:
105-
group: [1, 2, 3]
105+
group: [1, 2, 3, 4]
106106

107107
steps:
108108
- uses: actions/checkout@v6
@@ -120,4 +120,4 @@ jobs:
120120

121121
- name: Run pytest
122122
run: |
123-
uv run pytest --splits 3 --group ${{ matrix.group }} --durations-path=.test_durations
123+
uv run pytest --splits 4 --group ${{ matrix.group }} --durations-path=.test_durations

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ coverage.xml
9797
*.cover
9898
.hypothesis/
9999
__coverage__/
100+
coverage.json
100101

101102
# macOS
102103
.DS_Store

.test_durations

Lines changed: 1435 additions & 810 deletions
Large diffs are not rendered by default.

hypha/api/v2/tests.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import json
2+
3+
from django.test import TestCase
4+
5+
from hypha.apply.funds.tests.factories import (
6+
FundTypeFactory,
7+
LabFactory,
8+
RoundFactory,
9+
TodayRoundFactory,
10+
)
11+
from hypha.home.factories import ApplySiteFactory
12+
13+
OPEN_CALLS_URL = "/api/v2/open-calls.json"
14+
15+
# Default rate limit is 5/m — one more than the limit triggers a 403.
16+
RATE_LIMIT = 5
17+
18+
19+
class TestOpenCallsJson(TestCase):
20+
def setUp(self):
21+
ApplySiteFactory()
22+
23+
def test_returns_200_for_anonymous_user(self):
24+
response = self.client.get(OPEN_CALLS_URL)
25+
self.assertEqual(response.status_code, 200)
26+
27+
def test_content_type_is_json(self):
28+
response = self.client.get(OPEN_CALLS_URL)
29+
self.assertIn("application/json", response["Content-Type"])
30+
31+
def test_response_is_valid_json_list(self):
32+
response = self.client.get(OPEN_CALLS_URL)
33+
data = json.loads(response.content)
34+
self.assertIsInstance(data, list)
35+
36+
def test_returns_empty_list_with_no_open_funds(self):
37+
response = self.client.get(OPEN_CALLS_URL)
38+
data = json.loads(response.content)
39+
self.assertEqual(data, [])
40+
41+
def test_includes_fund_with_open_round(self):
42+
fund = FundTypeFactory(parent=None, list_on_front_page=True, description="desc")
43+
TodayRoundFactory(parent=fund)
44+
response = self.client.get(OPEN_CALLS_URL)
45+
data = json.loads(response.content)
46+
titles = [item["title"] for item in data]
47+
self.assertIn(fund.title, titles)
48+
49+
def test_excludes_fund_with_no_open_round(self):
50+
fund = FundTypeFactory(parent=None, list_on_front_page=True, description="desc")
51+
RoundFactory(parent=fund, closed=True)
52+
response = self.client.get(OPEN_CALLS_URL)
53+
data = json.loads(response.content)
54+
titles = [item["title"] for item in data]
55+
self.assertNotIn(fund.title, titles)
56+
57+
def test_excludes_fund_not_listed_on_front_page(self):
58+
fund = FundTypeFactory(
59+
parent=None, list_on_front_page=False, description="desc"
60+
)
61+
TodayRoundFactory(parent=fund)
62+
response = self.client.get(OPEN_CALLS_URL)
63+
data = json.loads(response.content)
64+
titles = [item["title"] for item in data]
65+
self.assertNotIn(fund.title, titles)
66+
67+
def test_includes_live_lab(self):
68+
lab = LabFactory(parent=None, list_on_front_page=True, description="desc")
69+
response = self.client.get(OPEN_CALLS_URL)
70+
data = json.loads(response.content)
71+
titles = [item["title"] for item in data]
72+
self.assertIn(lab.title, titles)
73+
74+
def test_excludes_lab_not_listed_on_front_page(self):
75+
LabFactory(parent=None, list_on_front_page=False, description="desc")
76+
response = self.client.get(OPEN_CALLS_URL)
77+
data = json.loads(response.content)
78+
self.assertEqual(data, [])
79+
80+
def test_response_items_have_expected_keys(self):
81+
fund = FundTypeFactory(parent=None, list_on_front_page=True, description="desc")
82+
TodayRoundFactory(parent=fund)
83+
response = self.client.get(OPEN_CALLS_URL)
84+
data = json.loads(response.content)
85+
self.assertEqual(len(data), 1)
86+
item = data[0]
87+
for key in ("title", "description", "image", "weight", "next_deadline", "url"):
88+
self.assertIn(key, item)
89+
90+
def test_response_does_not_expose_private_fields(self):
91+
fund = FundTypeFactory(parent=None, list_on_front_page=True, description="desc")
92+
TodayRoundFactory(parent=fund)
93+
response = self.client.get(OPEN_CALLS_URL)
94+
data = json.loads(response.content)
95+
item = data[0]
96+
for private_field in ("slack_channel", "activity_digest_recipient_emails"):
97+
self.assertNotIn(private_field, item)
98+
99+
100+
class TestOpenCallsJsonRateLimit(TestCase):
101+
"""The open-calls endpoint is rate-limited by IP on all HTTP methods."""
102+
103+
def setUp(self):
104+
ApplySiteFactory()
105+
106+
def test_accessible_before_limit(self):
107+
response = self.client.get(OPEN_CALLS_URL)
108+
self.assertEqual(response.status_code, 200)
109+
110+
def test_blocked_after_ip_limit_exceeded(self):
111+
for _ in range(RATE_LIMIT):
112+
self.client.get(OPEN_CALLS_URL)
113+
response = self.client.get(OPEN_CALLS_URL)
114+
self.assertEqual(response.status_code, 403)
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Tests for activity/templatetags/activity_tags.py."""
2+
3+
from django.test import SimpleTestCase, TestCase, override_settings
4+
5+
from hypha.apply.users.tests.factories import ApplicantFactory, StaffFactory
6+
7+
from ..models import ALL, REVIEWER, TEAM
8+
from ..templatetags.activity_tags import (
9+
email_name,
10+
lowerfirst,
11+
source_type,
12+
visibility_display,
13+
)
14+
15+
16+
class TestLowerfirst(SimpleTestCase):
17+
def test_lowercases_first_character(self):
18+
self.assertEqual(lowerfirst("Hello"), "hello")
19+
20+
def test_preserves_rest_of_string(self):
21+
self.assertEqual(lowerfirst("Hello World"), "hello World")
22+
23+
def test_already_lowercase_unchanged(self):
24+
self.assertEqual(lowerfirst("hello"), "hello")
25+
26+
def test_empty_string_returns_empty(self):
27+
self.assertEqual(lowerfirst(""), "")
28+
29+
def test_single_character(self):
30+
self.assertEqual(lowerfirst("A"), "a")
31+
32+
33+
class TestEmailName(SimpleTestCase):
34+
def test_extracts_username_from_email(self):
35+
self.assertEqual(email_name("alice@example.com"), "alice")
36+
37+
def test_returns_string_unchanged_if_no_at_sign(self):
38+
self.assertEqual(email_name("alice"), "alice")
39+
40+
def test_returns_non_string_unchanged(self):
41+
self.assertEqual(email_name(42), 42)
42+
43+
def test_returns_none_unchanged(self):
44+
self.assertIsNone(email_name(None))
45+
46+
def test_extracts_first_part_only(self):
47+
self.assertEqual(email_name("first.last@domain.org"), "first.last")
48+
49+
50+
class TestSourceType(SimpleTestCase):
51+
def test_submission_variant_returns_submission(self):
52+
self.assertEqual(source_type("application submission"), "Submission")
53+
54+
def test_exact_submission_returns_submission(self):
55+
self.assertEqual(source_type("submission"), "Submission")
56+
57+
def test_non_submission_capitalizes_first_letter(self):
58+
self.assertEqual(source_type("project"), "Project")
59+
60+
def test_none_returns_none_string(self):
61+
self.assertEqual(source_type(None), "None")
62+
63+
def test_empty_string_returns_empty(self):
64+
self.assertEqual(source_type(""), "")
65+
66+
67+
class TestVisibilityDisplay(TestCase):
68+
def test_team_visibility_for_staff_returns_team(self):
69+
staff = StaffFactory()
70+
result = visibility_display(TEAM, staff)
71+
self.assertEqual(result, TEAM)
72+
73+
@override_settings(ORG_SHORT_NAME="OTF")
74+
def test_team_visibility_for_applicant_includes_org_name(self):
75+
applicant = ApplicantFactory()
76+
result = visibility_display(TEAM, applicant)
77+
self.assertIn("OTF", result)
78+
self.assertIn(str(TEAM), result)
79+
80+
def test_all_visibility_returns_all(self):
81+
staff = StaffFactory()
82+
result = visibility_display(ALL, staff)
83+
self.assertEqual(result, ALL)
84+
85+
def test_reviewer_visibility_for_staff_includes_team(self):
86+
staff = StaffFactory()
87+
result = visibility_display(REVIEWER, staff)
88+
# REVIEWER is not TEAM or ALL → "reviewers + team"
89+
self.assertIn(str(REVIEWER), result)
90+
self.assertIn(str(TEAM), result)
91+
92+
@override_settings(ORG_SHORT_NAME="OTF")
93+
def test_reviewer_visibility_for_applicant_includes_org_team(self):
94+
applicant = ApplicantFactory()
95+
result = visibility_display(REVIEWER, applicant)
96+
self.assertIn(str(REVIEWER), result)
97+
self.assertIn("OTF", result)
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""Tests for activity/adapters/utils.py pure functions."""
2+
3+
from django.test import SimpleTestCase, TestCase
4+
5+
from hypha.apply.activity.options import MESSAGES
6+
from hypha.apply.projects.models.payment import (
7+
APPROVED_BY_FINANCE,
8+
APPROVED_BY_STAFF,
9+
CHANGES_REQUESTED_BY_STAFF,
10+
DECLINED,
11+
PAID,
12+
PAYMENT_FAILED,
13+
RESUBMITTED,
14+
SUBMITTED,
15+
)
16+
17+
from ..adapters.utils import (
18+
group_reviewers,
19+
is_invoice_public_transition,
20+
is_ready_for_review,
21+
is_reviewer_update,
22+
is_transition,
23+
reviewers_message,
24+
)
25+
26+
27+
class TestIsTransition(SimpleTestCase):
28+
def test_transition_message_returns_true(self):
29+
self.assertTrue(is_transition(MESSAGES.TRANSITION))
30+
31+
def test_batch_transition_returns_true(self):
32+
self.assertTrue(is_transition(MESSAGES.BATCH_TRANSITION))
33+
34+
def test_comment_message_returns_false(self):
35+
self.assertFalse(is_transition(MESSAGES.COMMENT))
36+
37+
def test_new_submission_returns_false(self):
38+
self.assertFalse(is_transition(MESSAGES.NEW_SUBMISSION))
39+
40+
41+
class TestIsReadyForReview(SimpleTestCase):
42+
def test_ready_for_review_returns_true(self):
43+
self.assertTrue(is_ready_for_review(MESSAGES.READY_FOR_REVIEW))
44+
45+
def test_batch_ready_for_review_returns_true(self):
46+
self.assertTrue(is_ready_for_review(MESSAGES.BATCH_READY_FOR_REVIEW))
47+
48+
def test_transition_returns_false(self):
49+
self.assertFalse(is_ready_for_review(MESSAGES.TRANSITION))
50+
51+
52+
class TestIsReviewerUpdate(SimpleTestCase):
53+
def test_reviewers_updated_returns_true(self):
54+
self.assertTrue(is_reviewer_update(MESSAGES.REVIEWERS_UPDATED))
55+
56+
def test_batch_reviewers_updated_returns_true(self):
57+
self.assertTrue(is_reviewer_update(MESSAGES.BATCH_REVIEWERS_UPDATED))
58+
59+
def test_comment_returns_false(self):
60+
self.assertFalse(is_reviewer_update(MESSAGES.COMMENT))
61+
62+
63+
class TestIsInvoicePublicTransition(TestCase):
64+
def _invoice(self, status):
65+
from unittest.mock import MagicMock
66+
67+
invoice = MagicMock()
68+
invoice.status = status
69+
return invoice
70+
71+
def test_submitted_is_public(self):
72+
self.assertTrue(is_invoice_public_transition(self._invoice(SUBMITTED)))
73+
74+
def test_resubmitted_is_public(self):
75+
self.assertTrue(is_invoice_public_transition(self._invoice(RESUBMITTED)))
76+
77+
def test_changes_requested_is_public(self):
78+
self.assertTrue(
79+
is_invoice_public_transition(self._invoice(CHANGES_REQUESTED_BY_STAFF))
80+
)
81+
82+
def test_declined_is_public(self):
83+
self.assertTrue(is_invoice_public_transition(self._invoice(DECLINED)))
84+
85+
def test_paid_is_public(self):
86+
self.assertTrue(is_invoice_public_transition(self._invoice(PAID)))
87+
88+
def test_payment_failed_is_public(self):
89+
self.assertTrue(is_invoice_public_transition(self._invoice(PAYMENT_FAILED)))
90+
91+
def test_approved_by_finance_is_public(self):
92+
self.assertTrue(
93+
is_invoice_public_transition(self._invoice(APPROVED_BY_FINANCE))
94+
)
95+
96+
def test_approved_by_staff_is_not_public(self):
97+
self.assertFalse(is_invoice_public_transition(self._invoice(APPROVED_BY_STAFF)))
98+
99+
100+
class TestGroupReviewers(SimpleTestCase):
101+
def _reviewer(self, role, name):
102+
from unittest.mock import MagicMock
103+
104+
r = MagicMock()
105+
r.role = role
106+
r.reviewer = name
107+
return r
108+
109+
def test_groups_by_role(self):
110+
reviewers = [
111+
self._reviewer("lead", "Alice"),
112+
self._reviewer("lead", "Bob"),
113+
self._reviewer("external", "Carol"),
114+
]
115+
groups = group_reviewers(reviewers)
116+
self.assertEqual(len(groups["lead"]), 2)
117+
self.assertEqual(len(groups["external"]), 1)
118+
119+
def test_empty_list_returns_empty_dict(self):
120+
groups = group_reviewers([])
121+
self.assertEqual(dict(groups), {})
122+
123+
def test_none_role_is_valid_key(self):
124+
reviewers = [self._reviewer(None, "Alice")]
125+
groups = group_reviewers(reviewers)
126+
self.assertIn(None, groups)
127+
128+
129+
class TestReviewersMessage(SimpleTestCase):
130+
def _reviewer(self, role, name):
131+
from unittest.mock import MagicMock
132+
133+
r = MagicMock()
134+
r.role = role
135+
r.reviewer.__str__ = lambda self: name
136+
return r
137+
138+
def test_reviewer_without_role_no_role_suffix(self):
139+
reviewers = [self._reviewer(None, "Alice")]
140+
messages = reviewers_message(reviewers)
141+
self.assertEqual(len(messages), 1)
142+
self.assertIn("Alice", messages[0])
143+
self.assertNotIn(" as ", messages[0])
144+
145+
def test_reviewer_with_role_includes_suffix(self):
146+
reviewers = [self._reviewer("Lead", "Bob")]
147+
messages = reviewers_message(reviewers)
148+
self.assertEqual(len(messages), 1)
149+
self.assertIn(" as Lead", messages[0])
150+
151+
def test_multiple_roles_produce_multiple_messages(self):
152+
reviewers = [
153+
self._reviewer("Lead", "Alice"),
154+
self._reviewer("External", "Bob"),
155+
]
156+
messages = reviewers_message(reviewers)
157+
self.assertEqual(len(messages), 2)
158+
159+
def test_empty_reviewers_returns_empty_list(self):
160+
self.assertEqual(reviewers_message([]), [])

0 commit comments

Comments
 (0)