From 29c4ba989d77594f3d88d10920aa9b84b08f999d Mon Sep 17 00:00:00 2001
From: mrkeshav-05
Date: Sat, 11 Apr 2026 03:20:53 +0530
Subject: [PATCH 1/5] edit sponsor node
---
backend/apps/owasp/api/internal/nodes/sponsor.py | 1 +
backend/apps/owasp/api/internal/queries/sponsor.py | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/backend/apps/owasp/api/internal/nodes/sponsor.py b/backend/apps/owasp/api/internal/nodes/sponsor.py
index c66cb269b3..e88186a6eb 100644
--- a/backend/apps/owasp/api/internal/nodes/sponsor.py
+++ b/backend/apps/owasp/api/internal/nodes/sponsor.py
@@ -9,6 +9,7 @@
@strawberry_django.type(
Sponsor,
fields=[
+ "description",
"image_url",
"name",
"sponsor_type",
diff --git a/backend/apps/owasp/api/internal/queries/sponsor.py b/backend/apps/owasp/api/internal/queries/sponsor.py
index 80014e529f..c1f4476918 100644
--- a/backend/apps/owasp/api/internal/queries/sponsor.py
+++ b/backend/apps/owasp/api/internal/queries/sponsor.py
@@ -15,7 +15,7 @@ class SponsorQuery:
def sponsors(self) -> list[SponsorNode]:
"""Resolve sponsors."""
return sorted(
- Sponsor.objects.all(),
+ Sponsor.objects.filter(status=Sponsor.SponsorStatus.ACTIVE),
key=lambda x: {
Sponsor.SponsorType.DIAMOND: 1,
Sponsor.SponsorType.PLATINUM: 2,
From 684ce403be83440b5549d2aff31fe43323e1a8e7 Mon Sep 17 00:00:00 2001
From: mrkeshav-05
Date: Sun, 12 Apr 2026 14:38:40 +0530
Subject: [PATCH 2/5] update code
---
backend/apps/api/rest/v0/sponsor.py | 94 +++++-
backend/apps/owasp/admin/sponsor.py | 22 ++
.../0073_sponsor_status_email_entity_fks.py | 61 ++++
backend/apps/owasp/models/sponsor.py | 32 ++
.../unit/apps/api/rest/v0/sponsor_test.py | 106 +++++-
frontend/src/app/sponsors/apply/page.tsx | 306 ++++++++++++++++++
frontend/src/app/sponsors/layout.tsx | 9 +
frontend/src/app/sponsors/page.tsx | 228 +++++++++++++
frontend/src/components/LogoCarousel.tsx | 7 +-
frontend/src/server/queries/homeQueries.ts | 1 +
frontend/src/server/queries/sponsorQueries.ts | 14 +
frontend/src/types/__generated__/graphql.ts | 1 +
.../__generated__/homeQueries.generated.ts | 4 +-
.../__generated__/sponsorQueries.generated.ts | 10 +
frontend/src/types/home.ts | 1 +
frontend/src/utils/constants.ts | 4 +-
frontend/src/utils/metadata.ts | 7 +
17 files changed, 891 insertions(+), 16 deletions(-)
create mode 100644 backend/apps/owasp/migrations/0073_sponsor_status_email_entity_fks.py
create mode 100644 frontend/src/app/sponsors/apply/page.tsx
create mode 100644 frontend/src/app/sponsors/layout.tsx
create mode 100644 frontend/src/app/sponsors/page.tsx
create mode 100644 frontend/src/server/queries/sponsorQueries.ts
create mode 100644 frontend/src/types/__generated__/sponsorQueries.generated.ts
diff --git a/backend/apps/api/rest/v0/sponsor.py b/backend/apps/api/rest/v0/sponsor.py
index 4641e7639c..ed6a19f1a2 100644
--- a/backend/apps/api/rest/v0/sponsor.py
+++ b/backend/apps/api/rest/v0/sponsor.py
@@ -11,6 +11,7 @@
from apps.api.decorators.cache import cache_response
from apps.api.rest.v0.common import ValidationErrorSchema
+from apps.common.utils import slugify
from apps.owasp.models.sponsor import Sponsor as SponsorModel
router = RouterPaginated(tags=["Sponsors"])
@@ -19,6 +20,7 @@
class SponsorBase(Schema):
"""Base schema for Sponsor (used in list endpoints)."""
+ description: str
image_url: str
key: str
name: str
@@ -33,10 +35,10 @@ class Sponsor(SponsorBase):
class SponsorDetail(SponsorBase):
"""Detail schema for Sponsor (used in single item endpoints)."""
- description: str
is_member: bool
job_url: str
member_type: str
+ status: str
class SponsorError(Schema):
@@ -45,6 +47,22 @@ class SponsorError(Schema):
message: str
+class SponsorApplication(Schema):
+ """Schema for sponsor application form submission."""
+
+ organization_name: str = Field(..., description="Name of the sponsoring organization")
+ website: str = Field("", description="Organization website URL")
+ contact_email: str = Field(..., description="Contact email address")
+ message: str = Field("", description="Sponsorship interest or message")
+
+
+class SponsorApplicationResponse(Schema):
+ """Response schema for sponsor application."""
+
+ message: str
+ key: str
+
+
class SponsorFilter(FilterSchema):
"""Filter for Sponsor."""
@@ -63,6 +81,11 @@ class SponsorFilter(FilterSchema):
example="Silver",
)
+ status: str | None = Field(
+ None,
+ description="Filter by sponsor status (draft, active, archived). Defaults to active.",
+ )
+
@router.get(
"/",
@@ -81,7 +104,74 @@ def list_sponsors(
),
) -> list[Sponsor]:
"""Get sponsors."""
- return filters.filter(SponsorModel.objects.order_by(ordering or "name"))
+ qs = SponsorModel.objects.order_by(ordering or "name")
+ if filters.status is None:
+ qs = qs.filter(status=SponsorModel.SponsorStatus.ACTIVE)
+
+ return filters.filter(qs)
+
+
+@router.get(
+ "/nest",
+ description="Retrieve active OWASP Nest sponsors for external integrations.",
+ operation_id="list_nest_sponsors",
+ response=list[Sponsor],
+ summary="List Nest sponsors",
+)
+@decorate_view(cache_response())
+def list_nest_sponsors(
+ request: HttpRequest,
+) -> list[Sponsor]:
+ """Get active Nest sponsors for external integrations (GitHub Actions, dashboards, etc.)."""
+ return list(
+ SponsorModel.objects.filter(status=SponsorModel.SponsorStatus.ACTIVE).order_by(
+ "sponsor_type", "name"
+ )
+ )
+
+
+@router.post(
+ "/apply",
+ description="Submit a sponsor application. Creates a new sponsor record with draft status.",
+ operation_id="apply_sponsor",
+ response={
+ HTTPStatus.BAD_REQUEST: ValidationErrorSchema,
+ HTTPStatus.CREATED: SponsorApplicationResponse,
+ },
+ summary="Apply to become a sponsor",
+)
+def apply_sponsor(
+ request: HttpRequest,
+ payload: SponsorApplication,
+) -> Response:
+ """Submit a sponsor application."""
+ key = slugify(payload.organization_name)
+
+ if SponsorModel.objects.filter(key=key).exists():
+ return Response(
+ {"message": "A sponsor application with this organization name already exists."},
+ status=HTTPStatus.BAD_REQUEST,
+ )
+
+ sponsor = SponsorModel(
+ contact_email=payload.contact_email,
+ description=payload.message,
+ key=key,
+ name=payload.organization_name,
+ sort_name=payload.organization_name,
+ status=SponsorModel.SponsorStatus.DRAFT,
+ url=payload.website,
+ )
+ sponsor.save()
+
+ return Response(
+ {
+ "message": "Sponsor application submitted successfully. "
+ "It will be reviewed by the OWASP team.",
+ "key": key,
+ },
+ status=HTTPStatus.CREATED,
+ )
@router.get(
diff --git a/backend/apps/owasp/admin/sponsor.py b/backend/apps/owasp/admin/sponsor.py
index c124b7cd67..cd164b66e3 100644
--- a/backend/apps/owasp/admin/sponsor.py
+++ b/backend/apps/owasp/admin/sponsor.py
@@ -14,15 +14,19 @@ class SponsorAdmin(admin.ModelAdmin, StandardOwaspAdminMixin):
"name",
"sort_name",
"sponsor_type",
+ "status",
"is_member",
"member_type",
+ "contact_email",
)
search_fields = (
"name",
"sort_name",
"description",
+ "contact_email",
)
list_filter = (
+ "status",
"sponsor_type",
"is_member",
"member_type",
@@ -48,16 +52,34 @@ class SponsorAdmin(admin.ModelAdmin, StandardOwaspAdminMixin):
)
},
),
+ (
+ "Contact",
+ {"fields": ("contact_email",)},
+ ),
(
"Status",
{
"fields": (
+ "status",
"is_member",
"member_type",
"sponsor_type",
)
},
),
+ (
+ "Entity Association",
+ {
+ "fields": (
+ "chapter",
+ "project",
+ ),
+ "description": (
+ "Optionally associate this sponsor with a specific chapter or project. "
+ "Leave blank for global/general sponsors."
+ ),
+ },
+ ),
)
diff --git a/backend/apps/owasp/migrations/0073_sponsor_status_email_entity_fks.py b/backend/apps/owasp/migrations/0073_sponsor_status_email_entity_fks.py
new file mode 100644
index 0000000000..a27c31b9f8
--- /dev/null
+++ b/backend/apps/owasp/migrations/0073_sponsor_status_email_entity_fks.py
@@ -0,0 +1,61 @@
+# Generated manually for sponsor status, email, and entity FK fields.
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("owasp", "0072_project_project_name_gin_idx_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="sponsor",
+ name="status",
+ field=models.CharField(
+ choices=[
+ ("draft", "Draft"),
+ ("active", "Active"),
+ ("archived", "Archived"),
+ ],
+ default="active",
+ max_length=20,
+ verbose_name="Status",
+ ),
+ ),
+ migrations.AddField(
+ model_name="sponsor",
+ name="contact_email",
+ field=models.EmailField(
+ blank=True,
+ default="",
+ max_length=254,
+ verbose_name="Contact Email",
+ ),
+ ),
+ migrations.AddField(
+ model_name="sponsor",
+ name="chapter",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="sponsors",
+ to="owasp.chapter",
+ verbose_name="Chapter",
+ ),
+ ),
+ migrations.AddField(
+ model_name="sponsor",
+ name="project",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="sponsors",
+ to="owasp.project",
+ verbose_name="Project",
+ ),
+ ),
+ ]
diff --git a/backend/apps/owasp/models/sponsor.py b/backend/apps/owasp/models/sponsor.py
index d7d0e38158..3d40894935 100644
--- a/backend/apps/owasp/models/sponsor.py
+++ b/backend/apps/owasp/models/sponsor.py
@@ -20,6 +20,13 @@ class Meta:
db_table = "owasp_sponsors"
verbose_name_plural = "Sponsors"
+ class SponsorStatus(models.TextChoices):
+ """Sponsor status choices."""
+
+ DRAFT = "draft", "Draft"
+ ACTIVE = "active", "Active"
+ ARCHIVED = "archived", "Archived"
+
class SponsorType(models.TextChoices):
"""Sponsor type choices."""
@@ -47,8 +54,15 @@ class MemberType(models.TextChoices):
url = models.URLField(verbose_name="Website URL", blank=True)
job_url = models.URLField(verbose_name="Job URL", blank=True)
image_url = models.CharField(verbose_name="Image Path", max_length=255, blank=True)
+ contact_email = models.EmailField(verbose_name="Contact Email", blank=True, default="")
# Status fields
+ status = models.CharField(
+ verbose_name="Status",
+ max_length=20,
+ choices=SponsorStatus.choices,
+ default=SponsorStatus.ACTIVE,
+ )
is_member = models.BooleanField(verbose_name="Is Corporate Sponsor", default=False)
member_type = models.CharField(
verbose_name="Member Type",
@@ -64,6 +78,24 @@ class MemberType(models.TextChoices):
default=SponsorType.NOT_SPONSOR,
)
+ # Entity associations (optional)
+ chapter = models.ForeignKey(
+ "owasp.Chapter",
+ on_delete=models.SET_NULL,
+ blank=True,
+ null=True,
+ related_name="sponsors",
+ verbose_name="Chapter",
+ )
+ project = models.ForeignKey(
+ "owasp.Project",
+ on_delete=models.SET_NULL,
+ blank=True,
+ null=True,
+ related_name="sponsors",
+ verbose_name="Project",
+ )
+
def __str__(self) -> str:
"""Sponsor human readable representation."""
return f"{self.name}"
diff --git a/backend/tests/unit/apps/api/rest/v0/sponsor_test.py b/backend/tests/unit/apps/api/rest/v0/sponsor_test.py
index 5e89a5b30d..e579b9402d 100644
--- a/backend/tests/unit/apps/api/rest/v0/sponsor_test.py
+++ b/backend/tests/unit/apps/api/rest/v0/sponsor_test.py
@@ -3,7 +3,13 @@
import pytest
-from apps.api.rest.v0.sponsor import SponsorDetail, get_sponsor, list_sponsors
+from apps.api.rest.v0.sponsor import (
+ SponsorDetail,
+ apply_sponsor,
+ get_sponsor,
+ list_nest_sponsors,
+ list_sponsors,
+)
class TestSponsorSchema:
@@ -19,6 +25,7 @@ class TestSponsorSchema:
"member_type": "PLATINUM",
"name": "Gold Sponsor Inc.",
"sponsor_type": "GOLD",
+ "status": "active",
"url": "https://goldsponsor.com",
},
{
@@ -30,6 +37,7 @@ class TestSponsorSchema:
"member_type": "SILVER",
"name": "Silver Sponsor LLC",
"sponsor_type": "SILVER",
+ "status": "active",
"url": "https://silversponsor.com",
},
],
@@ -46,6 +54,7 @@ def test_sponsor_schema_creation(self, sponsor_data):
assert sponsor.member_type == sponsor_data["member_type"]
assert sponsor.name == sponsor_data["name"]
assert sponsor.sponsor_type == sponsor_data["sponsor_type"]
+ assert sponsor.status == sponsor_data["status"]
assert sponsor.url == sponsor_data["url"]
def test_sponsor_schema_with_minimal_data(self):
@@ -59,6 +68,7 @@ def test_sponsor_schema_with_minimal_data(self):
"member_type": "",
"name": "Test Sponsor",
"sponsor_type": "SILVER",
+ "status": "draft",
"url": "",
}
sponsor = SponsorDetail(**minimal_data)
@@ -66,6 +76,7 @@ def test_sponsor_schema_with_minimal_data(self):
assert sponsor.job_url == ""
assert sponsor.key == "test-sponsor"
assert sponsor.name == "Test Sponsor"
+ assert sponsor.status == "draft"
class TestListSponsors:
@@ -76,30 +87,76 @@ def test_list_sponsors_no_ordering(self, mock_sponsor_model):
"""Test listing sponsors without ordering."""
mock_request = MagicMock()
mock_filters = MagicMock()
+ mock_filters.status = None
mock_queryset = MagicMock()
+ mock_filtered_queryset = MagicMock()
mock_sponsor_model.objects.order_by.return_value = mock_queryset
- mock_filters.filter.return_value = mock_queryset
+ mock_queryset.filter.return_value = mock_filtered_queryset
+ mock_filters.filter.return_value = mock_filtered_queryset
result = list_sponsors(mock_request, mock_filters, ordering=None)
mock_sponsor_model.objects.order_by.assert_called_with("name")
- assert result == mock_queryset
+ mock_queryset.filter.assert_called_once_with(
+ status=mock_sponsor_model.SponsorStatus.ACTIVE
+ )
+ assert result == mock_filtered_queryset
@patch("apps.api.rest.v0.sponsor.SponsorModel")
def test_list_sponsors_with_ordering(self, mock_sponsor_model):
"""Test listing sponsors with custom ordering."""
mock_request = MagicMock()
mock_filters = MagicMock()
+ mock_filters.status = None
mock_queryset = MagicMock()
+ mock_filtered_queryset = MagicMock()
mock_sponsor_model.objects.order_by.return_value = mock_queryset
- mock_filters.filter.return_value = mock_queryset
+ mock_queryset.filter.return_value = mock_filtered_queryset
+ mock_filters.filter.return_value = mock_filtered_queryset
result = list_sponsors(mock_request, mock_filters, ordering="-name")
mock_sponsor_model.objects.order_by.assert_called_with("-name")
+ assert result == mock_filtered_queryset
+
+ @patch("apps.api.rest.v0.sponsor.SponsorModel")
+ def test_list_sponsors_with_status_filter(self, mock_sponsor_model):
+ """Test listing sponsors with explicit status filter skips default filtering."""
+ mock_request = MagicMock()
+ mock_filters = MagicMock()
+ mock_filters.status = "draft"
+ mock_queryset = MagicMock()
+ mock_sponsor_model.objects.order_by.return_value = mock_queryset
+ mock_filters.filter.return_value = mock_queryset
+
+ result = list_sponsors(mock_request, mock_filters, ordering=None)
+
+ mock_sponsor_model.objects.order_by.assert_called_with("name")
+ mock_queryset.filter.assert_not_called()
assert result == mock_queryset
+class TestListNestSponsors:
+ """Tests for list_nest_sponsors endpoint."""
+
+ @patch("apps.api.rest.v0.sponsor.SponsorModel")
+ def test_list_nest_sponsors(self, mock_sponsor_model):
+ """Test listing Nest sponsors returns active sponsors ordered by type and name."""
+ mock_request = MagicMock()
+ mock_queryset = MagicMock()
+ mock_sponsor_model.objects.filter.return_value.order_by.return_value = mock_queryset
+ mock_queryset.__iter__ = MagicMock(return_value=iter([]))
+
+ list_nest_sponsors(mock_request)
+
+ mock_sponsor_model.objects.filter.assert_called_once_with(
+ status=mock_sponsor_model.SponsorStatus.ACTIVE
+ )
+ mock_sponsor_model.objects.filter.return_value.order_by.assert_called_once_with(
+ "sponsor_type", "name"
+ )
+
+
class TestGetSponsor:
"""Tests for get_sponsor endpoint."""
@@ -124,3 +181,44 @@ def test_get_sponsor_not_found(self, mock_sponsor_model):
result = get_sponsor(mock_request, "nonexistent")
assert result.status_code == HTTPStatus.NOT_FOUND
+
+
+class TestApplySponsor:
+ """Tests for apply_sponsor endpoint."""
+
+ @patch("apps.api.rest.v0.sponsor.SponsorModel")
+ @patch("apps.api.rest.v0.sponsor.slugify")
+ def test_apply_sponsor_success(self, mock_slugify, mock_sponsor_model):
+ """Test successful sponsor application."""
+ mock_request = MagicMock()
+ mock_slugify.return_value = "test-org"
+ mock_sponsor_model.objects.filter.return_value.exists.return_value = False
+ mock_sponsor_instance = MagicMock()
+ mock_sponsor_model.return_value = mock_sponsor_instance
+ mock_sponsor_model.SponsorStatus.DRAFT = "draft"
+
+ payload = MagicMock()
+ payload.organization_name = "Test Org"
+ payload.website = "https://test.org"
+ payload.contact_email = "sponsor@test.org"
+ payload.message = "We'd like to sponsor OWASP."
+
+ result = apply_sponsor(mock_request, payload)
+
+ assert result.status_code == HTTPStatus.CREATED
+ mock_sponsor_instance.save.assert_called_once()
+
+ @patch("apps.api.rest.v0.sponsor.SponsorModel")
+ @patch("apps.api.rest.v0.sponsor.slugify")
+ def test_apply_sponsor_duplicate(self, mock_slugify, mock_sponsor_model):
+ """Test sponsor application with duplicate organization name."""
+ mock_request = MagicMock()
+ mock_slugify.return_value = "existing-org"
+ mock_sponsor_model.objects.filter.return_value.exists.return_value = True
+
+ payload = MagicMock()
+ payload.organization_name = "Existing Org"
+
+ result = apply_sponsor(mock_request, payload)
+
+ assert result.status_code == HTTPStatus.BAD_REQUEST
diff --git a/frontend/src/app/sponsors/apply/page.tsx b/frontend/src/app/sponsors/apply/page.tsx
new file mode 100644
index 0000000000..f10f82e234
--- /dev/null
+++ b/frontend/src/app/sponsors/apply/page.tsx
@@ -0,0 +1,306 @@
+'use client'
+
+import { addToast } from '@heroui/toast'
+import Link from 'next/link'
+import { type ChangeEvent, type FormEvent, useState } from 'react'
+import { FaArrowLeft, FaHandshake, FaPaperPlane } from 'react-icons/fa6'
+import { API_URL } from 'utils/env.client'
+import AnchorTitle from 'components/AnchorTitle'
+import SecondaryCard from 'components/SecondaryCard'
+
+interface FormData {
+ organizationName: string
+ website: string
+ contactEmail: string
+ message: string
+}
+
+interface FormErrors {
+ organizationName?: string
+ contactEmail?: string
+}
+
+const validateForm = (data: FormData): FormErrors => {
+ const errors: FormErrors = {}
+ if (!data.organizationName.trim()) {
+ errors.organizationName = 'Organization name is required.'
+ }
+ if (!data.contactEmail.trim()) {
+ errors.contactEmail = 'Contact email is required.'
+ } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.contactEmail)) {
+ errors.contactEmail = 'Please enter a valid email address.'
+ }
+ return errors
+}
+
+export default function SponsorApplyPage() {
+ const [formData, setFormData] = useState({
+ organizationName: '',
+ website: '',
+ contactEmail: '',
+ message: '',
+ })
+ const [errors, setErrors] = useState({})
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [isSubmitted, setIsSubmitted] = useState(false)
+
+ const handleChange = (e: ChangeEvent) => {
+ const { name, value } = e.target
+ setFormData((prev) => ({ ...prev, [name]: value }))
+
+ if (errors[name as keyof FormErrors]) {
+ setErrors((prev) => ({ ...prev, [name]: undefined }))
+ }
+ }
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault()
+
+ const validationErrors = validateForm(formData)
+ if (Object.keys(validationErrors).length > 0) {
+ setErrors(validationErrors)
+ return
+ }
+
+ setIsSubmitting(true)
+
+ try {
+ const payload: Record = {}
+ payload['contact_email'] = formData.contactEmail
+ payload['message'] = formData.message
+ payload['organization_name'] = formData.organizationName
+ payload['website'] = formData.website
+ const response = await fetch(`${API_URL}api/v0/sponsors/apply`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify(payload),
+ })
+
+ if (response.ok) {
+ setIsSubmitted(true)
+ addToast({
+ description: 'Your sponsor application has been submitted for review.',
+ title: 'Application Submitted',
+ timeout: 5000,
+ shouldShowTimeoutProgress: true,
+ color: 'success',
+ variant: 'solid',
+ })
+ } else {
+ const data = await response.json()
+ addToast({
+ description: data.message || 'Failed to submit application. Please try again.',
+ title: 'Submission Failed',
+ timeout: 5000,
+ shouldShowTimeoutProgress: true,
+ color: 'danger',
+ variant: 'solid',
+ })
+ }
+ } catch {
+ addToast({
+ description: 'An unexpected error occurred. Please try again later.',
+ title: 'Error',
+ timeout: 5000,
+ shouldShowTimeoutProgress: true,
+ color: 'danger',
+ variant: 'solid',
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ if (isSubmitted) {
+ return (
+
+
+
+
+
+
+
+
+ Thank You for Your Interest!
+
+
+ Your sponsor application has been submitted successfully. Our team will review it
+ and reach out to you at {formData.contactEmail} .
+
+
+
+ View Sponsors
+
+
+ Back to Home
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ Back to Sponsors
+
+
+
+
+ Become a Sponsor
+
+
+ Support the OWASP Nest project and gain visibility within the global cybersecurity
+ community.
+
+
+
+
}>
+
+
+
+
+
+
+ What happens next?
+
+
+ Your application will be reviewed by the OWASP team.
+ We'll reach out to discuss sponsorship details and levels.
+ Once approved, your organization will be featured on our sponsors page.
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/app/sponsors/layout.tsx b/frontend/src/app/sponsors/layout.tsx
new file mode 100644
index 0000000000..261a5e70fb
--- /dev/null
+++ b/frontend/src/app/sponsors/layout.tsx
@@ -0,0 +1,9 @@
+import { Metadata } from 'next'
+import React from 'react'
+import { getStaticMetadata } from 'utils/metaconfig'
+
+export const metadata: Metadata = getStaticMetadata('sponsors', '/sponsors')
+
+export default function SponsorsLayout({ children }: { children: React.ReactNode }) {
+ return children
+}
diff --git a/frontend/src/app/sponsors/page.tsx b/frontend/src/app/sponsors/page.tsx
new file mode 100644
index 0000000000..cc303ced43
--- /dev/null
+++ b/frontend/src/app/sponsors/page.tsx
@@ -0,0 +1,228 @@
+'use client'
+
+import { useQuery } from '@apollo/client/react'
+import { addToast } from '@heroui/toast'
+import Image from 'next/image'
+import Link from 'next/link'
+import { useEffect } from 'react'
+import { FaGem, FaHandHoldingHeart } from 'react-icons/fa'
+import { FaArrowUpRightFromSquare } from 'react-icons/fa6'
+import { GetSponsorsDataDocument } from 'types/__generated__/sponsorQueries.generated'
+import type { Sponsor } from 'types/home'
+import AnchorTitle from 'components/AnchorTitle'
+import LoadingSpinner from 'components/LoadingSpinner'
+import SecondaryCard from 'components/SecondaryCard'
+
+const TIER_ORDER = ['Diamond', 'Platinum', 'Gold', 'Silver', 'Supporter']
+
+const TIER_STYLES: Record = {
+ diamond: {
+ gradient: 'from-cyan-50 to-blue-50 dark:from-cyan-900/20 dark:to-blue-900/20',
+ border: 'border-cyan-300 dark:border-cyan-600',
+ badge: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300',
+ },
+ platinum: {
+ gradient: 'from-slate-50 to-gray-100 dark:from-slate-800/40 dark:to-gray-800/40',
+ border: 'border-slate-300 dark:border-slate-500',
+ badge: 'bg-slate-100 text-slate-700 dark:bg-slate-800/60 dark:text-slate-300',
+ },
+ gold: {
+ gradient: 'from-amber-50 to-yellow-50 dark:from-amber-900/20 dark:to-yellow-900/20',
+ border: 'border-amber-300 dark:border-amber-600',
+ badge: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
+ },
+ silver: {
+ gradient: 'from-gray-50 to-slate-50 dark:from-gray-800/30 dark:to-slate-800/30',
+ border: 'border-gray-300 dark:border-gray-600',
+ badge: 'bg-gray-100 text-gray-700 dark:bg-gray-700/60 dark:text-gray-300',
+ },
+ supporter: {
+ gradient: 'from-emerald-50 to-green-50 dark:from-emerald-900/20 dark:to-green-900/20',
+ border: 'border-emerald-300 dark:border-emerald-600',
+ badge: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
+ },
+}
+
+const SponsorTierCard = ({ sponsor }: { sponsor: Sponsor }) => {
+ const style = TIER_STYLES[sponsor.sponsorType.toLowerCase()] || TIER_STYLES.supporter
+
+ return (
+
+
+
+ {sponsor.imageUrl ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {sponsor.name}
+
+
+ {sponsor.sponsorType}
+
+
+ {sponsor.description && (
+
+ {sponsor.description}
+
+ )}
+ {sponsor.url && (
+
+ Visit website
+
+
+ )}
+
+
+
+ )
+}
+
+export default function SponsorsPage() {
+ const {
+ data: graphQLData,
+ error: graphQLRequestError,
+ loading: isLoading,
+ } = useQuery(GetSponsorsDataDocument)
+
+ useEffect(() => {
+ if (graphQLRequestError) {
+ addToast({
+ description: 'Unable to load sponsors data.',
+ title: 'GraphQL Request Failed',
+ timeout: 3000,
+ shouldShowTimeoutProgress: true,
+ color: 'danger',
+ variant: 'solid',
+ })
+ }
+ }, [graphQLRequestError])
+
+ if (isLoading) {
+ return
+ }
+
+ const sponsors = graphQLData?.sponsors ?? []
+
+ const groupedSponsors = TIER_ORDER.reduce>((acc, tier) => {
+ const tierSponsors = sponsors.filter((s) => s.sponsorType === tier)
+ if (tierSponsors.length > 0) {
+ acc[tier] = tierSponsors
+ }
+ return acc
+ }, {})
+
+ const otherSponsors = sponsors.filter(
+ (s) => !TIER_ORDER.includes(s.sponsorType) && s.sponsorType !== 'Not a Sponsor'
+ )
+ if (otherSponsors.length > 0) {
+ groupedSponsors['Other'] = otherSponsors
+ }
+
+ return (
+
+
+
+
+ Our Sponsors
+
+
+ These organizations generously support OWASP Nest, helping us build a stronger and more
+ secure open-source community.
+
+
+
+ {Object.keys(groupedSponsors).length === 0 ? (
+
+
+
+
+ No Sponsors Yet
+
+
+ Be the first to support the OWASP Nest project!
+
+
+ Become a Sponsor
+
+
+
+ ) : (
+ <>
+ {Object.entries(groupedSponsors).map(([tier, tierSponsors]) => (
+
+
+
+ {tierSponsors.length}
+
+
+ }
+ >
+
+ {tierSponsors.map((sponsor) => (
+
+ ))}
+
+
+ ))}
+ >
+ )}
+
+
}>
+
+
+ Interested in supporting the OWASP Nest project? Your sponsorship helps us maintain
+ and improve the platform for the global cybersecurity community.
+
+
+ Apply to Sponsor
+
+
+ Donate via OWASP
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/LogoCarousel.tsx b/frontend/src/components/LogoCarousel.tsx
index 396bcd281c..716feb149d 100644
--- a/frontend/src/components/LogoCarousel.tsx
+++ b/frontend/src/components/LogoCarousel.tsx
@@ -76,12 +76,7 @@ export default function MovingLogos({ sponsors }: Readonly) {
If you're interested in sponsoring the OWASP Nest project ❤️{' '}
-
+
click here
.
diff --git a/frontend/src/server/queries/homeQueries.ts b/frontend/src/server/queries/homeQueries.ts
index 73b90693f9..db966ee679 100644
--- a/frontend/src/server/queries/homeQueries.ts
+++ b/frontend/src/server/queries/homeQueries.ts
@@ -78,6 +78,7 @@ export const GET_MAIN_PAGE_DATA = gql`
url
}
sponsors {
+ description
id
imageUrl
name
diff --git a/frontend/src/server/queries/sponsorQueries.ts b/frontend/src/server/queries/sponsorQueries.ts
new file mode 100644
index 0000000000..ec3137850e
--- /dev/null
+++ b/frontend/src/server/queries/sponsorQueries.ts
@@ -0,0 +1,14 @@
+import { gql } from '@apollo/client'
+
+export const GET_SPONSORS_DATA = gql`
+ query GetSponsorsData {
+ sponsors {
+ description
+ id
+ imageUrl
+ name
+ sponsorType
+ url
+ }
+ }
+`
diff --git a/frontend/src/types/__generated__/graphql.ts b/frontend/src/types/__generated__/graphql.ts
index ecf3e3d1f1..6d6faf297b 100644
--- a/frontend/src/types/__generated__/graphql.ts
+++ b/frontend/src/types/__generated__/graphql.ts
@@ -1061,6 +1061,7 @@ export type SnapshotNode = Node & {
export type SponsorNode = Node & {
__typename?: 'SponsorNode';
+ description: Scalars['String']['output'];
/** The Globally Unique ID of this object */
id: Scalars['ID']['output'];
imageUrl: Scalars['String']['output'];
diff --git a/frontend/src/types/__generated__/homeQueries.generated.ts b/frontend/src/types/__generated__/homeQueries.generated.ts
index 31db7776cf..485d3d6381 100644
--- a/frontend/src/types/__generated__/homeQueries.generated.ts
+++ b/frontend/src/types/__generated__/homeQueries.generated.ts
@@ -6,7 +6,7 @@ export type GetMainPageDataQueryVariables = Types.Exact<{
}>;
-export type GetMainPageDataQuery = { recentProjects: Array<{ __typename: 'ProjectNode', id: string, createdAt: any | null, key: string, leaders: Array, name: string, openIssuesCount: number, repositoriesCount: number, type: string }>, recentPosts: Array<{ __typename: 'PostNode', id: string, authorName: string, authorImageUrl: string, publishedAt: any, title: string, url: string }>, recentChapters: Array<{ __typename: 'ChapterNode', id: string, createdAt: string, key: string, leaders: Array, name: string, suggestedLocation: string | null }>, topContributors: Array<{ __typename: 'RepositoryContributorNode', id: string, avatarUrl: string, login: string, name: string }>, recentIssues: Array<{ __typename: 'IssueNode', id: string, createdAt: any, organizationName: string | null, repositoryName: string | null, title: string, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string } | null }>, recentPullRequests: Array<{ __typename: 'PullRequestNode', id: string, createdAt: any, organizationName: string | null, repositoryName: string | null, title: string, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string } | null }>, recentReleases: Array<{ __typename: 'ReleaseNode', id: string, name: string, organizationName: string | null, publishedAt: any | null, repositoryName: string | null, tagName: string, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string } | null }>, sponsors: Array<{ __typename: 'SponsorNode', id: string, imageUrl: string, name: string, sponsorType: string, url: string }>, statsOverview: { __typename: 'StatsNode', activeChaptersStats: number, activeProjectsStats: number, contributorsStats: number, countriesStats: number, slackWorkspaceStats: number }, upcomingEvents: Array<{ __typename: 'EventNode', id: string, category: string, endDate: any | null, key: string, name: string, startDate: any, summary: string, suggestedLocation: string, url: string }>, recentMilestones: Array<{ __typename: 'MilestoneNode', id: string, title: string, openIssuesCount: number, closedIssuesCount: number, repositoryName: string | null, organizationName: string | null, createdAt: any, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string } | null }> };
+export type GetMainPageDataQuery = { recentProjects: Array<{ __typename: 'ProjectNode', id: string, createdAt: any | null, key: string, leaders: Array, name: string, openIssuesCount: number, repositoriesCount: number, type: string }>, recentPosts: Array<{ __typename: 'PostNode', id: string, authorName: string, authorImageUrl: string, publishedAt: any, title: string, url: string }>, recentChapters: Array<{ __typename: 'ChapterNode', id: string, createdAt: string, key: string, leaders: Array, name: string, suggestedLocation: string | null }>, topContributors: Array<{ __typename: 'RepositoryContributorNode', id: string, avatarUrl: string, login: string, name: string }>, recentIssues: Array<{ __typename: 'IssueNode', id: string, createdAt: any, organizationName: string | null, repositoryName: string | null, title: string, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string } | null }>, recentPullRequests: Array<{ __typename: 'PullRequestNode', id: string, createdAt: any, organizationName: string | null, repositoryName: string | null, title: string, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string } | null }>, recentReleases: Array<{ __typename: 'ReleaseNode', id: string, name: string, organizationName: string | null, publishedAt: any | null, repositoryName: string | null, tagName: string, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string } | null }>, sponsors: Array<{ __typename: 'SponsorNode', description: string, id: string, imageUrl: string, name: string, sponsorType: string, url: string }>, statsOverview: { __typename: 'StatsNode', activeChaptersStats: number, activeProjectsStats: number, contributorsStats: number, countriesStats: number, slackWorkspaceStats: number }, upcomingEvents: Array<{ __typename: 'EventNode', id: string, category: string, endDate: any | null, key: string, name: string, startDate: any, summary: string, suggestedLocation: string, url: string }>, recentMilestones: Array<{ __typename: 'MilestoneNode', id: string, title: string, openIssuesCount: number, closedIssuesCount: number, repositoryName: string | null, organizationName: string | null, createdAt: any, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string } | null }> };
-export const GetMainPageDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMainPageData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"recentProjects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"3"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"leaders"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"repositoriesCount"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentPosts"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"6"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"authorName"}},{"kind":"Field","name":{"kind":"Name","value":"authorImageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"publishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentChapters"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"3"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"leaders"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"suggestedLocation"}}]}},{"kind":"Field","name":{"kind":"Name","value":"topContributors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"hasFullName"},"value":{"kind":"BooleanValue","value":true}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"40"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentIssues"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"distinct"},"value":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentPullRequests"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"distinct"},"value":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentReleases"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"distinct"},"value":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"publishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"tagName"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"sponsors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sponsorType"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"statsOverview"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeChaptersStats"}},{"kind":"Field","name":{"kind":"Name","value":"activeProjectsStats"}},{"kind":"Field","name":{"kind":"Name","value":"contributorsStats"}},{"kind":"Field","name":{"kind":"Name","value":"countriesStats"}},{"kind":"Field","name":{"kind":"Name","value":"slackWorkspaceStats"}}]}},{"kind":"Field","name":{"kind":"Name","value":"upcomingEvents"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"9"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"endDate"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"startDate"}},{"kind":"Field","name":{"kind":"Name","value":"summary"}},{"kind":"Field","name":{"kind":"Name","value":"suggestedLocation"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentMilestones"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"distinct"},"value":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"closedIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode;
\ No newline at end of file
+export const GetMainPageDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMainPageData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"recentProjects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"3"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"leaders"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"repositoriesCount"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentPosts"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"6"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"authorName"}},{"kind":"Field","name":{"kind":"Name","value":"authorImageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"publishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentChapters"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"3"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"leaders"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"suggestedLocation"}}]}},{"kind":"Field","name":{"kind":"Name","value":"topContributors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"hasFullName"},"value":{"kind":"BooleanValue","value":true}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"40"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentIssues"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"distinct"},"value":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentPullRequests"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"distinct"},"value":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentReleases"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"distinct"},"value":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"publishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"tagName"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"sponsors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sponsorType"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"statsOverview"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeChaptersStats"}},{"kind":"Field","name":{"kind":"Name","value":"activeProjectsStats"}},{"kind":"Field","name":{"kind":"Name","value":"contributorsStats"}},{"kind":"Field","name":{"kind":"Name","value":"countriesStats"}},{"kind":"Field","name":{"kind":"Name","value":"slackWorkspaceStats"}}]}},{"kind":"Field","name":{"kind":"Name","value":"upcomingEvents"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"9"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"endDate"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"startDate"}},{"kind":"Field","name":{"kind":"Name","value":"summary"}},{"kind":"Field","name":{"kind":"Name","value":"suggestedLocation"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentMilestones"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"distinct"},"value":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"closedIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode;
\ No newline at end of file
diff --git a/frontend/src/types/__generated__/sponsorQueries.generated.ts b/frontend/src/types/__generated__/sponsorQueries.generated.ts
new file mode 100644
index 0000000000..83f935fa90
--- /dev/null
+++ b/frontend/src/types/__generated__/sponsorQueries.generated.ts
@@ -0,0 +1,10 @@
+import * as Types from './graphql';
+
+import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
+export type GetSponsorsDataQueryVariables = Types.Exact<{ [key: string]: never; }>;
+
+
+export type GetSponsorsDataQuery = { sponsors: Array<{ __typename: 'SponsorNode', description: string, id: string, imageUrl: string, name: string, sponsorType: string, url: string }> };
+
+
+export const GetSponsorsDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSponsorsData"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sponsors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sponsorType"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode;
\ No newline at end of file
diff --git a/frontend/src/types/home.ts b/frontend/src/types/home.ts
index 5a3e0a0724..8ac17c4f23 100644
--- a/frontend/src/types/home.ts
+++ b/frontend/src/types/home.ts
@@ -34,6 +34,7 @@ export type MainPageData = {
}
export type Sponsor = {
+ description: string
id: string
imageUrl: string
name: string
diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts
index 01b90cad6d..b729d8b21d 100644
--- a/frontend/src/utils/constants.ts
+++ b/frontend/src/utils/constants.ts
@@ -61,8 +61,8 @@ export const footerSections: Section[] = [
href: 'https://owasp.org/www-community/initiatives/gsoc/gsoc2026ideas#owasp-nest',
},
{
- text: 'Sponsor',
- href: 'https://owasp.org/donate/?reponame=www-project-nest&title=OWASP+Nest',
+ text: 'Sponsors',
+ href: '/sponsors',
},
],
},
diff --git a/frontend/src/utils/metadata.ts b/frontend/src/utils/metadata.ts
index 503c39d48e..7b1afd75fd 100644
--- a/frontend/src/utils/metadata.ts
+++ b/frontend/src/utils/metadata.ts
@@ -60,4 +60,11 @@ export const METADATA_CONFIG = {
pageTitle: 'Organizations',
type: 'website',
},
+ sponsors: {
+ description:
+ 'Meet the organizations that support OWASP Nest through sponsorship and help strengthen the open-source security community.',
+ keywords: ['OWASP sponsors', 'sponsorship', 'security sponsors', 'OWASP Nest sponsors'],
+ pageTitle: 'Sponsors',
+ type: 'website',
+ },
}
From 9b8f00e33cd688cdf572f6fac62a602f152b1bab Mon Sep 17 00:00:00 2001
From: mrkeshav-05
Date: Sun, 12 Apr 2026 22:27:26 +0530
Subject: [PATCH 3/5] update tests
---
backend/apps/api/rest/v0/sponsor.py | 47 ++-
.../unit/apps/api/rest/v0/sponsor_test.py | 57 +++-
.../api/internal/queries/sponsor_test.py | 2 +-
.../unit/pages/SponsorApply.test.tsx | 267 +++++++++++++++++
.../__tests__/unit/pages/Sponsors.test.tsx | 277 ++++++++++++++++++
.../unit/utils/deadlineUtils.test.ts | 21 +-
frontend/src/app/sponsors/apply/page.tsx | 12 +-
frontend/src/app/sponsors/page.tsx | 26 ++
frontend/src/components/LogoCarousel.tsx | 7 +-
9 files changed, 666 insertions(+), 50 deletions(-)
create mode 100644 frontend/__tests__/unit/pages/SponsorApply.test.tsx
create mode 100644 frontend/__tests__/unit/pages/Sponsors.test.tsx
diff --git a/backend/apps/api/rest/v0/sponsor.py b/backend/apps/api/rest/v0/sponsor.py
index ed6a19f1a2..5982eb35fc 100644
--- a/backend/apps/api/rest/v0/sponsor.py
+++ b/backend/apps/api/rest/v0/sponsor.py
@@ -3,6 +3,8 @@
from http import HTTPStatus
from typing import Literal
+from django.db import IntegrityError, transaction
+from django.db.models import Case, IntegerField, Value, When
from django.http import HttpRequest
from ninja import Field, FilterSchema, Path, Query, Schema
from ninja.decorators import decorate_view
@@ -123,10 +125,19 @@ def list_nest_sponsors(
request: HttpRequest,
) -> list[Sponsor]:
"""Get active Nest sponsors for external integrations (GitHub Actions, dashboards, etc.)."""
+ tier_order = Case(
+ When(sponsor_type=SponsorModel.SponsorType.DIAMOND, then=Value(1)),
+ When(sponsor_type=SponsorModel.SponsorType.PLATINUM, then=Value(2)),
+ When(sponsor_type=SponsorModel.SponsorType.GOLD, then=Value(3)),
+ When(sponsor_type=SponsorModel.SponsorType.SILVER, then=Value(4)),
+ When(sponsor_type=SponsorModel.SponsorType.SUPPORTER, then=Value(5)),
+ default=Value(6),
+ output_field=IntegerField(),
+ )
return list(
- SponsorModel.objects.filter(status=SponsorModel.SponsorStatus.ACTIVE).order_by(
- "sponsor_type", "name"
- )
+ SponsorModel.objects.filter(status=SponsorModel.SponsorStatus.ACTIVE)
+ .annotate(tier_order=tier_order)
+ .order_by("tier_order", "name")
)
@@ -147,22 +158,30 @@ def apply_sponsor(
"""Submit a sponsor application."""
key = slugify(payload.organization_name)
- if SponsorModel.objects.filter(key=key).exists():
+ if not key:
return Response(
- {"message": "A sponsor application with this organization name already exists."},
+ {"message": "Organization name must produce a valid key."},
status=HTTPStatus.BAD_REQUEST,
)
- sponsor = SponsorModel(
- contact_email=payload.contact_email,
- description=payload.message,
- key=key,
- name=payload.organization_name,
- sort_name=payload.organization_name,
- status=SponsorModel.SponsorStatus.DRAFT,
- url=payload.website,
+ duplicate_response = Response(
+ {"message": "A sponsor application with this organization name already exists."},
+ status=HTTPStatus.BAD_REQUEST,
)
- sponsor.save()
+
+ try:
+ with transaction.atomic():
+ SponsorModel.objects.create(
+ contact_email=payload.contact_email,
+ description=payload.message,
+ key=key,
+ name=payload.organization_name,
+ sort_name=payload.organization_name,
+ status=SponsorModel.SponsorStatus.DRAFT,
+ url=payload.website,
+ )
+ except IntegrityError:
+ return duplicate_response
return Response(
{
diff --git a/backend/tests/unit/apps/api/rest/v0/sponsor_test.py b/backend/tests/unit/apps/api/rest/v0/sponsor_test.py
index e579b9402d..2a5a59a595 100644
--- a/backend/tests/unit/apps/api/rest/v0/sponsor_test.py
+++ b/backend/tests/unit/apps/api/rest/v0/sponsor_test.py
@@ -2,6 +2,7 @@
from unittest.mock import MagicMock, patch
import pytest
+from django.db import IntegrityError
from apps.api.rest.v0.sponsor import (
SponsorDetail,
@@ -141,10 +142,12 @@ class TestListNestSponsors:
@patch("apps.api.rest.v0.sponsor.SponsorModel")
def test_list_nest_sponsors(self, mock_sponsor_model):
- """Test listing Nest sponsors returns active sponsors ordered by type and name."""
+ """Test that active sponsors are ordered by tier precedence then name."""
mock_request = MagicMock()
mock_queryset = MagicMock()
- mock_sponsor_model.objects.filter.return_value.order_by.return_value = mock_queryset
+ (
+ mock_sponsor_model.objects.filter.return_value.annotate.return_value.order_by
+ ).return_value = mock_queryset
mock_queryset.__iter__ = MagicMock(return_value=iter([]))
list_nest_sponsors(mock_request)
@@ -152,9 +155,9 @@ def test_list_nest_sponsors(self, mock_sponsor_model):
mock_sponsor_model.objects.filter.assert_called_once_with(
status=mock_sponsor_model.SponsorStatus.ACTIVE
)
- mock_sponsor_model.objects.filter.return_value.order_by.assert_called_once_with(
- "sponsor_type", "name"
- )
+ (
+ mock_sponsor_model.objects.filter.return_value.annotate.return_value.order_by
+ ).assert_called_once_with("tier_order", "name")
class TestGetSponsor:
@@ -186,16 +189,16 @@ def test_get_sponsor_not_found(self, mock_sponsor_model):
class TestApplySponsor:
"""Tests for apply_sponsor endpoint."""
+ @patch("apps.api.rest.v0.sponsor.transaction")
@patch("apps.api.rest.v0.sponsor.SponsorModel")
@patch("apps.api.rest.v0.sponsor.slugify")
- def test_apply_sponsor_success(self, mock_slugify, mock_sponsor_model):
+ def test_apply_sponsor_success(self, mock_slugify, mock_sponsor_model, mock_transaction):
"""Test successful sponsor application."""
mock_request = MagicMock()
mock_slugify.return_value = "test-org"
- mock_sponsor_model.objects.filter.return_value.exists.return_value = False
- mock_sponsor_instance = MagicMock()
- mock_sponsor_model.return_value = mock_sponsor_instance
mock_sponsor_model.SponsorStatus.DRAFT = "draft"
+ mock_transaction.atomic.return_value.__enter__ = MagicMock(return_value=None)
+ mock_transaction.atomic.return_value.__exit__ = MagicMock(return_value=False)
payload = MagicMock()
payload.organization_name = "Test Org"
@@ -206,18 +209,46 @@ def test_apply_sponsor_success(self, mock_slugify, mock_sponsor_model):
result = apply_sponsor(mock_request, payload)
assert result.status_code == HTTPStatus.CREATED
- mock_sponsor_instance.save.assert_called_once()
+ mock_sponsor_model.objects.create.assert_called_once_with(
+ contact_email=payload.contact_email,
+ description=payload.message,
+ key="test-org",
+ name=payload.organization_name,
+ sort_name=payload.organization_name,
+ status=mock_sponsor_model.SponsorStatus.DRAFT,
+ url=payload.website,
+ )
+ @patch("apps.api.rest.v0.sponsor.transaction")
@patch("apps.api.rest.v0.sponsor.SponsorModel")
@patch("apps.api.rest.v0.sponsor.slugify")
- def test_apply_sponsor_duplicate(self, mock_slugify, mock_sponsor_model):
- """Test sponsor application with duplicate organization name."""
+ def test_apply_sponsor_duplicate(self, mock_slugify, mock_sponsor_model, mock_transaction):
+ """Test sponsor application with duplicate organization name returns BAD_REQUEST."""
mock_request = MagicMock()
mock_slugify.return_value = "existing-org"
- mock_sponsor_model.objects.filter.return_value.exists.return_value = True
+ mock_sponsor_model.SponsorStatus.DRAFT = "draft"
+ mock_transaction.atomic.return_value.__enter__ = MagicMock(return_value=None)
+ mock_transaction.atomic.return_value.__exit__ = MagicMock(return_value=False)
+ mock_sponsor_model.objects.create.side_effect = IntegrityError
payload = MagicMock()
payload.organization_name = "Existing Org"
+ payload.website = "https://existing.org"
+ payload.contact_email = "sponsor@existing.org"
+ payload.message = ""
+
+ result = apply_sponsor(mock_request, payload)
+
+ assert result.status_code == HTTPStatus.BAD_REQUEST
+
+ @patch("apps.api.rest.v0.sponsor.slugify")
+ def test_apply_sponsor_empty_key(self, mock_slugify):
+ """Test sponsor application with org name that produces an empty slug."""
+ mock_request = MagicMock()
+ mock_slugify.return_value = ""
+
+ payload = MagicMock()
+ payload.organization_name = "!!!"
result = apply_sponsor(mock_request, payload)
diff --git a/backend/tests/unit/apps/owasp/api/internal/queries/sponsor_test.py b/backend/tests/unit/apps/owasp/api/internal/queries/sponsor_test.py
index 2c60585bd2..d76f56348e 100644
--- a/backend/tests/unit/apps/owasp/api/internal/queries/sponsor_test.py
+++ b/backend/tests/unit/apps/owasp/api/internal/queries/sponsor_test.py
@@ -16,7 +16,7 @@ def test_sponsors_returns_sorted_by_type(self):
platinum = MagicMock()
platinum.sponsor_type = Sponsor.SponsorType.PLATINUM
- with patch.object(Sponsor.objects, "all", return_value=[silver, diamond, platinum]):
+ with patch.object(Sponsor.objects, "filter", return_value=[silver, diamond, platinum]):
query = SponsorQuery()
result = list(query.sponsors())
diff --git a/frontend/__tests__/unit/pages/SponsorApply.test.tsx b/frontend/__tests__/unit/pages/SponsorApply.test.tsx
new file mode 100644
index 0000000000..8c1e58e438
--- /dev/null
+++ b/frontend/__tests__/unit/pages/SponsorApply.test.tsx
@@ -0,0 +1,267 @@
+import { addToast } from '@heroui/toast'
+import { screen, waitFor, fireEvent } from '@testing-library/react'
+import { render } from 'wrappers/testUtil'
+import SponsorApplyPage from 'app/sponsors/apply/page'
+
+jest.mock('@heroui/toast', () => ({
+ addToast: jest.fn(),
+}))
+
+jest.mock('utils/env.client', () => ({
+ API_URL: 'http://localhost:8000/',
+}))
+
+const mockFetch = jest.fn()
+
+describe('SponsorApplyPage', () => {
+ const mockAddToast = addToast as jest.MockedFunction
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ globalThis.fetch = mockFetch
+ })
+
+ test('renders form fields', () => {
+ render( )
+ expect(screen.getByLabelText(/organization name/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/website url/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/contact email/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/sponsorship interest/i)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /submit application/i })).toBeInTheDocument()
+ })
+
+ test('renders back to sponsors link', () => {
+ render( )
+ expect(screen.getByText('Back to Sponsors')).toBeInTheDocument()
+ })
+
+ test('renders cancel link pointing to /sponsors', () => {
+ render( )
+ expect(screen.getByText('Cancel')).toBeInTheDocument()
+ })
+
+ test('shows validation error when org name is empty on submit', async () => {
+ render( )
+
+ fireEvent.change(screen.getByLabelText(/contact email/i), {
+ target: { name: 'contactEmail', value: 'test@example.com' },
+ })
+ fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+
+ await waitFor(() => {
+ expect(screen.getByText('Organization name is required.')).toBeInTheDocument()
+ })
+ expect(mockFetch).not.toHaveBeenCalled()
+ })
+
+ test('shows validation error when contact email is empty on submit', async () => {
+ render( )
+
+ fireEvent.change(screen.getByLabelText(/organization name/i), {
+ target: { name: 'organizationName', value: 'My Org' },
+ })
+ fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+
+ await waitFor(() => {
+ expect(screen.getByText('Contact email is required.')).toBeInTheDocument()
+ })
+ expect(mockFetch).not.toHaveBeenCalled()
+ })
+
+ test('shows validation error for invalid email format', async () => {
+ render( )
+
+ fireEvent.change(screen.getByLabelText(/organization name/i), {
+ target: { name: 'organizationName', value: 'My Org' },
+ })
+ fireEvent.change(screen.getByLabelText(/contact email/i), {
+ target: { name: 'contactEmail', value: 'not-an-email' },
+ })
+ fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+
+ await waitFor(() => {
+ expect(screen.getByText('Please enter a valid email address.')).toBeInTheDocument()
+ })
+ expect(mockFetch).not.toHaveBeenCalled()
+ })
+
+ test('clears org name error when user types in the field', async () => {
+ render( )
+
+ fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+ await waitFor(() => {
+ expect(screen.getByText('Organization name is required.')).toBeInTheDocument()
+ })
+
+ fireEvent.change(screen.getByLabelText(/organization name/i), {
+ target: { name: 'organizationName', value: 'Updated Org' },
+ })
+ await waitFor(() => {
+ expect(screen.queryByText('Organization name is required.')).not.toBeInTheDocument()
+ })
+ })
+
+ test('clears email error when user types in the field', async () => {
+ render( )
+
+ fireEvent.change(screen.getByLabelText(/organization name/i), {
+ target: { name: 'organizationName', value: 'My Org' },
+ })
+ fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+ await waitFor(() => {
+ expect(screen.getByText('Contact email is required.')).toBeInTheDocument()
+ })
+
+ fireEvent.change(screen.getByLabelText(/contact email/i), {
+ target: { name: 'contactEmail', value: 'valid@example.com' },
+ })
+ await waitFor(() => {
+ expect(screen.queryByText('Contact email is required.')).not.toBeInTheDocument()
+ })
+ })
+
+ test('shows success state after successful submission', async () => {
+ mockFetch.mockResolvedValueOnce({ ok: true })
+
+ render( )
+
+ fireEvent.change(screen.getByLabelText(/organization name/i), {
+ target: { name: 'organizationName', value: 'Test Org' },
+ })
+ fireEvent.change(screen.getByLabelText(/contact email/i), {
+ target: { name: 'contactEmail', value: 'sponsor@example.com' },
+ })
+ fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+
+ await waitFor(() => {
+ expect(screen.getByText('Thank You for Your Interest!')).toBeInTheDocument()
+ })
+ expect(screen.getByText(/sponsor@example.com/)).toBeInTheDocument()
+ expect(mockAddToast).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Application Submitted', color: 'success' })
+ )
+ expect(screen.getByText('View Sponsors')).toBeInTheDocument()
+ expect(screen.getByText('Back to Home')).toBeInTheDocument()
+ })
+
+ test('shows error toast when submission returns non-OK response with message', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ headers: { get: jest.fn().mockReturnValue('application/json') },
+ json: () => Promise.resolve({ message: 'Duplicate application.' }),
+ })
+
+ render( )
+
+ fireEvent.change(screen.getByLabelText(/organization name/i), {
+ target: { name: 'organizationName', value: 'Test Org' },
+ })
+ fireEvent.change(screen.getByLabelText(/contact email/i), {
+ target: { name: 'contactEmail', value: 'sponsor@example.com' },
+ })
+ fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+
+ await waitFor(() => {
+ expect(mockAddToast).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Submission Failed', color: 'danger' })
+ )
+ })
+ expect(screen.queryByText('Thank You for Your Interest!')).not.toBeInTheDocument()
+ })
+
+ test('shows default error message when non-OK response has no message field', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ headers: { get: jest.fn().mockReturnValue('application/json') },
+ json: () => Promise.resolve({}),
+ })
+
+ render( )
+
+ fireEvent.change(screen.getByLabelText(/organization name/i), {
+ target: { name: 'organizationName', value: 'Test Org' },
+ })
+ fireEvent.change(screen.getByLabelText(/contact email/i), {
+ target: { name: 'contactEmail', value: 'sponsor@example.com' },
+ })
+ fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+
+ await waitFor(() => {
+ expect(mockAddToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ description: 'Failed to submit application. Please try again.',
+ color: 'danger',
+ })
+ )
+ })
+ })
+
+ test('shows error toast on network error', async () => {
+ mockFetch.mockRejectedValueOnce(new Error('Network failure'))
+
+ render( )
+
+ fireEvent.change(screen.getByLabelText(/organization name/i), {
+ target: { name: 'organizationName', value: 'Test Org' },
+ })
+ fireEvent.change(screen.getByLabelText(/contact email/i), {
+ target: { name: 'contactEmail', value: 'sponsor@example.com' },
+ })
+ fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+
+ await waitFor(() => {
+ expect(mockAddToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Error',
+ color: 'danger',
+ })
+ )
+ })
+ })
+
+ test('updates message field when user types', () => {
+ render( )
+ const messageField = screen.getByLabelText(/sponsorship interest/i)
+ fireEvent.change(messageField, {
+ target: { name: 'message', value: 'We love open source security!' },
+ })
+ expect(messageField).toHaveValue('We love open source security!')
+ })
+
+ test('calls fetch with correct payload on valid submission', async () => {
+ mockFetch.mockResolvedValueOnce({ ok: true })
+
+ render( )
+
+ fireEvent.change(screen.getByLabelText(/organization name/i), {
+ target: { name: 'organizationName', value: 'ACME Inc' },
+ })
+ fireEvent.change(screen.getByLabelText(/website url/i), {
+ target: { name: 'website', value: 'https://acme.com' },
+ })
+ fireEvent.change(screen.getByLabelText(/contact email/i), {
+ target: { name: 'contactEmail', value: 'info@acme.com' },
+ })
+ fireEvent.change(screen.getByLabelText(/sponsorship interest/i), {
+ target: { name: 'message', value: 'Interested in Gold tier' },
+ })
+ fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.stringContaining('api/v0/sponsors/apply'),
+ expect.objectContaining({
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: expect.stringContaining('ACME Inc'),
+ })
+ )
+ })
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body)
+ expect(body.organization_name).toBe('ACME Inc')
+ expect(body.contact_email).toBe('info@acme.com')
+ expect(body.website).toBe('https://acme.com')
+ expect(body.message).toBe('Interested in Gold tier')
+ })
+})
diff --git a/frontend/__tests__/unit/pages/Sponsors.test.tsx b/frontend/__tests__/unit/pages/Sponsors.test.tsx
new file mode 100644
index 0000000000..0af8346bee
--- /dev/null
+++ b/frontend/__tests__/unit/pages/Sponsors.test.tsx
@@ -0,0 +1,277 @@
+import { useQuery } from '@apollo/client/react'
+import { addToast } from '@heroui/toast'
+import { screen, waitFor } from '@testing-library/react'
+import { render } from 'wrappers/testUtil'
+import SponsorsPage from 'app/sponsors/page'
+
+jest.mock('@apollo/client/react', () => ({
+ ...jest.requireActual('@apollo/client/react'),
+ useQuery: jest.fn(),
+}))
+
+jest.mock('@heroui/toast', () => ({
+ addToast: jest.fn(),
+}))
+
+const mockSponsors = [
+ {
+ id: 'sponsor-1',
+ name: 'Diamond Corp',
+ description: 'A leading security firm',
+ imageUrl: 'https://example.com/diamond.png',
+ url: 'https://diamondcorp.com',
+ sponsorType: 'Diamond',
+ },
+ {
+ id: 'sponsor-2',
+ name: 'Platinum Inc',
+ description: '',
+ imageUrl: '',
+ url: '',
+ sponsorType: 'Platinum',
+ },
+ {
+ id: 'sponsor-3',
+ name: 'Gold Ltd',
+ description: 'Gold level sponsor description',
+ imageUrl: '',
+ url: 'https://goldltd.com',
+ sponsorType: 'Gold',
+ },
+ {
+ id: 'sponsor-4',
+ name: 'Supporter Co',
+ description: '',
+ imageUrl: '',
+ url: '',
+ sponsorType: 'Supporter',
+ },
+ {
+ id: 'sponsor-5',
+ name: 'Bronze Alliance',
+ description: '',
+ imageUrl: '',
+ url: '',
+ sponsorType: 'Bronze',
+ },
+ {
+ id: 'sponsor-6',
+ name: 'Excluded Corp',
+ description: '',
+ imageUrl: '',
+ url: '',
+ sponsorType: 'Not a Sponsor',
+ },
+]
+
+describe('SponsorsPage', () => {
+ const mockAddToast = addToast as jest.MockedFunction
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ ;(useQuery as unknown as jest.Mock).mockReturnValue({
+ data: { sponsors: mockSponsors },
+ error: null,
+ loading: false,
+ })
+ })
+
+ test('renders loading spinner when loading is true', () => {
+ ;(useQuery as unknown as jest.Mock).mockReturnValue({
+ data: null,
+ error: null,
+ loading: true,
+ })
+ render( )
+ expect(screen.getAllByAltText('Loading indicator').length).toBeGreaterThan(0)
+ })
+
+ test('shows "No Sponsors Yet" when sponsors list is empty', async () => {
+ ;(useQuery as unknown as jest.Mock).mockReturnValue({
+ data: { sponsors: [] },
+ error: null,
+ loading: false,
+ })
+ render( )
+ await waitFor(() => {
+ expect(screen.getByText('No Sponsors Yet')).toBeInTheDocument()
+ })
+ expect(screen.getByText('Be the first to support the OWASP Nest project!')).toBeInTheDocument()
+ })
+
+ test('shows "No Sponsors Yet" when data is null', async () => {
+ ;(useQuery as unknown as jest.Mock).mockReturnValue({
+ data: null,
+ error: null,
+ loading: false,
+ })
+ render( )
+ await waitFor(() => {
+ expect(screen.getByText('No Sponsors Yet')).toBeInTheDocument()
+ })
+ })
+
+ test('calls addToast with danger color on GraphQL error', async () => {
+ ;(useQuery as unknown as jest.Mock).mockReturnValue({
+ data: null,
+ error: { message: 'GraphQL error' },
+ loading: false,
+ })
+ render( )
+ await waitFor(() => {
+ expect(mockAddToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'GraphQL Request Failed',
+ color: 'danger',
+ })
+ )
+ })
+ })
+
+ test('renders error UI on GraphQL error instead of empty state', async () => {
+ ;(useQuery as unknown as jest.Mock).mockReturnValue({
+ data: null,
+ error: { message: 'GraphQL error' },
+ loading: false,
+ })
+ render( )
+ await waitFor(() => {
+ expect(screen.getByText('Unable to load sponsors')).toBeInTheDocument()
+ })
+ expect(screen.getByText(/Something went wrong while fetching sponsor data/)).toBeInTheDocument()
+ expect(screen.queryByText('No Sponsors Yet')).not.toBeInTheDocument()
+ })
+
+ test('renders sponsor names when data is loaded', async () => {
+ render( )
+ await waitFor(() => {
+ expect(screen.getByText('Diamond Corp')).toBeInTheDocument()
+ })
+ expect(screen.getByText('Platinum Inc')).toBeInTheDocument()
+ expect(screen.getByText('Gold Ltd')).toBeInTheDocument()
+ expect(screen.getByText('Supporter Co')).toBeInTheDocument()
+ })
+
+ test('renders sponsor description when present', async () => {
+ render( )
+ await waitFor(() => {
+ expect(screen.getByText('A leading security firm')).toBeInTheDocument()
+ })
+ expect(screen.getByText('Gold level sponsor description')).toBeInTheDocument()
+ })
+
+ test('renders sponsor image when imageUrl is provided', async () => {
+ render( )
+ await waitFor(() => {
+ const image = screen.getByAltText('Diamond Corp logo')
+ expect(image).toHaveAttribute('src', 'https://example.com/diamond.png')
+ })
+ })
+
+ test('renders "Visit website" link when sponsor url is provided', async () => {
+ render( )
+ await waitFor(() => {
+ const visitLinks = screen.getAllByText('Visit website')
+ expect(visitLinks.length).toBeGreaterThan(0)
+ })
+ })
+
+ test('does not render "Visit website" when sponsor url is empty', async () => {
+ ;(useQuery as unknown as jest.Mock).mockReturnValue({
+ data: {
+ sponsors: [
+ {
+ id: 'no-url-sponsor',
+ name: 'No URL Sponsor',
+ description: '',
+ imageUrl: '',
+ url: '',
+ sponsorType: 'Gold',
+ },
+ ],
+ },
+ error: null,
+ loading: false,
+ })
+ render( )
+ await waitFor(() => {
+ expect(screen.getByText('No URL Sponsor')).toBeInTheDocument()
+ })
+ expect(screen.queryByText('Visit website')).not.toBeInTheDocument()
+ })
+
+ test('puts sponsors with unknown tier into "Other" group', async () => {
+ render( )
+ await waitFor(() => {
+ expect(screen.getByText('Other Sponsors')).toBeInTheDocument()
+ })
+ expect(screen.getByText('Bronze Alliance')).toBeInTheDocument()
+ })
+
+ test('excludes sponsors with "Not a Sponsor" type', async () => {
+ render( )
+ await waitFor(() => {
+ expect(screen.getByText('Diamond Corp')).toBeInTheDocument()
+ })
+ expect(screen.queryByText('Excluded Corp')).not.toBeInTheDocument()
+ })
+
+ test('renders tier group badge counts', async () => {
+ render( )
+ await waitFor(() => {
+ expect(screen.getByText('Diamond Sponsors')).toBeInTheDocument()
+ })
+ expect(screen.getByText('Gold Sponsors')).toBeInTheDocument()
+ })
+
+ test('renders "Become a Sponsor" section with apply and donate links', async () => {
+ render( )
+ await waitFor(() => {
+ expect(screen.getByText('Apply to Sponsor')).toBeInTheDocument()
+ })
+ expect(screen.getByText('Donate via OWASP')).toBeInTheDocument()
+ })
+
+ test('renders "Become a Sponsor" link in empty state', async () => {
+ ;(useQuery as unknown as jest.Mock).mockReturnValue({
+ data: { sponsors: [] },
+ error: null,
+ loading: false,
+ })
+ render( )
+ await waitFor(() => {
+ expect(screen.getAllByText('Become a Sponsor').length).toBeGreaterThan(0)
+ })
+ })
+
+ test('uses fallback style for unrecognised sponsorType', async () => {
+ ;(useQuery as unknown as jest.Mock).mockReturnValue({
+ data: {
+ sponsors: [
+ {
+ id: 'unknown-tier',
+ name: 'Unrecognised Corp',
+ description: '',
+ imageUrl: '',
+ url: '',
+ sponsorType: 'Obscure',
+ },
+ ],
+ },
+ error: null,
+ loading: false,
+ })
+ render( )
+ await waitFor(() => {
+ expect(screen.getByText('Unrecognised Corp')).toBeInTheDocument()
+ })
+ expect(screen.getByText('Other Sponsors')).toBeInTheDocument()
+ })
+
+ test('renders sponsor sponsor type badge', async () => {
+ render( )
+ await waitFor(() => {
+ expect(screen.getByText('Diamond')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/frontend/__tests__/unit/utils/deadlineUtils.test.ts b/frontend/__tests__/unit/utils/deadlineUtils.test.ts
index 34daa9400e..d4772b0e59 100644
--- a/frontend/__tests__/unit/utils/deadlineUtils.test.ts
+++ b/frontend/__tests__/unit/utils/deadlineUtils.test.ts
@@ -78,23 +78,10 @@ describe('deadlineUtils', () => {
})
it('should ignore time component and only compare dates', () => {
- const today = new Date()
- const earlyMorning = new Date(
- today.getUTCFullYear(),
- today.getUTCMonth(),
- today.getUTCDate(),
- 0,
- 0,
- 0
- )
- const lateNight = new Date(
- today.getUTCFullYear(),
- today.getUTCMonth(),
- today.getUTCDate(),
- 23,
- 59,
- 59
- )
+ const now = new Date()
+ const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
+ const earlyMorning = new Date(todayUtc)
+ const lateNight = new Date(todayUtc + 23 * 3600000 + 59 * 60000 + 59000)
expect(getDeadlineCategory(earlyMorning.toISOString())).toBe('due-soon')
expect(getDeadlineCategory(lateNight.toISOString())).toBe('due-soon')
diff --git a/frontend/src/app/sponsors/apply/page.tsx b/frontend/src/app/sponsors/apply/page.tsx
index f10f82e234..6a62c48279 100644
--- a/frontend/src/app/sponsors/apply/page.tsx
+++ b/frontend/src/app/sponsors/apply/page.tsx
@@ -27,7 +27,7 @@ const validateForm = (data: FormData): FormErrors => {
}
if (!data.contactEmail.trim()) {
errors.contactEmail = 'Contact email is required.'
- } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.contactEmail)) {
+ } else if (!/^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(data.contactEmail)) {
errors.contactEmail = 'Please enter a valid email address.'
}
return errors
@@ -70,7 +70,7 @@ export default function SponsorApplyPage() {
payload['message'] = formData.message
payload['organization_name'] = formData.organizationName
payload['website'] = formData.website
- const response = await fetch(`${API_URL}api/v0/sponsors/apply`, {
+ const response = await fetch(new URL('/api/v0/sponsors/apply', API_URL).toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -90,9 +90,13 @@ export default function SponsorApplyPage() {
variant: 'solid',
})
} else {
- const data = await response.json()
+ const isJson = response.headers?.get('content-type')?.includes('application/json')
+ const errorMessage = isJson
+ ? (((await response.json()) as { message?: string }).message ??
+ 'Failed to submit application. Please try again.')
+ : (await response.text()) || 'Failed to submit application. Please try again.'
addToast({
- description: data.message || 'Failed to submit application. Please try again.',
+ description: errorMessage,
title: 'Submission Failed',
timeout: 5000,
shouldShowTimeoutProgress: true,
diff --git a/frontend/src/app/sponsors/page.tsx b/frontend/src/app/sponsors/page.tsx
index cc303ced43..03536f73e3 100644
--- a/frontend/src/app/sponsors/page.tsx
+++ b/frontend/src/app/sponsors/page.tsx
@@ -121,6 +121,32 @@ export default function SponsorsPage() {
return
}
+ if (graphQLRequestError) {
+ return (
+
+
+
+
+
+
+ Unable to load sponsors
+
+
+ Something went wrong while fetching sponsor data. Please try again later.
+
+
+ Retry
+
+
+
+
+
+ )
+ }
+
const sponsors = graphQLData?.sponsors ?? []
const groupedSponsors = TIER_ORDER.reduce>((acc, tier) => {
diff --git a/frontend/src/components/LogoCarousel.tsx b/frontend/src/components/LogoCarousel.tsx
index 716feb149d..396bcd281c 100644
--- a/frontend/src/components/LogoCarousel.tsx
+++ b/frontend/src/components/LogoCarousel.tsx
@@ -76,7 +76,12 @@ export default function MovingLogos({ sponsors }: Readonly) {
If you're interested in sponsoring the OWASP Nest project ❤️{' '}
-
+
click here
.
From a71c02d229c8b46deaaa1d65de7c3856c6539285 Mon Sep 17 00:00:00 2001
From: mrkeshav-05
Date: Sun, 12 Apr 2026 22:39:27 +0530
Subject: [PATCH 4/5] fix issues
---
.../api/internal/queries/sponsor_test.py | 3 ++-
.../unit/pages/SponsorApply.test.tsx | 26 ++++++++++++-------
frontend/src/app/sponsors/apply/page.tsx | 3 ++-
3 files changed, 20 insertions(+), 12 deletions(-)
diff --git a/backend/tests/unit/apps/owasp/api/internal/queries/sponsor_test.py b/backend/tests/unit/apps/owasp/api/internal/queries/sponsor_test.py
index d76f56348e..812daf0f37 100644
--- a/backend/tests/unit/apps/owasp/api/internal/queries/sponsor_test.py
+++ b/backend/tests/unit/apps/owasp/api/internal/queries/sponsor_test.py
@@ -16,10 +16,11 @@ def test_sponsors_returns_sorted_by_type(self):
platinum = MagicMock()
platinum.sponsor_type = Sponsor.SponsorType.PLATINUM
- with patch.object(Sponsor.objects, "filter", return_value=[silver, diamond, platinum]):
+ with patch.object(Sponsor.objects, "filter", return_value=[silver, diamond, platinum]) as mock_filter:
query = SponsorQuery()
result = list(query.sponsors())
+ mock_filter.assert_called_once_with(status=Sponsor.SponsorStatus.ACTIVE)
assert result[0] == diamond
assert result[1] == platinum
assert result[2] == silver
diff --git a/frontend/__tests__/unit/pages/SponsorApply.test.tsx b/frontend/__tests__/unit/pages/SponsorApply.test.tsx
index 8c1e58e438..1304755dc3 100644
--- a/frontend/__tests__/unit/pages/SponsorApply.test.tsx
+++ b/frontend/__tests__/unit/pages/SponsorApply.test.tsx
@@ -13,6 +13,12 @@ jest.mock('utils/env.client', () => ({
const mockFetch = jest.fn()
+const submitForm = () => {
+ fireEvent.submit(
+ screen.getByRole('button', { name: /submit application/i }).closest('form') as HTMLFormElement
+ )
+}
+
describe('SponsorApplyPage', () => {
const mockAddToast = addToast as jest.MockedFunction
@@ -46,7 +52,7 @@ describe('SponsorApplyPage', () => {
fireEvent.change(screen.getByLabelText(/contact email/i), {
target: { name: 'contactEmail', value: 'test@example.com' },
})
- fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+ submitForm()
await waitFor(() => {
expect(screen.getByText('Organization name is required.')).toBeInTheDocument()
@@ -60,7 +66,7 @@ describe('SponsorApplyPage', () => {
fireEvent.change(screen.getByLabelText(/organization name/i), {
target: { name: 'organizationName', value: 'My Org' },
})
- fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+ submitForm()
await waitFor(() => {
expect(screen.getByText('Contact email is required.')).toBeInTheDocument()
@@ -77,7 +83,7 @@ describe('SponsorApplyPage', () => {
fireEvent.change(screen.getByLabelText(/contact email/i), {
target: { name: 'contactEmail', value: 'not-an-email' },
})
- fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+ submitForm()
await waitFor(() => {
expect(screen.getByText('Please enter a valid email address.')).toBeInTheDocument()
@@ -88,7 +94,7 @@ describe('SponsorApplyPage', () => {
test('clears org name error when user types in the field', async () => {
render( )
- fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+ submitForm()
await waitFor(() => {
expect(screen.getByText('Organization name is required.')).toBeInTheDocument()
})
@@ -107,7 +113,7 @@ describe('SponsorApplyPage', () => {
fireEvent.change(screen.getByLabelText(/organization name/i), {
target: { name: 'organizationName', value: 'My Org' },
})
- fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+ submitForm()
await waitFor(() => {
expect(screen.getByText('Contact email is required.')).toBeInTheDocument()
})
@@ -131,7 +137,7 @@ describe('SponsorApplyPage', () => {
fireEvent.change(screen.getByLabelText(/contact email/i), {
target: { name: 'contactEmail', value: 'sponsor@example.com' },
})
- fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+ submitForm()
await waitFor(() => {
expect(screen.getByText('Thank You for Your Interest!')).toBeInTheDocument()
@@ -159,7 +165,7 @@ describe('SponsorApplyPage', () => {
fireEvent.change(screen.getByLabelText(/contact email/i), {
target: { name: 'contactEmail', value: 'sponsor@example.com' },
})
- fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+ submitForm()
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith(
@@ -184,7 +190,7 @@ describe('SponsorApplyPage', () => {
fireEvent.change(screen.getByLabelText(/contact email/i), {
target: { name: 'contactEmail', value: 'sponsor@example.com' },
})
- fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+ submitForm()
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith(
@@ -207,7 +213,7 @@ describe('SponsorApplyPage', () => {
fireEvent.change(screen.getByLabelText(/contact email/i), {
target: { name: 'contactEmail', value: 'sponsor@example.com' },
})
- fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+ submitForm()
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith(
@@ -245,7 +251,7 @@ describe('SponsorApplyPage', () => {
fireEvent.change(screen.getByLabelText(/sponsorship interest/i), {
target: { name: 'message', value: 'Interested in Gold tier' },
})
- fireEvent.submit(screen.getByRole('button', { name: /submit application/i }).closest('form')!)
+ submitForm()
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
diff --git a/frontend/src/app/sponsors/apply/page.tsx b/frontend/src/app/sponsors/apply/page.tsx
index 6a62c48279..86a0ba573a 100644
--- a/frontend/src/app/sponsors/apply/page.tsx
+++ b/frontend/src/app/sponsors/apply/page.tsx
@@ -70,7 +70,8 @@ export default function SponsorApplyPage() {
payload['message'] = formData.message
payload['organization_name'] = formData.organizationName
payload['website'] = formData.website
- const response = await fetch(new URL('/api/v0/sponsors/apply', API_URL).toString(), {
+ const baseUrl = (API_URL ?? '').replace(/\/$/, '')
+ const response = await fetch(`${baseUrl}/api/v0/sponsors/apply`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
From b145f66ab54df498656c20b775e1941a1ef76f4f Mon Sep 17 00:00:00 2001
From: mrkeshav-05
Date: Sun, 12 Apr 2026 23:07:17 +0530
Subject: [PATCH 5/5] fix code-rabbit
---
frontend/__tests__/unit/pages/SponsorApply.test.tsx | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/frontend/__tests__/unit/pages/SponsorApply.test.tsx b/frontend/__tests__/unit/pages/SponsorApply.test.tsx
index 1304755dc3..cdb10b9a30 100644
--- a/frontend/__tests__/unit/pages/SponsorApply.test.tsx
+++ b/frontend/__tests__/unit/pages/SponsorApply.test.tsx
@@ -38,12 +38,16 @@ describe('SponsorApplyPage', () => {
test('renders back to sponsors link', () => {
render( )
- expect(screen.getByText('Back to Sponsors')).toBeInTheDocument()
+ const link = screen.getByRole('link', { name: /back to sponsors/i })
+ expect(link).toBeInTheDocument()
+ expect(link).toHaveAttribute('href', '/sponsors')
})
test('renders cancel link pointing to /sponsors', () => {
render( )
- expect(screen.getByText('Cancel')).toBeInTheDocument()
+ const link = screen.getByRole('link', { name: /cancel/i })
+ expect(link).toBeInTheDocument()
+ expect(link).toHaveAttribute('href', '/sponsors')
})
test('shows validation error when org name is empty on submit', async () => {