Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/hypha-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ jobs:

strategy:
matrix:
group: [1, 2, 3]
group: [1, 2, 3, 4]

steps:
- uses: actions/checkout@v6
Expand All @@ -120,4 +120,4 @@ jobs:

- name: Run pytest
run: |
uv run pytest --splits 3 --group ${{ matrix.group }} --durations-path=.test_durations
uv run pytest --splits 4 --group ${{ matrix.group }} --durations-path=.test_durations
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ coverage.xml
*.cover
.hypothesis/
__coverage__/
coverage.json

# macOS
.DS_Store
Expand Down
2,245 changes: 1,435 additions & 810 deletions .test_durations

Large diffs are not rendered by default.

114 changes: 114 additions & 0 deletions hypha/api/v2/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import json

from django.test import TestCase

from hypha.apply.funds.tests.factories import (
FundTypeFactory,
LabFactory,
RoundFactory,
TodayRoundFactory,
)
from hypha.home.factories import ApplySiteFactory

OPEN_CALLS_URL = "/api/v2/open-calls.json"

# Default rate limit is 5/m — one more than the limit triggers a 403.
RATE_LIMIT = 5


class TestOpenCallsJson(TestCase):
def setUp(self):
ApplySiteFactory()

def test_returns_200_for_anonymous_user(self):
response = self.client.get(OPEN_CALLS_URL)
self.assertEqual(response.status_code, 200)

def test_content_type_is_json(self):
response = self.client.get(OPEN_CALLS_URL)
self.assertIn("application/json", response["Content-Type"])

def test_response_is_valid_json_list(self):
response = self.client.get(OPEN_CALLS_URL)
data = json.loads(response.content)
self.assertIsInstance(data, list)

def test_returns_empty_list_with_no_open_funds(self):
response = self.client.get(OPEN_CALLS_URL)
data = json.loads(response.content)
self.assertEqual(data, [])

def test_includes_fund_with_open_round(self):
fund = FundTypeFactory(parent=None, list_on_front_page=True, description="desc")
TodayRoundFactory(parent=fund)
response = self.client.get(OPEN_CALLS_URL)
data = json.loads(response.content)
titles = [item["title"] for item in data]
self.assertIn(fund.title, titles)

def test_excludes_fund_with_no_open_round(self):
fund = FundTypeFactory(parent=None, list_on_front_page=True, description="desc")
RoundFactory(parent=fund, closed=True)
response = self.client.get(OPEN_CALLS_URL)
data = json.loads(response.content)
titles = [item["title"] for item in data]
self.assertNotIn(fund.title, titles)

def test_excludes_fund_not_listed_on_front_page(self):
fund = FundTypeFactory(
parent=None, list_on_front_page=False, description="desc"
)
TodayRoundFactory(parent=fund)
response = self.client.get(OPEN_CALLS_URL)
data = json.loads(response.content)
titles = [item["title"] for item in data]
self.assertNotIn(fund.title, titles)

def test_includes_live_lab(self):
lab = LabFactory(parent=None, list_on_front_page=True, description="desc")
response = self.client.get(OPEN_CALLS_URL)
data = json.loads(response.content)
titles = [item["title"] for item in data]
self.assertIn(lab.title, titles)

def test_excludes_lab_not_listed_on_front_page(self):
LabFactory(parent=None, list_on_front_page=False, description="desc")
response = self.client.get(OPEN_CALLS_URL)
data = json.loads(response.content)
self.assertEqual(data, [])

def test_response_items_have_expected_keys(self):
fund = FundTypeFactory(parent=None, list_on_front_page=True, description="desc")
TodayRoundFactory(parent=fund)
response = self.client.get(OPEN_CALLS_URL)
data = json.loads(response.content)
self.assertEqual(len(data), 1)
item = data[0]
for key in ("title", "description", "image", "weight", "next_deadline", "url"):
self.assertIn(key, item)

def test_response_does_not_expose_private_fields(self):
fund = FundTypeFactory(parent=None, list_on_front_page=True, description="desc")
TodayRoundFactory(parent=fund)
response = self.client.get(OPEN_CALLS_URL)
data = json.loads(response.content)
item = data[0]
for private_field in ("slack_channel", "activity_digest_recipient_emails"):
self.assertNotIn(private_field, item)


class TestOpenCallsJsonRateLimit(TestCase):
"""The open-calls endpoint is rate-limited by IP on all HTTP methods."""

def setUp(self):
ApplySiteFactory()

def test_accessible_before_limit(self):
response = self.client.get(OPEN_CALLS_URL)
self.assertEqual(response.status_code, 200)

def test_blocked_after_ip_limit_exceeded(self):
for _ in range(RATE_LIMIT):
self.client.get(OPEN_CALLS_URL)
response = self.client.get(OPEN_CALLS_URL)
self.assertEqual(response.status_code, 403)
97 changes: 97 additions & 0 deletions hypha/apply/activity/tests/test_activity_tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Tests for activity/templatetags/activity_tags.py."""

from django.test import SimpleTestCase, TestCase, override_settings

from hypha.apply.users.tests.factories import ApplicantFactory, StaffFactory

from ..models import ALL, REVIEWER, TEAM
from ..templatetags.activity_tags import (
email_name,
lowerfirst,
source_type,
visibility_display,
)


class TestLowerfirst(SimpleTestCase):
def test_lowercases_first_character(self):
self.assertEqual(lowerfirst("Hello"), "hello")

def test_preserves_rest_of_string(self):
self.assertEqual(lowerfirst("Hello World"), "hello World")

def test_already_lowercase_unchanged(self):
self.assertEqual(lowerfirst("hello"), "hello")

def test_empty_string_returns_empty(self):
self.assertEqual(lowerfirst(""), "")

def test_single_character(self):
self.assertEqual(lowerfirst("A"), "a")


class TestEmailName(SimpleTestCase):
def test_extracts_username_from_email(self):
self.assertEqual(email_name("alice@example.com"), "alice")

def test_returns_string_unchanged_if_no_at_sign(self):
self.assertEqual(email_name("alice"), "alice")

def test_returns_non_string_unchanged(self):
self.assertEqual(email_name(42), 42)

def test_returns_none_unchanged(self):
self.assertIsNone(email_name(None))

def test_extracts_first_part_only(self):
self.assertEqual(email_name("first.last@domain.org"), "first.last")


class TestSourceType(SimpleTestCase):
def test_submission_variant_returns_submission(self):
self.assertEqual(source_type("application submission"), "Submission")

def test_exact_submission_returns_submission(self):
self.assertEqual(source_type("submission"), "Submission")

def test_non_submission_capitalizes_first_letter(self):
self.assertEqual(source_type("project"), "Project")

def test_none_returns_none_string(self):
self.assertEqual(source_type(None), "None")

def test_empty_string_returns_empty(self):
self.assertEqual(source_type(""), "")


class TestVisibilityDisplay(TestCase):
def test_team_visibility_for_staff_returns_team(self):
staff = StaffFactory()
result = visibility_display(TEAM, staff)
self.assertEqual(result, TEAM)

@override_settings(ORG_SHORT_NAME="OTF")
def test_team_visibility_for_applicant_includes_org_name(self):
applicant = ApplicantFactory()
result = visibility_display(TEAM, applicant)
self.assertIn("OTF", result)
self.assertIn(str(TEAM), result)

def test_all_visibility_returns_all(self):
staff = StaffFactory()
result = visibility_display(ALL, staff)
self.assertEqual(result, ALL)

def test_reviewer_visibility_for_staff_includes_team(self):
staff = StaffFactory()
result = visibility_display(REVIEWER, staff)
# REVIEWER is not TEAM or ALL → "reviewers + team"
self.assertIn(str(REVIEWER), result)
self.assertIn(str(TEAM), result)

@override_settings(ORG_SHORT_NAME="OTF")
def test_reviewer_visibility_for_applicant_includes_org_team(self):
applicant = ApplicantFactory()
result = visibility_display(REVIEWER, applicant)
self.assertIn(str(REVIEWER), result)
self.assertIn("OTF", result)
160 changes: 160 additions & 0 deletions hypha/apply/activity/tests/test_adapter_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""Tests for activity/adapters/utils.py pure functions."""

from django.test import SimpleTestCase, TestCase

from hypha.apply.activity.options import MESSAGES
from hypha.apply.projects.models.payment import (
APPROVED_BY_FINANCE,
APPROVED_BY_STAFF,
CHANGES_REQUESTED_BY_STAFF,
DECLINED,
PAID,
PAYMENT_FAILED,
RESUBMITTED,
SUBMITTED,
)

from ..adapters.utils import (
group_reviewers,
is_invoice_public_transition,
is_ready_for_review,
is_reviewer_update,
is_transition,
reviewers_message,
)


class TestIsTransition(SimpleTestCase):
def test_transition_message_returns_true(self):
self.assertTrue(is_transition(MESSAGES.TRANSITION))

def test_batch_transition_returns_true(self):
self.assertTrue(is_transition(MESSAGES.BATCH_TRANSITION))

def test_comment_message_returns_false(self):
self.assertFalse(is_transition(MESSAGES.COMMENT))

def test_new_submission_returns_false(self):
self.assertFalse(is_transition(MESSAGES.NEW_SUBMISSION))


class TestIsReadyForReview(SimpleTestCase):
def test_ready_for_review_returns_true(self):
self.assertTrue(is_ready_for_review(MESSAGES.READY_FOR_REVIEW))

def test_batch_ready_for_review_returns_true(self):
self.assertTrue(is_ready_for_review(MESSAGES.BATCH_READY_FOR_REVIEW))

def test_transition_returns_false(self):
self.assertFalse(is_ready_for_review(MESSAGES.TRANSITION))


class TestIsReviewerUpdate(SimpleTestCase):
def test_reviewers_updated_returns_true(self):
self.assertTrue(is_reviewer_update(MESSAGES.REVIEWERS_UPDATED))

def test_batch_reviewers_updated_returns_true(self):
self.assertTrue(is_reviewer_update(MESSAGES.BATCH_REVIEWERS_UPDATED))

def test_comment_returns_false(self):
self.assertFalse(is_reviewer_update(MESSAGES.COMMENT))


class TestIsInvoicePublicTransition(TestCase):
def _invoice(self, status):
from unittest.mock import MagicMock

invoice = MagicMock()
invoice.status = status
return invoice

def test_submitted_is_public(self):
self.assertTrue(is_invoice_public_transition(self._invoice(SUBMITTED)))

def test_resubmitted_is_public(self):
self.assertTrue(is_invoice_public_transition(self._invoice(RESUBMITTED)))

def test_changes_requested_is_public(self):
self.assertTrue(
is_invoice_public_transition(self._invoice(CHANGES_REQUESTED_BY_STAFF))
)

def test_declined_is_public(self):
self.assertTrue(is_invoice_public_transition(self._invoice(DECLINED)))

def test_paid_is_public(self):
self.assertTrue(is_invoice_public_transition(self._invoice(PAID)))

def test_payment_failed_is_public(self):
self.assertTrue(is_invoice_public_transition(self._invoice(PAYMENT_FAILED)))

def test_approved_by_finance_is_public(self):
self.assertTrue(
is_invoice_public_transition(self._invoice(APPROVED_BY_FINANCE))
)

def test_approved_by_staff_is_not_public(self):
self.assertFalse(is_invoice_public_transition(self._invoice(APPROVED_BY_STAFF)))


class TestGroupReviewers(SimpleTestCase):
def _reviewer(self, role, name):
from unittest.mock import MagicMock

r = MagicMock()
r.role = role
r.reviewer = name
return r

def test_groups_by_role(self):
reviewers = [
self._reviewer("lead", "Alice"),
self._reviewer("lead", "Bob"),
self._reviewer("external", "Carol"),
]
groups = group_reviewers(reviewers)
self.assertEqual(len(groups["lead"]), 2)
self.assertEqual(len(groups["external"]), 1)

def test_empty_list_returns_empty_dict(self):
groups = group_reviewers([])
self.assertEqual(dict(groups), {})

def test_none_role_is_valid_key(self):
reviewers = [self._reviewer(None, "Alice")]
groups = group_reviewers(reviewers)
self.assertIn(None, groups)


class TestReviewersMessage(SimpleTestCase):
def _reviewer(self, role, name):
from unittest.mock import MagicMock

r = MagicMock()
r.role = role
r.reviewer.__str__ = lambda self: name
return r

def test_reviewer_without_role_no_role_suffix(self):
reviewers = [self._reviewer(None, "Alice")]
messages = reviewers_message(reviewers)
self.assertEqual(len(messages), 1)
self.assertIn("Alice", messages[0])
self.assertNotIn(" as ", messages[0])

def test_reviewer_with_role_includes_suffix(self):
reviewers = [self._reviewer("Lead", "Bob")]
messages = reviewers_message(reviewers)
self.assertEqual(len(messages), 1)
self.assertIn(" as Lead", messages[0])

def test_multiple_roles_produce_multiple_messages(self):
reviewers = [
self._reviewer("Lead", "Alice"),
self._reviewer("External", "Bob"),
]
messages = reviewers_message(reviewers)
self.assertEqual(len(messages), 2)

def test_empty_reviewers_returns_empty_list(self):
self.assertEqual(reviewers_message([]), [])
Loading
Loading