diff --git a/backend/apps/api/rest/v0/sponsor.py b/backend/apps/api/rest/v0/sponsor.py index 4641e7639c..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 @@ -11,6 +13,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 +22,7 @@ class SponsorBase(Schema): """Base schema for Sponsor (used in list endpoints).""" + description: str image_url: str key: str name: str @@ -33,10 +37,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 +49,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 +83,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 +106,91 @@ 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.).""" + 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) + .annotate(tier_order=tier_order) + .order_by("tier_order", "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 not key: + return Response( + {"message": "Organization name must produce a valid key."}, + status=HTTPStatus.BAD_REQUEST, + ) + + duplicate_response = Response( + {"message": "A sponsor application with this organization name already exists."}, + status=HTTPStatus.BAD_REQUEST, + ) + + 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( + { + "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/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, 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..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,8 +2,15 @@ from unittest.mock import MagicMock, patch import pytest +from django.db import IntegrityError -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 +26,7 @@ class TestSponsorSchema: "member_type": "PLATINUM", "name": "Gold Sponsor Inc.", "sponsor_type": "GOLD", + "status": "active", "url": "https://goldsponsor.com", }, { @@ -30,6 +38,7 @@ class TestSponsorSchema: "member_type": "SILVER", "name": "Silver Sponsor LLC", "sponsor_type": "SILVER", + "status": "active", "url": "https://silversponsor.com", }, ], @@ -46,6 +55,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 +69,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 +77,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 +88,78 @@ 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 that active sponsors are ordered by tier precedence then name.""" + mock_request = MagicMock() + mock_queryset = MagicMock() + ( + 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) + + mock_sponsor_model.objects.filter.assert_called_once_with( + status=mock_sponsor_model.SponsorStatus.ACTIVE + ) + ( + mock_sponsor_model.objects.filter.return_value.annotate.return_value.order_by + ).assert_called_once_with("tier_order", "name") + + class TestGetSponsor: """Tests for get_sponsor endpoint.""" @@ -124,3 +184,72 @@ 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.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, mock_transaction): + """Test successful sponsor application.""" + mock_request = MagicMock() + mock_slugify.return_value = "test-org" + 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" + 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_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, mock_transaction): + """Test sponsor application with duplicate organization name returns BAD_REQUEST.""" + mock_request = MagicMock() + mock_slugify.return_value = "existing-org" + 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) + + assert result.status_code == HTTPStatus.BAD_REQUEST 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..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, "all", 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 new file mode 100644 index 0000000000..cdb10b9a30 --- /dev/null +++ b/frontend/__tests__/unit/pages/SponsorApply.test.tsx @@ -0,0 +1,277 @@ +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() + +const submitForm = () => { + fireEvent.submit( + screen.getByRole('button', { name: /submit application/i }).closest('form') as HTMLFormElement + ) +} + +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() + 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() + 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 () => { + render() + + fireEvent.change(screen.getByLabelText(/contact email/i), { + target: { name: 'contactEmail', value: 'test@example.com' }, + }) + submitForm() + + 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' }, + }) + submitForm() + + 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' }, + }) + submitForm() + + 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() + + submitForm() + 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' }, + }) + submitForm() + 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' }, + }) + submitForm() + + 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' }, + }) + submitForm() + + 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' }, + }) + submitForm() + + 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' }, + }) + submitForm() + + 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' }, + }) + submitForm() + + 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 new file mode 100644 index 0000000000..86a0ba573a --- /dev/null +++ b/frontend/src/app/sponsors/apply/page.tsx @@ -0,0 +1,311 @@ +'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 baseUrl = (API_URL ?? '').replace(/\/$/, '') + const response = await fetch(`${baseUrl}/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 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: errorMessage, + 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. +

+
+ + }> +