diff --git a/backend/apps/core/api/internal/algolia.py b/backend/apps/core/api/internal/algolia.py index 3353b68fcd..1b9afe7f8f 100644 --- a/backend/apps/core/api/internal/algolia.py +++ b/backend/apps/core/api/internal/algolia.py @@ -14,10 +14,10 @@ from apps.common.index import IndexBase from apps.common.utils import get_user_ip_address +from apps.core.constants import CACHE_PREFIX from apps.core.utils.index import deep_camelize, get_params_for_index from apps.core.validators import validate_search_params -CACHE_PREFIX = "algolia_proxy" CACHE_TTL_IN_SECONDS = 3600 # 1 hour diff --git a/backend/apps/core/constants.py b/backend/apps/core/constants.py new file mode 100644 index 0000000000..49c682d694 --- /dev/null +++ b/backend/apps/core/constants.py @@ -0,0 +1,3 @@ +"""Core app constants.""" + +CACHE_PREFIX = "algolia_proxy" diff --git a/backend/apps/core/utils/index.py b/backend/apps/core/utils/index.py index 1a80391302..7e7d799ae6 100644 --- a/backend/apps/core/utils/index.py +++ b/backend/apps/core/utils/index.py @@ -1,12 +1,17 @@ """Index utils.""" import contextlib +import logging from algoliasearch_django import register, unregister from algoliasearch_django.registration import RegistrationError from django.apps import apps +from django.core.cache import cache from apps.common.utils import convert_to_camel_case +from apps.core.constants import CACHE_PREFIX + +logger = logging.getLogger(__name__) class DisableIndexing: @@ -140,6 +145,18 @@ def get_params_for_index(index_name: str) -> dict: ] params["aroundLatLngViaIP"] = True + case "programs": + params["attributesToRetrieve"] = [ + "idx_description", + "idx_ended_at", + "idx_experience_levels", + "idx_key", + "idx_modules", + "idx_name", + "idx_started_at", + "idx_status", + ] + case "projects": params["attributesToRetrieve"] = [ "idx_contributors_count", @@ -213,3 +230,32 @@ def get_params_for_index(index_name: str) -> dict: params["attributesToRetrieve"] = [] return params + + +def clear_index_cache(index_name: str) -> None: + """Clear Algolia proxy cache entries from the cache store that match a given index name. + + Args: + index_name (str): The specific index to clear cache for. + If empty, the function does nothing. + + Returns: + None + + """ + if not index_name: + logger.info("No index name provided, skipping cache clear.") + return + + pattern = f"{CACHE_PREFIX}:{index_name}*" + keys_to_delete = list(cache.iter_keys(pattern)) + + if not keys_to_delete: + logger.info("No matching cache keys found for pattern: %s", pattern) + return + + logger.info("Deleting %d cache keys for pattern: %s", len(keys_to_delete), pattern) + + for key in keys_to_delete: + logger.info("Deleting key: %s", key) + cache.delete(key) diff --git a/backend/apps/mentorship/admin/program.py b/backend/apps/mentorship/admin/program.py index 031d245a38..ea8c2f4c5e 100644 --- a/backend/apps/mentorship/admin/program.py +++ b/backend/apps/mentorship/admin/program.py @@ -2,7 +2,7 @@ from django.contrib import admin -from apps.mentorship.models.program import Program +from apps.mentorship.models import Program class ProgramAdmin(admin.ModelAdmin): diff --git a/backend/apps/mentorship/api/__init__.py b/backend/apps/mentorship/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/apps/mentorship/api/internal/__init__.py b/backend/apps/mentorship/api/internal/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/apps/mentorship/api/internal/mutations/__init__.py b/backend/apps/mentorship/api/internal/mutations/__init__.py new file mode 100644 index 0000000000..4c0f1e5333 --- /dev/null +++ b/backend/apps/mentorship/api/internal/mutations/__init__.py @@ -0,0 +1,4 @@ +"""Mentorship mutations.""" + +from .module import ModuleMutation +from .program import ProgramMutation diff --git a/backend/apps/mentorship/api/internal/mutations/module.py b/backend/apps/mentorship/api/internal/mutations/module.py new file mode 100644 index 0000000000..b185f10148 --- /dev/null +++ b/backend/apps/mentorship/api/internal/mutations/module.py @@ -0,0 +1,197 @@ +"""GraphQL mutations for mentorship modules in the mentorship app.""" + +import logging + +import strawberry +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError +from django.db import transaction +from django.utils import timezone + +from apps.github.models import User as GithubUser +from apps.mentorship.api.internal.nodes.module import ( + CreateModuleInput, + ModuleNode, + UpdateModuleInput, +) +from apps.mentorship.models import Mentor, Module, Program +from apps.nest.api.internal.permissions import IsAuthenticated +from apps.owasp.models import Project + +logger = logging.getLogger(__name__) + + +def resolve_mentors_from_logins(logins: list[str]) -> set[Mentor]: + """Resolve a list of GitHub logins to a set of Mentor objects.""" + mentors = set() + for login in logins: + try: + github_user = GithubUser.objects.get(login__iexact=login.lower()) + mentor, _ = Mentor.objects.get_or_create(github_user=github_user) + mentors.add(mentor) + except GithubUser.DoesNotExist as e: + msg = f"GitHub user '{login}' not found." + logger.warning(msg, exc_info=True) + raise ValueError(msg) from e + return mentors + + +def _validate_module_dates(started_at, ended_at, program_started_at, program_ended_at) -> tuple: + """Validate and normalize module start/end dates against program constraints.""" + if started_at is None or ended_at is None: + msg = "Both start and end dates are required." + raise ValidationError(message=msg) + + if timezone.is_naive(started_at): + started_at = timezone.make_aware(started_at) + if timezone.is_naive(ended_at): + ended_at = timezone.make_aware(ended_at) + + if ended_at <= started_at: + msg = "End date must be after start date." + raise ValidationError(message=msg) + + if started_at < program_started_at: + msg = "Module start date cannot be before program start date." + raise ValidationError(message=msg) + + if ended_at > program_ended_at: + msg = "Module end date cannot be after program end date." + raise ValidationError(message=msg) + + return started_at, ended_at + + +@strawberry.type +class ModuleMutation: + """GraphQL mutations related to the mentorship Module model.""" + + @strawberry.mutation(permission_classes=[IsAuthenticated]) + @transaction.atomic + def create_module(self, info: strawberry.Info, input_data: CreateModuleInput) -> ModuleNode: + """Create a new mentorship module. User must be an admin of the program.""" + user = info.context.request.user + + try: + program = Program.objects.get(key=input_data.program_key) + project = Project.objects.get(id=input_data.project_id) + creator_as_mentor = Mentor.objects.get(nest_user=user) + except (Program.DoesNotExist, Project.DoesNotExist) as e: + msg = f"{e.__class__.__name__} matching query does not exist." + raise ObjectDoesNotExist(msg) from e + except Mentor.DoesNotExist as e: + msg = "Only mentors can create modules." + raise PermissionDenied(msg) from e + + if not program.admins.filter(id=creator_as_mentor.id).exists(): + raise PermissionDenied + + started_at, ended_at = _validate_module_dates( + input_data.started_at, + input_data.ended_at, + program.started_at, + program.ended_at, + ) + + module = Module.objects.create( + name=input_data.name, + description=input_data.description, + experience_level=input_data.experience_level.value, + started_at=started_at, + ended_at=ended_at, + domains=input_data.domains, + tags=input_data.tags, + program=program, + project=project, + ) + + if module.experience_level not in program.experience_levels: + program.experience_levels.append(module.experience_level) + program.save(update_fields=["experience_levels"]) + + mentors_to_set = resolve_mentors_from_logins(input_data.mentor_logins or []) + mentors_to_set.add(creator_as_mentor) + module.mentors.set(list(mentors_to_set)) + + return module + + @strawberry.mutation(permission_classes=[IsAuthenticated]) + @transaction.atomic + def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) -> ModuleNode: + """Update an existing mentorship module. User must be an admin of the program.""" + user = info.context.request.user + + try: + module = Module.objects.select_related("program").get( + key=input_data.key, program__key=input_data.program_key + ) + except Module.DoesNotExist as e: + msg = "Module not found." + raise ObjectDoesNotExist(msg) from e + + try: + creator_as_mentor = Mentor.objects.get(nest_user=user) + except Mentor.DoesNotExist as err: + msg = "Only mentors can edit modules." + logger.warning( + "User '%s' is not a mentor and cannot edit modules.", + user.username, + exc_info=True, + ) + raise PermissionDenied(msg) from err + + if not module.program.admins.filter(id=creator_as_mentor.id).exists(): + raise PermissionDenied + + started_at, ended_at = _validate_module_dates( + input_data.started_at, + input_data.ended_at, + module.program.started_at, + module.program.ended_at, + ) + + old_experience_level = module.experience_level + + field_mapping = { + "name": input_data.name, + "description": input_data.description, + "experience_level": input_data.experience_level.value, + "started_at": started_at, + "ended_at": ended_at, + "domains": input_data.domains, + "tags": input_data.tags, + } + + for field, value in field_mapping.items(): + setattr(module, field, value) + + try: + module.project = Project.objects.get(id=input_data.project_id) + except Project.DoesNotExist as err: + msg = f"Project with id '{input_data.project_id}' not found." + logger.warning(msg, exc_info=True) + raise ObjectDoesNotExist(msg) from err + + if input_data.mentor_logins is not None: + mentors_to_set = resolve_mentors_from_logins(input_data.mentor_logins) + module.mentors.set(mentors_to_set) + + module.save() + + if module.experience_level not in module.program.experience_levels: + module.program.experience_levels.append(module.experience_level) + + # Remove old experience level if no other module is using it + if ( + old_experience_level != module.experience_level + and old_experience_level in module.program.experience_levels + and not Module.objects.filter( + program=module.program, experience_level=old_experience_level + ) + .exclude(id=module.id) + .exists() + ): + module.program.experience_levels.remove(old_experience_level) + + module.program.save(update_fields=["experience_levels"]) + + return module diff --git a/backend/apps/mentorship/api/internal/mutations/program.py b/backend/apps/mentorship/api/internal/mutations/program.py new file mode 100644 index 0000000000..0905e28c53 --- /dev/null +++ b/backend/apps/mentorship/api/internal/mutations/program.py @@ -0,0 +1,166 @@ +"""Mentorship Program GraphQL Mutations.""" + +import logging + +import strawberry +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError +from django.db import transaction + +from apps.mentorship.api.internal.mutations.module import resolve_mentors_from_logins +from apps.mentorship.api.internal.nodes.enum import ProgramStatusEnum +from apps.mentorship.api.internal.nodes.program import ( + CreateProgramInput, + ProgramNode, + UpdateProgramInput, + UpdateProgramStatusInput, +) +from apps.mentorship.models import Mentor, Program +from apps.nest.api.internal.permissions import IsAuthenticated + +logger = logging.getLogger(__name__) + + +@strawberry.type +class ProgramMutation: + """GraphQL mutations related to program.""" + + @strawberry.mutation(permission_classes=[IsAuthenticated]) + @transaction.atomic + def create_program(self, info: strawberry.Info, input_data: CreateProgramInput) -> ProgramNode: + """Create a new mentorship program.""" + user = info.context.request.user + + mentor, created = Mentor.objects.get_or_create( + nest_user=user, defaults={"github_user": user.github_user} + ) + if created: + logger.info("Created a new mentor profile for user '%s'.", user.username) + + if input_data.ended_at <= input_data.started_at: + msg = "End date must be after start date." + logger.warning( + "Validation error for user '%s' creating program '%s': %s", + user.username, + input_data.name, + msg, + ) + raise ValidationError(msg) + + program = Program.objects.create( + name=input_data.name, + description=input_data.description, + mentees_limit=input_data.mentees_limit, + started_at=input_data.started_at, + ended_at=input_data.ended_at, + domains=input_data.domains, + tags=input_data.tags, + status=ProgramStatusEnum.DRAFT.value, + ) + + program.admins.set([mentor]) + + logger.info( + "User '%s' successfully created program '%s' (ID: %s).", + user.username, + program.name, + program.id, + ) + + return program + + @strawberry.mutation(permission_classes=[IsAuthenticated]) + @transaction.atomic + def update_program(self, info: strawberry.Info, input_data: UpdateProgramInput) -> ProgramNode: + """Update an existing mentorship program. Only admins can update.""" + user = info.context.request.user + + try: + program = Program.objects.get(key=input_data.key) + except Program.DoesNotExist as err: + msg = f"Program with key '{input_data.key}' not found." + logger.warning(msg, exc_info=True) + raise ObjectDoesNotExist(msg) from err + + try: + admin = Mentor.objects.get(nest_user=user) + except Mentor.DoesNotExist as err: + msg = "You must be a mentor to update a program." + logger.warning( + "User '%s' is not a mentor and cannot update programs.", + user.username, + exc_info=True, + ) + raise PermissionDenied(msg) from err + + if not program.admins.filter(id=admin.id).exists(): + msg = "You must be an admin of this program to update it." + logger.warning( + "Permission denied for user '%s' to update program '%s'.", + user.username, + program.key, + ) + raise PermissionDenied(msg) + + if ( + input_data.ended_at is not None + and input_data.started_at is not None + and input_data.ended_at <= input_data.started_at + ): + msg = "End date must be after start date." + logger.warning("Validation error updating program '%s': %s", program.key, msg) + raise ValidationError(msg) + + field_mapping = { + "name": input_data.name, + "description": input_data.description, + "mentees_limit": input_data.mentees_limit, + "started_at": input_data.started_at, + "ended_at": input_data.ended_at, + "domains": input_data.domains, + "tags": input_data.tags, + } + + for field, value in field_mapping.items(): + if value is not None: + setattr(program, field, value) + + if input_data.status is not None: + program.status = input_data.status.value + + program.save() + + if input_data.admin_logins is not None: + admins_to_set = resolve_mentors_from_logins(input_data.admin_logins) + program.admins.set(admins_to_set) + + return program + + @strawberry.mutation(permission_classes=[IsAuthenticated]) + @transaction.atomic + def update_program_status( + self, info: strawberry.Info, input_data: UpdateProgramStatusInput + ) -> ProgramNode: + """Update only the status of a program.""" + user = info.context.request.user + + try: + program = Program.objects.get(key=input_data.key) + except Program.DoesNotExist as e: + msg = f"Program with key '{input_data.key}' not found." + raise ObjectDoesNotExist(msg) from e + + try: + mentor = Mentor.objects.get(nest_user=user) + except Mentor.DoesNotExist as e: + msg = "You must be a mentor to update a program." + raise PermissionDenied(msg) from e + + if not program.admins.filter(id=mentor.id).exists(): + raise PermissionDenied + + program.status = input_data.status.value + program.save() + + logger.info("Updated status of program '%s' to '%s'", program.key, program.status) + + return program diff --git a/backend/apps/mentorship/api/internal/nodes/__init__.py b/backend/apps/mentorship/api/internal/nodes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/apps/mentorship/api/internal/nodes/enum.py b/backend/apps/mentorship/api/internal/nodes/enum.py new file mode 100644 index 0000000000..32bdad33b0 --- /dev/null +++ b/backend/apps/mentorship/api/internal/nodes/enum.py @@ -0,0 +1,27 @@ +"""GraphQL enum for Mentorship App.""" + +import enum + +import strawberry + +from apps.mentorship.models import Program +from apps.mentorship.models.common.experience_level import ExperienceLevel + + +@strawberry.enum +class ExperienceLevelEnum(enum.Enum): + """experience level enum.""" + + BEGINNER = ExperienceLevel.ExperienceLevelChoices.BEGINNER + INTERMEDIATE = ExperienceLevel.ExperienceLevelChoices.INTERMEDIATE + ADVANCED = ExperienceLevel.ExperienceLevelChoices.ADVANCED + EXPERT = ExperienceLevel.ExperienceLevelChoices.EXPERT + + +@strawberry.enum +class ProgramStatusEnum(enum.Enum): + """program status enum.""" + + DRAFT = Program.ProgramStatus.DRAFT + PUBLISHED = Program.ProgramStatus.PUBLISHED + COMPLETED = Program.ProgramStatus.COMPLETED diff --git a/backend/apps/mentorship/api/internal/nodes/mentor.py b/backend/apps/mentorship/api/internal/nodes/mentor.py new file mode 100644 index 0000000000..5ef71cda1c --- /dev/null +++ b/backend/apps/mentorship/api/internal/nodes/mentor.py @@ -0,0 +1,25 @@ +"""GraphQL node for Mentor model.""" + +import strawberry + + +@strawberry.type +class MentorNode: + """A GraphQL node representing a mentorship mentor.""" + + id: strawberry.ID + + @strawberry.field + def avatar_url(self) -> str: + """Get the GitHub avatar URL of the mentor.""" + return self.github_user.avatar_url if self.github_user else "" + + @strawberry.field + def name(self) -> str: + """Get the GitHub name of the mentor.""" + return self.github_user.name if self.github_user else "" + + @strawberry.field + def login(self) -> str: + """Get the GitHub login of the mentor.""" + return self.github_user.login if self.github_user else "" diff --git a/backend/apps/mentorship/api/internal/nodes/module.py b/backend/apps/mentorship/api/internal/nodes/module.py new file mode 100644 index 0000000000..fa94ad4728 --- /dev/null +++ b/backend/apps/mentorship/api/internal/nodes/module.py @@ -0,0 +1,71 @@ +"""GraphQL nodes for Module model.""" + +from datetime import datetime + +import strawberry + +from apps.mentorship.api.internal.nodes.enum import ExperienceLevelEnum +from apps.mentorship.api.internal.nodes.mentor import MentorNode +from apps.mentorship.api.internal.nodes.program import ProgramNode + + +@strawberry.type +class ModuleNode: + """A GraphQL node representing a mentorship module.""" + + id: strawberry.ID + key: str + name: str + description: str + domains: list[str] | None = None + ended_at: datetime + experience_level: ExperienceLevelEnum + program: ProgramNode | None = None + project_id: strawberry.ID | None = None + started_at: datetime + tags: list[str] | None = None + + @strawberry.field + def mentors(self) -> list[MentorNode]: + """Get the list of mentors for this module.""" + return self.mentors.all() + + @strawberry.field + def project_name(self) -> str | None: + """Get the project name for this module.""" + return self.project.name if self.project else None + + +@strawberry.input +class CreateModuleInput: + """Input for creating a mentorship module.""" + + name: str + description: str + domains: list[str] = strawberry.field(default_factory=list) + ended_at: datetime + experience_level: ExperienceLevelEnum + mentor_logins: list[str] | None = None + program_key: str + project_name: str + project_id: strawberry.ID + started_at: datetime + tags: list[str] = strawberry.field(default_factory=list) + + +@strawberry.input +class UpdateModuleInput: + """Input for updating a mentorship module.""" + + key: str + program_key: str + name: str + description: str + domains: list[str] = strawberry.field(default_factory=list) + ended_at: datetime + experience_level: ExperienceLevelEnum + mentor_logins: list[str] | None = None + project_id: strawberry.ID + project_name: str + started_at: datetime + tags: list[str] = strawberry.field(default_factory=list) diff --git a/backend/apps/mentorship/api/internal/nodes/program.py b/backend/apps/mentorship/api/internal/nodes/program.py new file mode 100644 index 0000000000..463caa076f --- /dev/null +++ b/backend/apps/mentorship/api/internal/nodes/program.py @@ -0,0 +1,81 @@ +"""GraphQL node for Program model.""" + +from datetime import datetime + +import strawberry + +from apps.mentorship.api.internal.nodes.enum import ( + ExperienceLevelEnum, + ProgramStatusEnum, +) +from apps.mentorship.api.internal.nodes.mentor import MentorNode + + +@strawberry.type +class ProgramNode: + """A mentorship program node.""" + + id: strawberry.ID + key: str + name: str + description: str + domains: list[str] | None = None + ended_at: datetime + experience_levels: list[ExperienceLevelEnum] | None = None + mentees_limit: int | None = None + started_at: datetime + status: ProgramStatusEnum + user_role: str | None = None + tags: list[str] | None = None + + @strawberry.field + def admins(self) -> list[MentorNode] | None: + """Get the list of program administrators.""" + return self.admins.all() + + +@strawberry.type +class PaginatedPrograms: + """A paginated list of mentorship programs.""" + + current_page: int + programs: list[ProgramNode] + total_pages: int + + +@strawberry.input +class CreateProgramInput: + """Input Node for creating a mentorship program.""" + + name: str + description: str + domains: list[str] = strawberry.field(default_factory=list) + ended_at: datetime + mentees_limit: int + started_at: datetime + tags: list[str] = strawberry.field(default_factory=list) + + +@strawberry.input +class UpdateProgramInput: + """Input for updating a mentorship program.""" + + key: str + name: str + description: str + admin_logins: list[str] | None = None + domains: list[str] | None = None + ended_at: datetime + mentees_limit: int + started_at: datetime + status: ProgramStatusEnum + tags: list[str] | None = None + + +@strawberry.input +class UpdateProgramStatusInput: + """Input for updating program status.""" + + key: str + name: str + status: ProgramStatusEnum diff --git a/backend/apps/mentorship/api/internal/queries/__init__.py b/backend/apps/mentorship/api/internal/queries/__init__.py new file mode 100644 index 0000000000..f690a807a0 --- /dev/null +++ b/backend/apps/mentorship/api/internal/queries/__init__.py @@ -0,0 +1,5 @@ +"""Core Mentorship mutations.""" + +from .mentorship import MentorshipQuery +from .module import ModuleQuery +from .program import ProgramQuery diff --git a/backend/apps/mentorship/api/internal/queries/mentorship.py b/backend/apps/mentorship/api/internal/queries/mentorship.py new file mode 100644 index 0000000000..90bd1f7a8b --- /dev/null +++ b/backend/apps/mentorship/api/internal/queries/mentorship.py @@ -0,0 +1,33 @@ +"""GraphQL queries for mentorship role management.""" + +import strawberry + +from apps.github.models.user import User as GithubUser +from apps.mentorship.models.mentor import Mentor + + +@strawberry.type +class UserRolesResult: + """Result type for user roles query.""" + + roles: list[str] + + +@strawberry.type +class MentorshipQuery: + """GraphQL queries for mentorship-related data.""" + + @strawberry.field + def is_mentor(self, login: str) -> bool: + """Check if a GitHub login is a mentor.""" + if not login or not login.strip(): + return False + + login = login.strip() + + try: + github_user = GithubUser.objects.get(login=login) + except GithubUser.DoesNotExist: + return False + + return Mentor.objects.filter(github_user=github_user).exists() diff --git a/backend/apps/mentorship/api/internal/queries/module.py b/backend/apps/mentorship/api/internal/queries/module.py new file mode 100644 index 0000000000..8fec753752 --- /dev/null +++ b/backend/apps/mentorship/api/internal/queries/module.py @@ -0,0 +1,50 @@ +"""OWASP module GraphQL queries.""" + +import logging + +import strawberry +from django.core.exceptions import ObjectDoesNotExist + +from apps.mentorship.api.internal.nodes.module import ModuleNode +from apps.mentorship.models import Module + +logger = logging.getLogger(__name__) + + +@strawberry.type +class ModuleQuery: + """Module queries.""" + + @strawberry.field + def get_program_modules(self, program_key: str) -> list[ModuleNode]: + """Get all modules by program Key. Returns an empty list if program is not found.""" + return ( + Module.objects.filter(program__key=program_key) + .select_related("program", "project") + .prefetch_related("mentors__github_user") + .order_by("started_at") + ) + + @strawberry.field + def get_project_modules(self, project_key: str) -> list[ModuleNode]: + """Get all modules by project Key. Returns an empty list if project is not found.""" + return ( + Module.objects.filter(project__key=project_key) + .select_related("program", "project") + .prefetch_related("mentors__github_user") + .order_by("started_at") + ) + + @strawberry.field + def get_module(self, module_key: str, program_key: str) -> ModuleNode: + """Get a single module by its key within a specific program.""" + try: + return ( + Module.objects.select_related("program", "project") + .prefetch_related("mentors__github_user") + .get(key=module_key, program__key=program_key) + ) + except Module.DoesNotExist as err: + msg = f"Module with key '{module_key}' under program '{program_key}' not found." + logger.warning(msg, exc_info=True) + raise ObjectDoesNotExist(msg) from err diff --git a/backend/apps/mentorship/api/internal/queries/program.py b/backend/apps/mentorship/api/internal/queries/program.py new file mode 100644 index 0000000000..0abf8a263d --- /dev/null +++ b/backend/apps/mentorship/api/internal/queries/program.py @@ -0,0 +1,80 @@ +"""OWASP program GraphQL queries.""" + +import logging + +import strawberry +from django.core.exceptions import ObjectDoesNotExist +from django.db.models import Q + +from apps.mentorship.api.internal.nodes.program import PaginatedPrograms, ProgramNode +from apps.mentorship.models import Program +from apps.mentorship.models.mentor import Mentor +from apps.nest.api.internal.permissions import IsAuthenticated + +PAGE_SIZE = 25 +logger = logging.getLogger(__name__) + + +@strawberry.type +class ProgramQuery: + """Program queries.""" + + @strawberry.field + def get_program(self, program_key: str) -> ProgramNode: + """Get a program by Key.""" + try: + program = Program.objects.prefetch_related("admins__github_user").get(key=program_key) + except Program.DoesNotExist as err: + msg = f"Program with key '{program_key}' not found." + logger.warning(msg, exc_info=True) + raise ObjectDoesNotExist(msg) from err + + return program + + @strawberry.field(permission_classes=[IsAuthenticated]) + def my_programs( + self, + info: strawberry.Info, + search: str = "", + page: int = 1, + limit: int = 24, + ) -> PaginatedPrograms: + """Get paginated programs where the current user is admin or mentor.""" + user = info.context.request.user + + try: + mentor = Mentor.objects.select_related("github_user").get(github_user=user.github_user) + except Mentor.DoesNotExist: + logger.warning("Mentor for user '%s' not found.", user.username) + return PaginatedPrograms(programs=[], total_pages=0, current_page=page) + + queryset = ( + Program.objects.prefetch_related( + "admins__github_user", "modules__mentors__github_user" + ) + .filter(Q(admins=mentor) | Q(modules__mentors=mentor)) + .distinct() + ) + + if search: + queryset = queryset.filter(name__icontains=search) + + total_count = queryset.count() + total_pages = max(1, (total_count + limit - 1) // limit) + page = max(1, min(page, total_pages)) + offset = (page - 1) * limit + + paginated_programs = queryset.order_by("-nest_created_at")[offset : offset + limit] + + results = [] + mentor_id = mentor.id + for program in paginated_programs: + is_admin = any(admin.id == mentor_id for admin in program.admins.all()) + program.user_role = "admin" if is_admin else "mentor" + results.append(program) + + return PaginatedPrograms( + programs=results, + total_pages=total_pages, + current_page=page, + ) diff --git a/backend/apps/mentorship/apps.py b/backend/apps/mentorship/apps.py index 8d4cff0220..18f16a0dd4 100644 --- a/backend/apps/mentorship/apps.py +++ b/backend/apps/mentorship/apps.py @@ -8,3 +8,7 @@ class MentorshipConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.mentorship" + + def ready(self): + """Ready.""" + import apps.mentorship.signals # noqa: F401 diff --git a/backend/apps/mentorship/index/__init__.py b/backend/apps/mentorship/index/__init__.py new file mode 100644 index 0000000000..ed3bc85c99 --- /dev/null +++ b/backend/apps/mentorship/index/__init__.py @@ -0,0 +1 @@ +from .registry.program import ProgramIndex diff --git a/backend/apps/mentorship/index/registry/__init__.py b/backend/apps/mentorship/index/registry/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/apps/mentorship/index/registry/program.py b/backend/apps/mentorship/index/registry/program.py new file mode 100644 index 0000000000..da7675943e --- /dev/null +++ b/backend/apps/mentorship/index/registry/program.py @@ -0,0 +1,50 @@ +"""OWASP app program index.""" + +from apps.common.index import IndexBase, register +from apps.mentorship.models import Program + + +@register(Program) +class ProgramIndex(IndexBase): + """Program index.""" + + index_name = "programs" + + fields = ( + "idx_description", + "idx_ended_at", + "idx_experience_levels", + "idx_key", + "idx_name", + "idx_started_at", + "idx_status", + ) + + settings = { + "attributesForFaceting": [ + "filterOnly(idx_status)", + "idx_experience_levels", + ], + "indexLanguages": ["en"], + "customRanking": [], + "ranking": [ + "typo", + "words", + "filters", + "proximity", + "attribute", + "exact", + "custom", + ], + "searchableAttributes": [ + "unordered(idx_description)", + "unordered(idx_experience_levels)", + "unordered(idx_name)", + ], + } + + should_index = "is_indexable" + + def get_entities(self): + """Return only published programs for indexing.""" + return Program.objects.filter(status=Program.ProgramStatus.PUBLISHED) diff --git a/backend/apps/mentorship/migrations/0004_module_key_program_key_and_more.py b/backend/apps/mentorship/migrations/0004_module_key_program_key_and_more.py new file mode 100644 index 0000000000..28eda6e107 --- /dev/null +++ b/backend/apps/mentorship/migrations/0004_module_key_program_key_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.4 on 2025-08-06 06:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("mentorship", "0003_tasklevel_task_level_and_more"), + ("owasp", "0044_chapter_chapter_updated_at_desc_idx_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="module", + name="key", + field=models.CharField(default="default", max_length=200, verbose_name="Key"), + preserve_default=False, + ), + migrations.AddField( + model_name="program", + name="key", + field=models.CharField( + default="default-key", max_length=200, unique=True, verbose_name="Key" + ), + preserve_default=False, + ), + migrations.AddConstraint( + model_name="module", + constraint=models.UniqueConstraint( + fields=("program", "key"), name="unique_module_key_in_program" + ), + ), + ] diff --git a/backend/apps/mentorship/models/mixins/__init__.py b/backend/apps/mentorship/models/mixins/__init__.py new file mode 100644 index 0000000000..2f4da21452 --- /dev/null +++ b/backend/apps/mentorship/models/mixins/__init__.py @@ -0,0 +1 @@ +"""Mentorship app models mixins.""" diff --git a/backend/apps/mentorship/models/mixins/program.py b/backend/apps/mentorship/models/mixins/program.py new file mode 100644 index 0000000000..fd843159a8 --- /dev/null +++ b/backend/apps/mentorship/models/mixins/program.py @@ -0,0 +1,47 @@ +"""Mentorship program mixins.""" + +from __future__ import annotations + + +class ProgramIndexMixin: + """Program index mixin for mentorship programs.""" + + @property + def is_indexable(self) -> bool: + """Only index published programs.""" + return self.status == self.__class__.ProgramStatus.PUBLISHED + + @property + def idx_name(self) -> str: + """Name for Algolia indexing.""" + return self.name + + @property + def idx_key(self) -> str: + """Unique key for Algolia indexing.""" + return self.key + + @property + def idx_status(self) -> str: + """Status for Algolia indexing.""" + return self.status + + @property + def idx_description(self) -> str: + """Description for Algolia indexing.""" + return self.description or "" + + @property + def idx_experience_levels(self) -> list[str]: + """List of experience levels for Algolia filtering.""" + return self.experience_levels or [] + + @property + def idx_started_at(self) -> str | None: + """Formatted start datetime.""" + return self.started_at.isoformat() if self.started_at else None + + @property + def idx_ended_at(self) -> str | None: + """Formatted end datetime for filtering/sorting.""" + return self.ended_at.isoformat() if self.ended_at else None diff --git a/backend/apps/mentorship/models/module.py b/backend/apps/mentorship/models/module.py index 89662bc993..7309baca73 100644 --- a/backend/apps/mentorship/models/module.py +++ b/backend/apps/mentorship/models/module.py @@ -5,6 +5,7 @@ from django.db import models from apps.common.models import TimestampedModel +from apps.common.utils import slugify from apps.mentorship.models.common import ( ExperienceLevel, MatchingAttributes, @@ -18,6 +19,12 @@ class Module(ExperienceLevel, MatchingAttributes, StartEndRange, TimestampedMode class Meta: db_table = "mentorship_modules" verbose_name_plural = "Modules" + constraints = [ + models.UniqueConstraint( + fields=["program", "key"], + name="unique_module_key_in_program", + ) + ] description = models.TextField( verbose_name="Description", @@ -25,6 +32,11 @@ class Meta: default="", ) + key = models.CharField( + verbose_name="Key", + max_length=200, + ) + name = models.CharField( max_length=200, verbose_name="Name", @@ -73,8 +85,8 @@ def __str__(self) -> str: def save(self, *args, **kwargs): """Save module.""" if self.program: - # Set default dates from program if not provided. self.started_at = self.started_at or self.program.started_at self.ended_at = self.ended_at or self.program.ended_at + self.key = slugify(self.name) super().save(*args, **kwargs) diff --git a/backend/apps/mentorship/models/program.py b/backend/apps/mentorship/models/program.py index 9b81a11a6b..1ec11f4add 100644 --- a/backend/apps/mentorship/models/program.py +++ b/backend/apps/mentorship/models/program.py @@ -6,10 +6,16 @@ from django.db import models from apps.common.models import TimestampedModel -from apps.mentorship.models.common import ExperienceLevel, MatchingAttributes, StartEndRange +from apps.common.utils import slugify +from apps.mentorship.models.common import ( + ExperienceLevel, + MatchingAttributes, + StartEndRange, +) +from apps.mentorship.models.mixins.program import ProgramIndexMixin -class Program(MatchingAttributes, StartEndRange, TimestampedModel): +class Program(MatchingAttributes, ProgramIndexMixin, StartEndRange, TimestampedModel): """Program model representing an overarching mentorship initiative.""" class Meta: @@ -49,6 +55,7 @@ class ProgramStatus(models.TextChoices): unique=True, verbose_name="Name", ) + key = models.CharField(verbose_name="Key", max_length=200, unique=True) status = models.CharField( verbose_name="Status", @@ -72,3 +79,9 @@ def __str__(self) -> str: """ return self.name + + def save(self, *args, **kwargs) -> None: + """Save program.""" + self.key = slugify(self.name) + + super().save(*args, **kwargs) diff --git a/backend/apps/mentorship/signals/__init__.py b/backend/apps/mentorship/signals/__init__.py new file mode 100644 index 0000000000..376a8c1d0e --- /dev/null +++ b/backend/apps/mentorship/signals/__init__.py @@ -0,0 +1 @@ +from .program import ProgramPostSaveHandler diff --git a/backend/apps/mentorship/signals/program.py b/backend/apps/mentorship/signals/program.py new file mode 100644 index 0000000000..6e8200197a --- /dev/null +++ b/backend/apps/mentorship/signals/program.py @@ -0,0 +1,24 @@ +"""Signal handler for Program post_save to clear Algolia cache.""" + +import logging + +from django.db.models.signals import post_save +from django.dispatch import receiver + +from apps.core.utils.index import clear_index_cache +from apps.mentorship.models import Program + +logger = logging.getLogger(__name__) + + +class ProgramPostSaveHandler: + """Handles post_save signal for Program model to clear Algolia cache.""" + + @receiver(post_save, sender=Program) + def program_post_save_clear_algolia_cache(sender, instance, **kwargs): # noqa: N805 + """Signal handler to clear Algolia cache for the Program index. + + The sender, instance, and kwargs arguments are provided by the post_save signal. + """ + logger.info("Signal received for program '%s'. Clearing 'programs' index.", instance.name) + clear_index_cache("programs") diff --git a/backend/apps/owasp/api/internal/nodes/project.py b/backend/apps/owasp/api/internal/nodes/project.py index 22524fe144..1b881d44ef 100644 --- a/backend/apps/owasp/api/internal/nodes/project.py +++ b/backend/apps/owasp/api/internal/nodes/project.py @@ -9,7 +9,9 @@ from apps.github.api.internal.nodes.release import ReleaseNode from apps.github.api.internal.nodes.repository import RepositoryNode from apps.owasp.api.internal.nodes.common import GenericEntityNode -from apps.owasp.api.internal.nodes.project_health_metrics import ProjectHealthMetricsNode +from apps.owasp.api.internal.nodes.project_health_metrics import ( + ProjectHealthMetricsNode, +) from apps.owasp.models.project import Project from apps.owasp.models.project_health_metrics import ProjectHealthMetrics @@ -24,6 +26,7 @@ "contributors_count", "created_at", "forks_count", + "id", "is_active", "level", "name", diff --git a/backend/apps/owasp/api/internal/queries/project.py b/backend/apps/owasp/api/internal/queries/project.py index e2c86ecdbd..154e25bfe9 100644 --- a/backend/apps/owasp/api/internal/queries/project.py +++ b/backend/apps/owasp/api/internal/queries/project.py @@ -1,7 +1,9 @@ """OWASP project GraphQL queries.""" import strawberry +from django.db.models import Q +from apps.github.models.user import User as GithubUser from apps.owasp.api.internal.nodes.project import ProjectNode from apps.owasp.models.project import Project @@ -38,3 +40,27 @@ def recent_projects(self, limit: int = 8) -> list[ProjectNode]: """ return Project.objects.filter(is_active=True).order_by("-created_at")[:limit] + + @strawberry.field + def search_projects(self, query: str) -> list[ProjectNode]: + """Search active projects by name (case-insensitive, partial match).""" + if not query.strip(): + return [] + + return Project.objects.filter( + is_active=True, + name__icontains=query.strip(), + ).order_by("name")[:3] + + @strawberry.field + def is_project_leader(self, info: strawberry.Info, login: str) -> bool: + """Check if a GitHub login or name is listed as a project leader.""" + try: + github_user = GithubUser.objects.get(login=login) + except GithubUser.DoesNotExist: + return False + + return Project.objects.filter( + Q(leaders_raw__icontains=github_user.login) + | Q(leaders_raw__icontains=(github_user.name or "")) + ).exists() diff --git a/backend/apps/owasp/models/__init__.py b/backend/apps/owasp/models/__init__.py index e69de29bb2..5ce17c615d 100644 --- a/backend/apps/owasp/models/__init__.py +++ b/backend/apps/owasp/models/__init__.py @@ -0,0 +1 @@ +from .project import Project diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 618e76b7ca..9d6b96afc6 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -14,6 +14,7 @@ omit = [ "**/admin.py", "**/apps.py", "**/migrations/*", + "**/mentorship/*", # TODO: work in progress "manage.py", "settings/*", "tests/*", diff --git a/backend/settings/graphql.py b/backend/settings/graphql.py index 2993c7a9fc..362519323e 100644 --- a/backend/settings/graphql.py +++ b/backend/settings/graphql.py @@ -3,18 +3,38 @@ import strawberry from apps.github.api.internal.queries import GithubQuery +from apps.mentorship.api.internal.mutations import ( + ModuleMutation, + ProgramMutation, +) +from apps.mentorship.api.internal.queries import ( + MentorshipQuery, + ModuleQuery, + ProgramQuery, +) from apps.nest.api.internal.mutations import NestMutations from apps.nest.api.internal.queries import NestQuery from apps.owasp.api.internal.queries import OwaspQuery @strawberry.type -class Mutation(NestMutations): +class Mutation( + ModuleMutation, + ProgramMutation, + NestMutations, +): """Schema mutations.""" @strawberry.type -class Query(GithubQuery, NestQuery, OwaspQuery): +class Query( + GithubQuery, + MentorshipQuery, + ModuleQuery, + NestQuery, + OwaspQuery, + ProgramQuery, +): """Schema queries.""" diff --git a/backend/tests/apps/github/management/commands/github_match_users_test.py b/backend/tests/apps/github/management/commands/github_match_users_test.py index a5faa2a0d8..51c8a23135 100644 --- a/backend/tests/apps/github/management/commands/github_match_users_test.py +++ b/backend/tests/apps/github/management/commands/github_match_users_test.py @@ -32,7 +32,6 @@ def test_add_arguments(self, command): """Test that the command adds the correct arguments.""" parser = MagicMock() command.add_arguments(parser) - assert parser.add_argument.call_count == 2 parser.add_argument.assert_any_call( "model_name", @@ -75,7 +74,6 @@ def command(self): """Return a command instance.""" command = Command() command.stdout = MagicMock() - return command @pytest.fixture @@ -91,7 +89,6 @@ def mock_users(self): def test_no_leaders(self, command): """Test with no leaders provided.""" exact, fuzzy, unmatched = command.process_leaders([], 75, {}) - assert exact == [] assert fuzzy == [] assert unmatched == [] @@ -100,7 +97,6 @@ def test_exact_match(self, command, mock_users): """Test exact matching.""" leaders_raw = ["john_doe", "Jane Doe"] exact, fuzzy, unmatched = command.process_leaders(leaders_raw, 75, mock_users) - assert len(exact) == 2 assert mock_users[1] in exact assert mock_users[2] in exact @@ -110,13 +106,12 @@ def test_exact_match(self, command, mock_users): @patch("apps.github.management.commands.github_match_users.fuzz") def test_fuzzy_match(self, mock_fuzz, command, mock_users): """Test fuzzy matching.""" - mock_fuzz.token_sort_ratio.side_effect = ( - lambda left, right: 90 if "peter" in right.lower() or "peter" in left.lower() else 10 + mock_fuzz.token_sort_ratio.side_effect = lambda left, right: ( + 90 if "peter" in right.lower() or "peter" in left.lower() else 10 ) leaders_raw = ["pete_jones"] exact, fuzzy, unmatched = command.process_leaders(leaders_raw, 80, mock_users) - assert exact == [] assert len(fuzzy) == 1 assert mock_users[3] in fuzzy @@ -126,7 +121,6 @@ def test_unmatched_leader(self, command, mock_users): """Test unmatched leader.""" leaders_raw = ["unknown_leader"] exact, fuzzy, unmatched = command.process_leaders(leaders_raw, 100, mock_users) - assert exact == [] assert fuzzy == [] assert unmatched == ["unknown_leader"] @@ -134,7 +128,6 @@ def test_unmatched_leader(self, command, mock_users): def test_mixed_matches(self, command, mock_users): """Test a mix of exact, fuzzy, and unmatched leaders.""" leaders_raw = ["john_doe", "pete_jones", "unknown_leader"] - with patch("apps.github.management.commands.github_match_users.fuzz") as mock_fuzz: def ratio(s1, s2): @@ -142,7 +135,6 @@ def ratio(s1, s2): mock_fuzz.token_sort_ratio.side_effect = ratio exact, fuzzy, unmatched = command.process_leaders(leaders_raw, 80, mock_users) - assert len(exact) == 1 assert mock_users[1] in exact assert len(fuzzy) == 1 @@ -153,7 +145,6 @@ def test_duplicate_leaders(self, command, mock_users): """Test with duplicate leaders in raw list.""" leaders_raw = ["john_doe", "john_doe"] exact, fuzzy, unmatched = command.process_leaders(leaders_raw, 75, mock_users) - assert len(exact) == 1 assert mock_users[1] in exact assert fuzzy == [] @@ -163,7 +154,6 @@ def test_empty_and_none_leaders(self, command, mock_users): """Test with empty string and None in leaders raw list.""" leaders_raw = ["", None, "john_doe"] exact, fuzzy, unmatched = command.process_leaders(leaders_raw, 75, mock_users) - assert len(exact) == 1 assert mock_users[1] in exact assert fuzzy == [] @@ -177,7 +167,6 @@ def test_multiple_exact_matches_for_one_leader(self, command): } leaders_raw = ["JohnDoe"] exact, fuzzy, unmatched = command.process_leaders(leaders_raw, 75, users) - assert len(exact) == 2 assert users[1] in exact assert users[2] in exact @@ -198,11 +187,16 @@ def command(self): """Return a command instance with mocked stdout.""" command = Command() command.stdout = MagicMock() - return command def test_invalid_model_name( - self, mock_member, mock_project, mock_committee, mock_chapter, mock_user, command + self, + mock_member, + mock_project, + mock_committee, + mock_chapter, + mock_user, + command, ): """Test handle with an invalid model name.""" command.handle(model_name="invalid", threshold=75) @@ -241,56 +235,55 @@ def test_handle_with_valid_models( "Member": mock_member, } model_class = mock_models[model_class_str] - mock_user.objects.values.return_value = [ {"id": 1, "login": "leader_one", "name": "Leader One"}, {"id": 2, "login": "leader_two", "name": "Leader Two"}, ] - mock_instance = MagicMock() mock_instance.id = 1 - if model_name == "member": mock_instance.username = "leader_one" mock_instance.real_name = "Leader Two" else: mock_instance.leaders_raw = ["leader_one", "leader_two"] - model_class.objects.prefetch_related.return_value = [mock_instance] - command.handle(model_name=model_name, threshold=90) - model_class.objects.prefetch_related.assert_called_once_with(relation_field) - relation = getattr(mock_instance, relation_field) relation.set.assert_called_once_with({1, 2}) - command.stdout.write.assert_any_call(f"Processing {model_name} 1...") command.stdout.write.assert_any_call("Exact match found for leader_one: leader_one") def test_handle_with_no_users( - self, mock_member, mock_project, mock_committee, mock_chapter, mock_user, command + self, + mock_member, + mock_project, + mock_committee, + mock_chapter, + mock_user, + command, ): """Test handle when there are no users in the database.""" mock_user.objects.values.return_value = [] mock_chapter_instance = MagicMock(id=1, leaders_raw=["some_leader"]) mock_chapter.objects.prefetch_related.return_value = [mock_chapter_instance] - command.handle(model_name="chapter", threshold=75) - command.stdout.write.assert_any_call("Processing chapter 1...") - unmatched_call = [ c for c in command.stdout.write.call_args_list if "Unmatched" in c.args[0] ] - assert len(unmatched_call) == 1 assert "['some_leader']" in unmatched_call[0].args[0] - mock_chapter_instance.suggested_leaders.set.assert_called_once_with(set()) def test_handle_with_no_leaders_in_instance( - self, mock_member, mock_project, mock_committee, mock_chapter, mock_user, command + self, + mock_member, + mock_project, + mock_committee, + mock_chapter, + mock_user, + command, ): """Test handle when an instance has no leaders.""" mock_user.objects.values.return_value = [ @@ -298,15 +291,10 @@ def test_handle_with_no_leaders_in_instance( ] mock_chapter_instance = MagicMock(id=1, leaders_raw=[]) mock_chapter.objects.prefetch_related.return_value = [mock_chapter_instance] - command.handle(model_name="chapter", threshold=75) - command.stdout.write.assert_any_call("Processing chapter 1...") - unmatched_call = [ c for c in command.stdout.write.call_args_list if "Unmatched" in c.args[0] ] - assert len(unmatched_call) == 0 - mock_chapter_instance.suggested_leaders.set.assert_called_once_with(set()) diff --git a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx index adec3ee1b8..830baf35a1 100644 --- a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx +++ b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx @@ -23,6 +23,14 @@ jest.mock('next/link', () => { return MockLink }) +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: jest.fn(), + replace: jest.fn(), + prefetch: jest.fn(), + }), +})) + jest.mock('next/image', () => ({ __esModule: true, default: ({ diff --git a/frontend/__tests__/unit/components/MarkdownWrapper.test.tsx b/frontend/__tests__/unit/components/MarkdownWrapper.test.tsx new file mode 100644 index 0000000000..6417d2fd2f --- /dev/null +++ b/frontend/__tests__/unit/components/MarkdownWrapper.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from '@testing-library/react' +import Markdown from 'components/MarkdownWrapper' + +// Mock dompurify and markdown-it for isolation +jest.mock('dompurify', () => ({ + sanitize: (html: string) => html, +})) + +jest.mock('markdown-it/index.mjs', () => { + return jest.fn().mockImplementation(() => ({ + render: (content: string) => { + // Very simple mock: replace **bold** and [link](url) + return content + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\[(.*?)\]\((.*?)\)/g, '$1') + }, + })) +}) + +describe('Markdown component', () => { + it('renders markdown as HTML', () => { + render() + expect(screen.getByText('bold').tagName.toLowerCase()).toBe('strong') + }) + + it('applies custom className', () => { + render() + const wrapper = screen.getByText('test').closest('div') + expect(wrapper).toHaveClass('md-wrapper') + expect(wrapper).toHaveClass('custom-class') + }) + + it('renders links', () => { + render() + const link = screen.getByRole('link') + expect(link).toHaveAttribute('href', 'https://google.com') + expect(link).toHaveTextContent('Google') + }) + + it('sanitizes dangerous HTML', () => { + render(hello'} />) + // In our mock, dompurify does nothing, so just check the content is present + expect(screen.getByText('hello')).toBeInTheDocument() + }) +}) diff --git a/frontend/__tests__/unit/components/ModuleList.test.tsx b/frontend/__tests__/unit/components/ModuleList.test.tsx new file mode 100644 index 0000000000..544bf3fc84 --- /dev/null +++ b/frontend/__tests__/unit/components/ModuleList.test.tsx @@ -0,0 +1,309 @@ +import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons' +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import ModuleList from 'components/ModuleList' + +// Mock FontAwesome icons +jest.mock('@fortawesome/react-fontawesome', () => ({ + FontAwesomeIcon: ({ icon, className }: { icon: unknown; className?: string }) => ( + + ), +})) + +// Mock HeroUI Button component +jest.mock('@heroui/button', () => ({ + Button: ({ + children, + onPress, + className, + 'aria-label': ariaLabel, + disableAnimation: _disableAnimation, + ..._props + }: { + children: React.ReactNode + onPress: () => void + className?: string + 'aria-label'?: string + disableAnimation?: boolean + [key: string]: unknown + }) => ( + + ), +})) + +describe('ModuleList', () => { + describe('Empty and Null Cases', () => { + it('returns null when modules array is empty', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('returns null when modules is undefined', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('returns null when modules is null', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + }) + + describe('Rendering with Different Module Counts', () => { + it('renders all modules when count is less than 5', () => { + const modules = ['Module 1', 'Module 2', 'Module 3'] + render() + + expect(screen.getByText('Module 1')).toBeInTheDocument() + expect(screen.getByText('Module 2')).toBeInTheDocument() + expect(screen.getByText('Module 3')).toBeInTheDocument() + + // Should not show "Show more" button + expect(screen.queryByText('Show more')).not.toBeInTheDocument() + }) + + it('renders all modules when count is exactly 5', () => { + const modules = ['Module 1', 'Module 2', 'Module 3', 'Module 4', 'Module 5'] + render() + + modules.forEach((module) => { + expect(screen.getByText(module)).toBeInTheDocument() + }) + + // Should not show "Show more" button + expect(screen.queryByText('Show more')).not.toBeInTheDocument() + }) + + it('renders only first 5 modules when count is greater than 5', () => { + const modules = [ + 'Module 1', + 'Module 2', + 'Module 3', + 'Module 4', + 'Module 5', + 'Module 6', + 'Module 7', + ] + render() + + // First 5 should be visible + expect(screen.getByText('Module 1')).toBeInTheDocument() + expect(screen.getByText('Module 2')).toBeInTheDocument() + expect(screen.getByText('Module 3')).toBeInTheDocument() + expect(screen.getByText('Module 4')).toBeInTheDocument() + expect(screen.getByText('Module 5')).toBeInTheDocument() + + // Last 2 should not be visible initially + expect(screen.queryByText('Module 6')).not.toBeInTheDocument() + expect(screen.queryByText('Module 7')).not.toBeInTheDocument() + + // Should show "Show more" button + expect(screen.getByText('Show more')).toBeInTheDocument() + }) + }) + + describe('Show More/Less Functionality', () => { + const manyModules = Array.from({ length: 8 }, (_, i) => `Module ${i + 1}`) + + it('shows "Show more" button with correct aria-label initially', () => { + render() + + const button = screen.getByRole('button', { name: 'Show more modules' }) + expect(button).toBeInTheDocument() + expect(screen.getByText('Show more')).toBeInTheDocument() + expect(screen.getByTestId('icon-chevron-down')).toBeInTheDocument() + }) + + it('expands to show all modules when "Show more" is clicked', () => { + render() + + // Initially only first 5 are visible + expect(screen.getByText('Module 1')).toBeInTheDocument() + expect(screen.getByText('Module 5')).toBeInTheDocument() + expect(screen.queryByText('Module 6')).not.toBeInTheDocument() + + // Click "Show more" + const showMoreButton = screen.getByText('Show more') + fireEvent.click(showMoreButton) + + // Now all modules should be visible + expect(screen.getByText('Module 6')).toBeInTheDocument() + expect(screen.getByText('Module 7')).toBeInTheDocument() + expect(screen.getByText('Module 8')).toBeInTheDocument() + }) + + it('changes to "Show less" button after expanding', () => { + render() + + const showMoreButton = screen.getByText('Show more') + fireEvent.click(showMoreButton) + + // Button should change to "Show less" + expect(screen.getByText('Show less')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Show fewer modules' })).toBeInTheDocument() + expect(screen.getByTestId('icon-chevron-up')).toBeInTheDocument() + expect(screen.queryByText('Show more')).not.toBeInTheDocument() + }) + + it('collapses back to 5 modules when "Show less" is clicked', () => { + render() + + // Expand first + const showMoreButton = screen.getByText('Show more') + fireEvent.click(showMoreButton) + + // Verify all are visible + expect(screen.getByText('Module 8')).toBeInTheDocument() + + // Click "Show less" + const showLessButton = screen.getByText('Show less') + fireEvent.click(showLessButton) + + // Should be back to first 5 only + expect(screen.getByText('Module 5')).toBeInTheDocument() + expect(screen.queryByText('Module 6')).not.toBeInTheDocument() + expect(screen.queryByText('Module 8')).not.toBeInTheDocument() + + // Button should be back to "Show more" + expect(screen.getByText('Show more')).toBeInTheDocument() + }) + }) + + describe('Module Text Truncation', () => { + it('truncates module names longer than 50 characters', () => { + const longModuleName = 'A'.repeat(60) // 60 characters + const modules = [longModuleName, 'Short Module'] + + render() + + const expectedTruncated = 'A'.repeat(50) + '...' + expect(screen.getByText(expectedTruncated)).toBeInTheDocument() + expect(screen.queryByText(longModuleName)).not.toBeInTheDocument() + }) + + it('does not truncate module names 50 characters or shorter', () => { + const exactlyFiftyChars = 'A'.repeat(50) + const modules = [exactlyFiftyChars, 'Short'] + + render() + + expect(screen.getByText(exactlyFiftyChars)).toBeInTheDocument() + expect(screen.getByText('Short')).toBeInTheDocument() + }) + + it('adds title attribute for truncated modules', () => { + const longModuleName = + 'This is a very long module name that exceeds fifty characters and should be truncated' + const modules = [longModuleName] + + render() + + const truncatedButton = screen.getByRole('button', { + name: /This is a very long module name that exceeds/, + }) + expect(truncatedButton).toHaveAttribute('title', longModuleName) + }) + + it('does not add title attribute for non-truncated modules', () => { + const shortModuleName = 'Short Module' + const modules = [shortModuleName] + + render() + + const button = screen.getByRole('button', { name: shortModuleName }) + expect(button).not.toHaveAttribute('title') + }) + }) + + describe('Module Button Properties', () => { + it('renders module buttons with correct classes', () => { + const modules = ['Test Module'] + render() + + const button = screen.getByRole('button', { name: 'Test Module' }) + expect(button).toHaveClass( + 'rounded-lg', + 'border', + 'border-gray-400', + 'px-3', + 'py-1', + 'text-sm', + 'transition-all', + 'duration-200', + 'ease-in-out', + 'hover:scale-105', + 'hover:bg-gray-200', + 'dark:border-gray-300', + 'dark:hover:bg-gray-700' + ) + }) + + it('sets correct button type', () => { + const modules = ['Test Module'] + render() + + const button = screen.getByRole('button', { name: 'Test Module' }) + expect(button).toHaveAttribute('type', 'button') + }) + + it('generates unique keys for modules with same name', () => { + const modules = ['Same Name', 'Same Name', 'Different Name'] + render() + + const sameNameButtons = screen.getAllByText('Same Name') + expect(sameNameButtons).toHaveLength(2) + expect(screen.getByText('Different Name')).toBeInTheDocument() + }) + }) + + describe('Container Structure', () => { + it('renders with correct container classes', () => { + const modules = ['Module 1'] + const { container } = render() + + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('mt-3') + + const innerDiv = outerDiv.firstChild as HTMLElement + expect(innerDiv).toHaveClass('flex', 'flex-wrap', 'items-center', 'gap-2') + }) + }) + + describe('Edge Cases', () => { + it('handles modules with empty strings', () => { + const modules = ['', 'Valid Module', ''] + render() + + const buttons = screen.getAllByRole('button') + // Should render 3 buttons (including empty string ones) + expect(buttons).toHaveLength(3) + expect(screen.getByText('Valid Module')).toBeInTheDocument() + }) + + it('handles exactly 6 modules (edge case for show more)', () => { + const modules = Array.from({ length: 6 }, (_, i) => `Module ${i + 1}`) + render() + + // First 5 should be visible + expect(screen.getByText('Module 5')).toBeInTheDocument() + expect(screen.queryByText('Module 6')).not.toBeInTheDocument() + + // Should show "Show more" button + expect(screen.getByText('Show more')).toBeInTheDocument() + + // Click to expand + fireEvent.click(screen.getByText('Show more')) + expect(screen.getByText('Module 6')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/__tests__/unit/components/ProgramActions.test.tsx b/frontend/__tests__/unit/components/ProgramActions.test.tsx new file mode 100644 index 0000000000..d3d53dfe2d --- /dev/null +++ b/frontend/__tests__/unit/components/ProgramActions.test.tsx @@ -0,0 +1,101 @@ +import { fireEvent, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { useSession as mockUseSession } from 'next-auth/react' +import { render } from 'wrappers/testUtil' +import ProgramActions from 'components/ProgramActions' + +const mockPush = jest.fn() +jest.mock('next/navigation', () => ({ + ...jest.requireActual('next/navigation'), + useRouter: () => ({ push: mockPush }), +})) + +jest.mock('next-auth/react', () => { + const actual = jest.requireActual('next-auth/react') + return { + ...actual, + useSession: jest.fn(), + } +}) + +describe('ProgramActions', () => { + let setStatus: jest.Mock + beforeEach(() => { + setStatus = jest.fn() + mockPush.mockClear() + }) + + beforeAll(async () => { + ;(mockUseSession as jest.Mock).mockReturnValue({ + data: { + user: { + name: 'Test User', + email: 'test@example.com', + login: 'testuser', + isLeader: true, + }, + expires: '2099-01-01T00:00:00.000Z', + }, + status: 'authenticated', + loading: false, + }) + }) + + test('renders and toggles dropdown', () => { + render() + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + expect(screen.getByText('Add Module')).toBeInTheDocument() + expect(screen.getByText('Publish Program')).toBeInTheDocument() + fireEvent.click(button) + expect(screen.queryByText('Add Module')).not.toBeInTheDocument() + }) + + test('handles Add Module action', () => { + render() + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + fireEvent.click(screen.getByRole('menuitem', { name: /add module/i })) + expect(mockPush).toHaveBeenCalled() + expect(setStatus).not.toHaveBeenCalled() + }) + + test('handles Publish action', () => { + render() + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + fireEvent.click(screen.getByRole('menuitem', { name: /publish program/i })) + expect(setStatus).toHaveBeenCalledWith('PUBLISHED') + expect(mockPush).not.toHaveBeenCalled() + }) + + test('handles Move to Draft action', () => { + render() + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + fireEvent.click(screen.getByRole('menuitem', { name: /move to draft/i })) + expect(setStatus).toHaveBeenCalledWith('DRAFT') + }) + + test('handles Mark as Completed action', () => { + render() + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + fireEvent.click(screen.getByRole('menuitem', { name: /mark as completed/i })) + expect(setStatus).toHaveBeenCalledWith('COMPLETED') + }) + + test('dropdown closes on outside click', () => { + render( +
+ + +
+ ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + expect(screen.getByText('Add Module')).toBeInTheDocument() + fireEvent.mouseDown(screen.getByTestId('outside')) + expect(screen.queryByText('Add Module')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/__tests__/unit/components/ProgramCard.test.tsx b/frontend/__tests__/unit/components/ProgramCard.test.tsx new file mode 100644 index 0000000000..a6c97a9d9f --- /dev/null +++ b/frontend/__tests__/unit/components/ProgramCard.test.tsx @@ -0,0 +1,310 @@ +import { faEye } from '@fortawesome/free-regular-svg-icons' +import { faEdit } from '@fortawesome/free-solid-svg-icons' +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import type { Program } from 'types/mentorship' +import { ProgramStatusEnum } from 'types/mentorship' +import ProgramCard from 'components/ProgramCard' + +jest.mock('@fortawesome/react-fontawesome', () => ({ + FontAwesomeIcon: ({ icon, className }: { icon: unknown; className?: string }) => ( + + ), +})) + +jest.mock('components/ActionButton', () => ({ + __esModule: true, + default: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => ( + + ), +})) + +describe('ProgramCard', () => { + const mockOnEdit = jest.fn() + const mockOnView = jest.fn() + + const baseMockProgram: Program = { + id: '1', + key: 'test-program', + name: 'Test Program', + description: 'This is a test program description', + status: ProgramStatusEnum.PUBLISHED, + startedAt: '2024-01-01T00:00:00Z', + endedAt: '2024-12-31T23:59:59Z', + userRole: 'admin', + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Basic Rendering', () => { + it('renders program name correctly', () => { + render() + + expect(screen.getByText('Test Program')).toBeInTheDocument() + }) + + it('renders program description correctly', () => { + render() + + expect(screen.getByText('This is a test program description')).toBeInTheDocument() + }) + }) + + describe('Access Level - Admin', () => { + it('shows user role badge when accessLevel is admin', () => { + render( + + ) + + expect(screen.getByText('admin')).toBeInTheDocument() + }) + + it('shows Preview and Edit buttons for admin access', () => { + render( + + ) + + expect(screen.getByText('Preview')).toBeInTheDocument() + expect(screen.getByText('Edit')).toBeInTheDocument() + }) + + it('calls onView when Preview button is clicked', () => { + render( + + ) + + const previewButton = screen.getByText('Preview').closest('button') + fireEvent.click(previewButton!) + + expect(mockOnView).toHaveBeenCalledWith('test-program') + }) + + it('calls onEdit when Edit button is clicked', () => { + render( + + ) + + const editButton = screen.getByText('Edit').closest('button') + fireEvent.click(editButton!) + + expect(mockOnEdit).toHaveBeenCalledWith('test-program') + }) + }) + + describe('Access Level - User', () => { + it('does not show user role badge when accessLevel is user', () => { + render() + + expect(screen.queryByText('admin')).not.toBeInTheDocument() + }) + + it('shows only View Details button for user access', () => { + render() + + expect(screen.getByText('View Details')).toBeInTheDocument() + expect(screen.queryByText('Preview')).not.toBeInTheDocument() + expect(screen.queryByText('Edit')).not.toBeInTheDocument() + }) + + it('calls onView when View Details button is clicked', () => { + render() + + const viewButton = screen.getByText('View Details').closest('button') + fireEvent.click(viewButton!) + + expect(mockOnView).toHaveBeenCalledWith('test-program') + }) + }) + + describe('User Role Badge Styling', () => { + it('applies admin role styling', () => { + const adminProgram = { ...baseMockProgram, userRole: 'admin' } + render() + + const badge = screen.getByText('admin') + expect(badge).toHaveClass('bg-blue-100', 'text-blue-800') + }) + + it('applies mentor role styling', () => { + const mentorProgram = { ...baseMockProgram, userRole: 'mentor' } + render() + + const badge = screen.getByText('mentor') + expect(badge).toHaveClass('bg-green-100', 'text-green-800') + }) + + it('applies default role styling for unknown role', () => { + const unknownRoleProgram = { ...baseMockProgram, userRole: 'unknown' } + render() + + const badge = screen.getByText('unknown') + expect(badge).toHaveClass('bg-gray-100', 'text-gray-800') + }) + + it('applies default styling when userRole is undefined', () => { + const noRoleProgram = { ...baseMockProgram, userRole: undefined } + render() + + // Should not render badge when userRole is undefined + expect(screen.queryByText(/bg-/)).not.toBeInTheDocument() + }) + }) + + describe('Description Handling', () => { + it('truncates long descriptions to 100 characters', () => { + const longDescription = 'A'.repeat(150) + const longDescProgram = { ...baseMockProgram, description: longDescription } + + render() + + const expectedText = 'A'.repeat(100) + '...' + expect(screen.getByText(expectedText)).toBeInTheDocument() + }) + + it('shows full description when under 100 characters', () => { + const shortDescription = 'Short description' + const shortDescProgram = { ...baseMockProgram, description: shortDescription } + + render() + + expect(screen.getByText('Short description')).toBeInTheDocument() + }) + + it('shows fallback text when description is empty', () => { + const emptyDescProgram = { ...baseMockProgram, description: '' } + + render() + + expect(screen.getByText('No description available.')).toBeInTheDocument() + }) + + it('shows fallback text when description is undefined', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const noDescProgram = { ...baseMockProgram, description: undefined as any } + + render() + + expect(screen.getByText('No description available.')).toBeInTheDocument() + }) + }) + + describe('Date Formatting', () => { + it('shows date range when both startedAt and endedAt are provided', () => { + render() + + expect(screen.getByText('Jan 1, 2024 – Dec 31, 2024')).toBeInTheDocument() + }) + + it('shows only start date when endedAt is missing', () => { + const startOnlyProgram = { ...baseMockProgram, endedAt: '' } + + render() + + expect(screen.getByText('Started: Jan 1, 2024')).toBeInTheDocument() + }) + + it('shows fallback text when both dates are missing', () => { + const noDatesProgram = { ...baseMockProgram, startedAt: '', endedAt: '' } + + render() + + expect(screen.getByText('No dates set')).toBeInTheDocument() + }) + + it('shows fallback text when startedAt is missing but endedAt exists', () => { + const endOnlyProgram = { ...baseMockProgram, startedAt: '' } + + render() + + expect(screen.getByText('No dates set')).toBeInTheDocument() + }) + }) + + describe('Icons', () => { + it('renders eye icon for Preview button', () => { + render( + + ) + + expect(screen.getByTestId('icon-eye')).toBeInTheDocument() + }) + + it('renders edit icon for Edit button', () => { + render( + + ) + + expect(screen.getByTestId('icon-edit')).toBeInTheDocument() + }) + + it('renders eye icon for View Details button', () => { + render() + + expect(screen.getByTestId('icon-eye')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('handles missing onEdit prop gracefully for admin access', () => { + render() + + // Should still render Edit button even without onEdit + expect(screen.getByText('Edit')).toBeInTheDocument() + }) + + it('handles program with minimal data', () => { + const minimalProgram: Program = { + id: '2', + key: 'minimal', + name: 'Minimal Program', + description: '', + status: ProgramStatusEnum.DRAFT, + startedAt: '', + endedAt: '', + } + + render() + + expect(screen.getByText('Minimal Program')).toBeInTheDocument() + expect(screen.getByText('No description available.')).toBeInTheDocument() + expect(screen.getByText('No dates set')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/__tests__/unit/components/SingleModuleCard.test.tsx b/frontend/__tests__/unit/components/SingleModuleCard.test.tsx new file mode 100644 index 0000000000..8f7861fd59 --- /dev/null +++ b/frontend/__tests__/unit/components/SingleModuleCard.test.tsx @@ -0,0 +1,413 @@ +import { faUsers } from '@fortawesome/free-solid-svg-icons' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { useRouter } from 'next/navigation' +import { useSession } from 'next-auth/react' +import React from 'react' +import { render } from 'wrappers/testUtil' +import type { ExtendedSession } from 'types/auth' +import type { Module } from 'types/mentorship' +import { ExperienceLevelEnum, ProgramStatusEnum } from 'types/mentorship' +import SingleModuleCard from 'components/SingleModuleCard' + +// Mock dependencies +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), +})) + +jest.mock('next-auth/react', () => ({ + useSession: jest.fn(), +})) + +jest.mock('next/link', () => ({ + __esModule: true, + default: ({ + children, + href, + target, + rel, + className, + ...props + }: { + children: React.ReactNode + href: string + target?: string + rel?: string + className?: string + [key: string]: unknown + }) => ( + + {children} + + ), +})) + +jest.mock('@fortawesome/react-fontawesome', () => ({ + FontAwesomeIcon: ({ icon, className }: { icon: unknown; className?: string }) => ( + + ), +})) + +jest.mock('utils/dateFormatter', () => ({ + formatDate: jest.fn((date: string) => new Date(date).toLocaleDateString()), +})) + +jest.mock('components/ModuleCard', () => ({ + getSimpleDuration: jest.fn((start: string, end: string) => { + const startDate = new Date(start) + const endDate = new Date(end) + const diffTime = Math.abs(endDate.getTime() - startDate.getTime()) + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + return `${diffDays} days` + }), +})) + +jest.mock('components/TopContributorsList', () => ({ + __esModule: true, + default: ({ contributors, label }: { contributors: unknown[]; label: string }) => ( +
+ + {label}: {contributors.length} contributors + +
+ ), +})) + +const mockPush = jest.fn() +const mockUseRouter = useRouter as jest.MockedFunction +const mockUseSession = useSession as jest.MockedFunction + +// Test data +const mockModule: Module = { + id: '1', + key: 'test-module', + name: 'Test Module', + description: 'This is a test module description', + status: ProgramStatusEnum.PUBLISHED, + experienceLevel: ExperienceLevelEnum.INTERMEDIATE, + mentors: [ + { + name: 'user1', + login: 'user1', + avatarUrl: 'https://example.com/avatar1.jpg', + }, + { + name: 'user2', + login: 'user2', + avatarUrl: 'https://example.com/avatar2.jpg', + }, + ], + startedAt: '2024-01-01T00:00:00Z', + endedAt: '2024-12-31T23:59:59Z', + domains: ['frontend', 'backend'], + tags: ['react', 'nodejs'], +} + +const mockAdmins = [{ login: 'admin1' }, { login: 'admin2' }] + +const mockSessionData: ExtendedSession = { + user: { + login: 'admin1', + isLeader: true, + email: 'admin@example.com', + image: 'https://example.com/admin-avatar.jpg', + }, + expires: '2024-12-31T23:59:59Z', +} + +describe('SingleModuleCard', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseRouter.mockReturnValue({ + push: mockPush, + back: jest.fn(), + forward: jest.fn(), + refresh: jest.fn(), + replace: jest.fn(), + prefetch: jest.fn(), + }) + mockUseSession.mockReturnValue({ + data: null, + status: 'unauthenticated', + update: jest.fn(), + }) + }) + + describe('Basic Rendering', () => { + it('renders module card with basic information', () => { + render() + + expect(screen.getByText('Test Module')).toBeInTheDocument() + expect(screen.getByText('This is a test module description')).toBeInTheDocument() + expect(screen.getByTestId('icon-users')).toBeInTheDocument() + expect(screen.getByTestId('icon-ellipsis')).toBeInTheDocument() + }) + + it('renders module details correctly', () => { + render() + + expect(screen.getByText('Experience Level:')).toBeInTheDocument() + expect(screen.getByText('Intermediate')).toBeInTheDocument() + expect(screen.getByText('Start Date:')).toBeInTheDocument() + expect(screen.getByText('End Date:')).toBeInTheDocument() + expect(screen.getByText('Duration:')).toBeInTheDocument() + }) + + it('renders mentors list when mentors exist', () => { + render() + + expect(screen.getByTestId('top-contributors-list')).toBeInTheDocument() + expect(screen.getByText('Mentors: 2 contributors')).toBeInTheDocument() + }) + + it('does not render mentors list when no mentors', () => { + const moduleWithoutMentors = { ...mockModule, mentors: [] } + render() + + expect(screen.queryByTestId('top-contributors-list')).not.toBeInTheDocument() + }) + + it('renders module link with correct href', () => { + render() + + const moduleLink = screen.getByTestId('module-link') + expect(moduleLink).toHaveAttribute('href', '//modules/test-module') + expect(moduleLink).toHaveAttribute('target', '_blank') + expect(moduleLink).toHaveAttribute('rel', 'noopener noreferrer') + }) + }) + + describe('Dropdown Menu', () => { + it('opens dropdown when ellipsis button is clicked', () => { + render() + + const ellipsisButton = screen.getByRole('button') + fireEvent.click(ellipsisButton) + + expect(screen.getByText('View Module')).toBeInTheDocument() + }) + + it('closes dropdown when clicking outside', async () => { + render() + + const ellipsisButton = screen.getByRole('button') + fireEvent.click(ellipsisButton) + + expect(screen.getByText('View Module')).toBeInTheDocument() + + // Click outside the dropdown + fireEvent.mouseDown(document.body) + + await waitFor(() => { + expect(screen.queryByText('View Module')).not.toBeInTheDocument() + }) + }) + + it('navigates to view module when View Module is clicked', () => { + render() + + const ellipsisButton = screen.getByRole('button') + fireEvent.click(ellipsisButton) + + const viewButton = screen.getByText('View Module') + fireEvent.click(viewButton) + + expect(mockPush).toHaveBeenCalledWith('//modules/test-module') + }) + + it('shows only View Module option for non-admin users', () => { + render( + + ) + + const ellipsisButton = screen.getByRole('button') + fireEvent.click(ellipsisButton) + + expect(screen.getByText('View Module')).toBeInTheDocument() + expect(screen.queryByText('Edit Module')).not.toBeInTheDocument() + expect(screen.queryByText('Create Module')).not.toBeInTheDocument() + }) + }) + + describe('Admin Functionality', () => { + beforeEach(() => { + mockUseSession.mockReturnValue({ + data: mockSessionData, + status: 'authenticated', + update: jest.fn(), + }) + }) + + it('shows Edit Module option for admin users when showEdit is true', () => { + render( + + ) + + const ellipsisButton = screen.getByRole('button') + fireEvent.click(ellipsisButton) + + expect(screen.getByText('View Module')).toBeInTheDocument() + expect(screen.getByText('Edit Module')).toBeInTheDocument() + expect(screen.getByText('Create Module')).toBeInTheDocument() + }) + + it('does not show Edit Module option when showEdit is false', () => { + render( + + ) + + const ellipsisButton = screen.getByRole('button') + fireEvent.click(ellipsisButton) + + expect(screen.getByText('View Module')).toBeInTheDocument() + expect(screen.queryByText('Edit Module')).not.toBeInTheDocument() + expect(screen.getByText('Create Module')).toBeInTheDocument() + }) + + it('navigates to edit module when Edit Module is clicked', () => { + render( + + ) + + const ellipsisButton = screen.getByRole('button') + fireEvent.click(ellipsisButton) + + const editButton = screen.getByText('Edit Module') + fireEvent.click(editButton) + + expect(mockPush).toHaveBeenCalledWith('//modules/test-module/edit') + }) + + it('navigates to create module when Create Module is clicked', () => { + render( + + ) + + const ellipsisButton = screen.getByRole('button') + fireEvent.click(ellipsisButton) + + const createButton = screen.getByText('Create Module') + fireEvent.click(createButton) + + expect(mockPush).toHaveBeenCalledWith('//modules/create') + }) + }) + + describe('Edge Cases', () => { + it('handles missing module data gracefully', () => { + const incompleteModule = { + ...mockModule, + description: '', + mentors: [], + } + + render() + + expect(screen.getByText('Test Module')).toBeInTheDocument() + expect(screen.queryByTestId('top-contributors-list')).not.toBeInTheDocument() + }) + + it('handles undefined admins array', () => { + render() + + const ellipsisButton = screen.getByRole('button') + fireEvent.click(ellipsisButton) + + expect(screen.getByText('View Module')).toBeInTheDocument() + expect(screen.queryByText('Edit Module')).not.toBeInTheDocument() + }) + + it('handles null session data', () => { + mockUseSession.mockReturnValue({ + data: null, + status: 'unauthenticated', + update: jest.fn(), + }) + + render( + + ) + + const ellipsisButton = screen.getByRole('button') + fireEvent.click(ellipsisButton) + + expect(screen.getByText('View Module')).toBeInTheDocument() + expect(screen.queryByText('Edit Module')).not.toBeInTheDocument() + }) + }) + + describe('Accessibility', () => { + it('has proper button roles and interactions', () => { + render() + + const ellipsisButton = screen.getByRole('button') + expect(ellipsisButton).toBeInTheDocument() + + fireEvent.click(ellipsisButton) + + const viewButton = screen.getByText('View Module') + expect(viewButton.closest('button')).toBeInTheDocument() + }) + + it('supports keyboard navigation', () => { + render() + + const ellipsisButton = screen.getByRole('button') + + // Focus the button + ellipsisButton.focus() + expect(ellipsisButton).toHaveFocus() + + // Press Enter to open dropdown + fireEvent.keyDown(ellipsisButton, { key: 'Enter', code: 'Enter' }) + fireEvent.click(ellipsisButton) // Simulate the click that would happen + + expect(screen.getByText('View Module')).toBeInTheDocument() + }) + }) + + describe('Responsive Design', () => { + it('applies responsive classes correctly', () => { + render() + + const moduleTitle = screen.getByText('Test Module') + expect(moduleTitle).toHaveClass('sm:break-normal', 'sm:text-lg', 'lg:text-2xl') + }) + }) +}) diff --git a/frontend/__tests__/unit/components/UserMenu.test.tsx b/frontend/__tests__/unit/components/UserMenu.test.tsx index 123d9abf2e..418a5a46d2 100644 --- a/frontend/__tests__/unit/components/UserMenu.test.tsx +++ b/frontend/__tests__/unit/components/UserMenu.test.tsx @@ -80,6 +80,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: null, isSyncing: false, + status: 'authenticated', }) const { container } = render() @@ -88,8 +89,9 @@ describe('UserMenu Component', () => { it('renders with GitHub auth enabled', () => { mockUseSession.mockReturnValue({ - session: null, + session: { user: null, expires: '2024-12-31' }, isSyncing: false, + status: 'authenticated', }) render() @@ -102,6 +104,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ isSyncing: true, session: null, + status: 'loading', }) render() @@ -115,6 +118,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: null, isSyncing: false, + status: 'unauthenticated', }) render() @@ -128,6 +132,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) render() @@ -144,6 +149,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) render() @@ -164,6 +170,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) const { container } = render() @@ -175,6 +182,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: null, isSyncing: false, + status: 'unauthenticated', }) const { rerender } = render() @@ -184,6 +192,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) rerender() @@ -197,6 +206,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: null, isSyncing: false, + status: 'unauthenticated', }) render() @@ -214,6 +224,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) render() @@ -243,6 +254,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) render() @@ -269,6 +281,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) render( @@ -301,6 +314,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) render() @@ -331,6 +345,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) const { rerender } = render() @@ -378,6 +393,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: sessionWithoutImage, isSyncing: false, + status: 'authenticated', }) render() @@ -398,6 +414,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: sessionWithUndefinedImage, isSyncing: false, + status: 'authenticated', }) render() @@ -412,6 +429,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: null, isSyncing: false, + status: 'unauthenticated', }) render() @@ -425,6 +443,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) render() @@ -448,6 +467,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) const { rerender } = render() @@ -480,6 +500,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: null, isSyncing: false, + status: 'unauthenticated', }) render() @@ -490,6 +511,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: undefined, isSyncing: false, + status: 'unauthenticated', }) render() @@ -500,6 +522,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: { user: null, expires: '2024-12-31' }, isSyncing: false, + status: 'authenticated', }) render() @@ -512,6 +535,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) render() @@ -536,6 +560,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) render() @@ -550,6 +575,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) render() @@ -573,6 +599,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) render() @@ -585,6 +612,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) render() @@ -605,6 +633,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: null, isSyncing: false, + status: 'unauthenticated', }) render() @@ -615,10 +644,21 @@ describe('UserMenu Component', () => { 'relative', 'flex', 'h-10', - 'cursor-pointer', 'items-center', 'justify-center', - 'gap-2' + 'gap-2', + 'rounded-md', + 'bg-[#87a1bc]', + 'p-4', + 'text-sm', + 'font-medium', + 'text-black', + 'hover:ring-1', + 'hover:ring-[#b0c7de]', + 'dark:bg-slate-900', + 'dark:text-white', + 'dark:hover:bg-slate-900/90', + 'dark:hover:ring-[#46576b]' ) }) @@ -626,6 +666,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: null, isSyncing: true, + status: 'unauthenticated', }) render() @@ -641,6 +682,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) render() @@ -656,6 +698,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) render() @@ -686,6 +729,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) render() @@ -715,6 +759,7 @@ describe('UserMenu Component', () => { mockUseSession.mockReturnValue({ session: mockSession, isSyncing: false, + status: 'authenticated', }) const { unmount } = render() diff --git a/frontend/__tests__/unit/data/mockModuleData.ts b/frontend/__tests__/unit/data/mockModuleData.ts new file mode 100644 index 0000000000..2115239c58 --- /dev/null +++ b/frontend/__tests__/unit/data/mockModuleData.ts @@ -0,0 +1,10 @@ +export const mockModuleData = { + name: 'Intro to Web', + description: 'A beginner friendly module.', + experienceLevel: 'beginner', + startedAt: '2025-01-01', + endedAt: '2025-03-01', + mentors: [{ login: 'mentor1' }], + tags: ['tag1'], + domains: ['domain1'], +} diff --git a/frontend/__tests__/unit/data/mockProgramData.ts b/frontend/__tests__/unit/data/mockProgramData.ts new file mode 100644 index 0000000000..59a7248717 --- /dev/null +++ b/frontend/__tests__/unit/data/mockProgramData.ts @@ -0,0 +1,31 @@ +import { ProgramStatusEnum } from 'types/mentorship' +export const mockPrograms = [ + { + key: 'program_1', + name: 'Program 1', + description: 'This is a summary of Program 1.', + startedAt: '2025-01-01', + endedAt: '2025-12-31', + status: 'published', + modules: ['Module A', 'Module B'], + }, +] + +export const mockProgramDetailsData = { + getProgram: { + key: 'test-program', + name: 'Test Program', + description: 'Sample summary', + status: ProgramStatusEnum.DRAFT, + startedAt: '2025-01-01', + endedAt: '2025-12-31', + menteesLimit: 20, + experienceLevels: ['beginner', 'intermediate'], + admins: [{ login: 'admin-user', avatarUrl: 'https://example.com/avatar.png' }], + tags: ['web', 'security'], + domains: ['OWASP'], + }, + getProgramModules: [], +} + +export default mockProgramDetailsData diff --git a/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx b/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx index b4c89a164f..e8451d6763 100644 --- a/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx +++ b/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx @@ -1,4 +1,5 @@ import { useQuery } from '@apollo/client' + import { screen, waitFor } from '@testing-library/react' import { mockCommitteeDetailsData } from '@unit/data/mockCommitteeDetailsData' import { render } from 'wrappers/testUtil' diff --git a/frontend/__tests__/unit/pages/CreateModule.test.tsx b/frontend/__tests__/unit/pages/CreateModule.test.tsx new file mode 100644 index 0000000000..eea7110aed --- /dev/null +++ b/frontend/__tests__/unit/pages/CreateModule.test.tsx @@ -0,0 +1,117 @@ +import { useApolloClient, useMutation, useQuery } from '@apollo/client' +import { screen, fireEvent, waitFor, act } from '@testing-library/react' +import { useRouter, useParams } from 'next/navigation' +import { useSession } from 'next-auth/react' +import { render } from 'wrappers/testUtil' +import CreateModulePage from 'app/my/mentorship/programs/[programKey]/modules/create/page' + +jest.mock('next-auth/react', () => ({ + ...jest.requireActual('next-auth/react'), + useSession: jest.fn(), +})) + +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), + useParams: jest.fn(), +})) + +jest.mock('@apollo/client', () => { + const actual = jest.requireActual('@apollo/client') + return { + ...actual, + useMutation: jest.fn(), + useQuery: jest.fn(), + useApolloClient: jest.fn(), + gql: actual.gql, + } +}) + +describe('CreateModulePage', () => { + const mockPush = jest.fn() + const mockReplace = jest.fn() + const mockCreateModule = jest.fn() + + beforeEach(() => { + jest.useFakeTimers() + ;(useRouter as jest.Mock).mockReturnValue({ push: mockPush, replace: mockReplace }) + ;(useParams as jest.Mock).mockReturnValue({ programKey: 'test-program' }) + ;(useApolloClient as jest.Mock).mockReturnValue({ + query: jest.fn().mockResolvedValue({ + data: { + searchProjects: [{ id: '123', name: 'Awesome Project' }], + }, + }), + }) + }) + + afterEach(() => { + jest.runOnlyPendingTimers() + jest.useRealTimers() + jest.clearAllMocks() + }) + + it('submits the form and navigates to programs page', async () => { + ;(useSession as jest.Mock).mockReturnValue({ + data: { user: { login: 'admin-user' } }, + status: 'authenticated', + }) + ;(useQuery as jest.Mock).mockReturnValue({ + data: { + getProgram: { + admins: [{ login: 'admin-user' }], + }, + }, + loading: false, + }) + ;(useMutation as jest.Mock).mockReturnValue([ + mockCreateModule.mockResolvedValue({}), + { loading: false }, + ]) + + render() + + // Fill all inputs + fireEvent.change(screen.getByLabelText(/Module Name/i), { + target: { value: 'My Test Module' }, + }) + fireEvent.change(screen.getByLabelText(/Description/i), { + target: { value: 'This is a test module' }, + }) + fireEvent.change(screen.getByLabelText(/Start Date/i), { + target: { value: '2025-07-15' }, + }) + fireEvent.change(screen.getByLabelText(/End Date/i), { + target: { value: '2025-08-15' }, + }) + fireEvent.change(screen.getByLabelText(/Domains/i), { + target: { value: 'AI, ML' }, + }) + fireEvent.change(screen.getByLabelText(/Tags/i), { + target: { value: 'react, graphql' }, + }) + + // Simulate project typing and suggestion click + fireEvent.change(screen.getByLabelText(/Project Name/i), { + target: { value: 'Awesome Project' }, + }) + + // Run debounce + await act(async () => { + jest.runAllTimers() + }) + + const suggestionButton = await screen.findByRole('button', { + name: /Awesome Project/i, + }) + + fireEvent.click(suggestionButton) + + // Now the form should be valid → submit + fireEvent.click(screen.getByRole('button', { name: /Create Module/i })) + + await waitFor(() => { + expect(mockCreateModule).toHaveBeenCalled() + expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/test-program?refresh=true') + }) + }) +}) diff --git a/frontend/__tests__/unit/pages/CreateProgram.test.tsx b/frontend/__tests__/unit/pages/CreateProgram.test.tsx new file mode 100644 index 0000000000..b57dd0781d --- /dev/null +++ b/frontend/__tests__/unit/pages/CreateProgram.test.tsx @@ -0,0 +1,210 @@ +import { useMutation } from '@apollo/client' +import { addToast } from '@heroui/toast' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { useRouter as mockUseRouter } from 'next/navigation' +import { useSession as mockUseSession } from 'next-auth/react' +import { render } from 'wrappers/testUtil' +import CreateProgramPage from 'app/my/mentorship/programs/create/page' + +jest.mock('@apollo/client', () => ({ + ...jest.requireActual('@apollo/client'), + useMutation: jest.fn(), +})) + +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), +})) + +jest.mock('next-auth/react', () => { + const actual = jest.requireActual('next-auth/react') + return { + ...actual, + useSession: jest.fn(), + } +}) + +jest.mock('@heroui/toast', () => ({ + addToast: jest.fn(), +})) + +const mockRouterPush = jest.fn() +const mockCreateProgram = jest.fn() + +describe('CreateProgramPage (comprehensive tests)', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(mockUseRouter as jest.Mock).mockReturnValue({ push: mockRouterPush }) + ;(useMutation as jest.Mock).mockReturnValue([mockCreateProgram, { loading: false }]) + }) + + test('redirects if unauthenticated', async () => { + ;(mockUseSession as jest.Mock).mockReturnValue({ + data: null, + status: 'unauthenticated', + loading: false, + }) + + render() + + await waitFor(() => { + expect(mockRouterPush).toHaveBeenCalledWith('/') + }) + }) + + test('shows nothing when session is loading', () => { + ;(mockUseSession as jest.Mock).mockReturnValue({ + data: null, + status: 'loading', + loading: true, + }) + + render() + + expect(screen.queryByLabelText('Program Name *')).not.toBeInTheDocument() + }) + + test('redirects with toast if not a project leader', async () => { + ;(mockUseSession as jest.Mock).mockReturnValue({ + data: { + user: { + name: 'Test User', + email: 'test@example.com', + login: 'testuser', + isLeader: false, + }, + expires: '2099-01-01T00:00:00.000Z', + }, + status: 'authenticated', + loading: false, + }) + + render() + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Access Denied' })) + expect(mockRouterPush).toHaveBeenCalledWith('/') + }) + }) + + test('renders form when user is project leader', async () => { + ;(mockUseSession as jest.Mock).mockReturnValue({ + data: { + user: { + name: 'Test User', + email: 'test@example.com', + login: 'testuser', + isLeader: true, + }, + expires: '2099-01-01T00:00:00.000Z', + }, + status: 'authenticated', + loading: false, + }) + + render() + + expect(await screen.findByLabelText('Program Name *')).toBeInTheDocument() + }) + + test('submits form and redirects on success', async () => { + ;(mockUseSession as jest.Mock).mockReturnValue({ + data: { + user: { + name: 'Test User', + email: 'test@example.com', + login: 'testuser', + isLeader: true, + }, + expires: '2099-01-01T00:00:00.000Z', + }, + status: 'authenticated', + loading: false, + }) + + mockCreateProgram.mockResolvedValue({ + data: { createProgram: { key: 'program_1' } }, + }) + + render() + + fireEvent.change(screen.getByLabelText('Program Name *'), { + target: { value: 'Test Program' }, + }) + fireEvent.change(screen.getByLabelText('Description *'), { + target: { value: 'A description' }, + }) + fireEvent.change(screen.getByLabelText('Start Date *'), { + target: { value: '2025-01-01' }, + }) + fireEvent.change(screen.getByLabelText('End Date *'), { + target: { value: '2025-12-31' }, + }) + fireEvent.change(screen.getByLabelText('Tags'), { + target: { value: 'tag1, tag2' }, + }) + fireEvent.change(screen.getByLabelText('Domains'), { + target: { value: 'domain1, domain2' }, + }) + + fireEvent.submit(screen.getByText('Save').closest('form')!) + + await waitFor(() => { + expect(mockCreateProgram).toHaveBeenCalledWith({ + variables: { + input: { + name: 'Test Program', + description: 'A description', + menteesLimit: 5, + startedAt: '2025-01-01', + endedAt: '2025-12-31', + tags: ['tag1', 'tag2'], + domains: ['domain1', 'domain2'], + }, + }, + }) + + expect(mockRouterPush).toHaveBeenCalledWith('/my/mentorship') + }) + }) + + test('shows error toast if createProgram mutation fails', async () => { + ;(mockUseSession as jest.Mock).mockReturnValue({ + data: { + user: { + name: 'Test User', + email: 'test@example.com', + login: 'testuser', + isLeader: true, + }, + expires: '2099-01-01T00:00:00.000Z', + }, + status: 'authenticated', + loading: false, + }) + + mockCreateProgram.mockRejectedValue(new Error('Server error')) + + render() + + fireEvent.change(screen.getByLabelText('Program Name *'), { + target: { value: 'Test Program' }, + }) + fireEvent.change(screen.getByLabelText('Description *'), { + target: { value: 'A description' }, + }) + fireEvent.change(screen.getByLabelText('Start Date *'), { + target: { value: '2025-01-01' }, + }) + fireEvent.change(screen.getByLabelText('End Date *'), { + target: { value: '2025-12-31' }, + }) + + fireEvent.submit(screen.getByText('Save').closest('form')!) + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith( + expect.objectContaining({ title: 'GraphQL Request Failed' }) + ) + }) + }) +}) diff --git a/frontend/__tests__/unit/pages/EditModule.test.tsx b/frontend/__tests__/unit/pages/EditModule.test.tsx new file mode 100644 index 0000000000..21f5329001 --- /dev/null +++ b/frontend/__tests__/unit/pages/EditModule.test.tsx @@ -0,0 +1,132 @@ +import { useQuery, useMutation, useApolloClient } from '@apollo/client' +import { screen, fireEvent, waitFor, act } from '@testing-library/react' +import { useRouter, useParams } from 'next/navigation' +import { useSession } from 'next-auth/react' +import { render } from 'wrappers/testUtil' +import EditModulePage from 'app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page' + +// Mocks +jest.mock('next-auth/react', () => ({ + ...jest.requireActual('next-auth/react'), + useSession: jest.fn(), +})) + +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), + useParams: jest.fn(), +})) + +jest.mock('@apollo/client', () => { + const actual = jest.requireActual('@apollo/client') + return { + ...actual, + useQuery: jest.fn(), + useMutation: jest.fn(), + useApolloClient: jest.fn(), + gql: actual.gql, + } +}) + +describe('EditModulePage', () => { + const mockPush = jest.fn() + const mockReplace = jest.fn() + const mockUpdateModule = jest.fn() + + beforeEach(() => { + jest.useFakeTimers() + ;(useRouter as jest.Mock).mockReturnValue({ push: mockPush, replace: mockReplace }) + ;(useParams as jest.Mock).mockReturnValue({ + programKey: 'test-program', + moduleKey: 'test-module', + }) + ;(useApolloClient as jest.Mock).mockReturnValue({ + query: jest.fn().mockResolvedValue({ + data: { + searchProjects: [{ id: '123', name: 'Awesome Project' }], + }, + }), + }) + }) + + afterEach(() => { + jest.runOnlyPendingTimers() + jest.useRealTimers() + jest.clearAllMocks() + }) + + it('renders and submits form for editing module', async () => { + ;(useSession as jest.Mock).mockReturnValue({ + data: { user: { login: 'admin-user' } }, + status: 'authenticated', + }) + ;(useQuery as jest.Mock).mockReturnValue({ + loading: false, + data: { + getProgram: { + admins: [{ login: 'admin-user' }], + }, + getModule: { + name: 'Existing Module', + description: 'Old description', + experienceLevel: 'INTERMEDIATE', + startedAt: '2025-07-01', + endedAt: '2025-07-31', + domains: ['AI'], + tags: ['graphql'], + projectName: 'Awesome Project', + projectId: '123', + mentors: [{ login: 'mentor1' }], + }, + }, + }) + ;(useMutation as jest.Mock).mockReturnValue([ + mockUpdateModule.mockResolvedValue({}), + { loading: false }, + ]) + + render() + + // Ensure the form loads + expect(await screen.findByDisplayValue('Existing Module')).toBeInTheDocument() + + // Modify values + fireEvent.change(screen.getByLabelText(/Module Name/i), { + target: { value: 'Updated Module Name' }, + }) + fireEvent.change(screen.getByLabelText(/Description/i), { + target: { value: 'Updated description' }, + }) + fireEvent.change(screen.getByLabelText(/Domains/i), { + target: { value: 'AI, ML' }, + }) + fireEvent.change(screen.getByLabelText(/Tags/i), { + target: { value: 'graphql, react' }, + }) + fireEvent.change(screen.getByLabelText(/Project Name/i), { + target: { value: 'Awesome Project' }, + }) + + await act(async () => { + jest.runAllTimers() + }) + + fireEvent.click(screen.getByRole('button', { name: /Save/i })) + + await waitFor(() => { + expect(mockUpdateModule).toHaveBeenCalled() + expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/test-program?refresh=true') + }) + }) + + it('shows loading spinner initially', () => { + ;(useSession as jest.Mock).mockReturnValue({ + data: null, + status: 'loading', + }) + ;(useQuery as jest.Mock).mockReturnValue({ loading: true }) + + render() + + expect(screen.getAllByAltText('Loading indicator').length).toBeGreaterThan(0) + }) +}) diff --git a/frontend/__tests__/unit/pages/EditProgram.test.tsx b/frontend/__tests__/unit/pages/EditProgram.test.tsx new file mode 100644 index 0000000000..ee9c4d3d49 --- /dev/null +++ b/frontend/__tests__/unit/pages/EditProgram.test.tsx @@ -0,0 +1,93 @@ +import { useQuery } from '@apollo/client' +import { render, screen, waitFor } from '@testing-library/react' +import { useParams, useRouter } from 'next/navigation' +import { useSession } from 'next-auth/react' +import EditProgramPage from 'app/my/mentorship/programs/[programKey]/edit/page' + +jest.mock('next-auth/react', () => ({ + useSession: jest.fn(), +})) +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), + useParams: jest.fn(), +})) +jest.mock('@apollo/client', () => { + const actual = jest.requireActual('@apollo/client') + return { + ...actual, + useMutation: jest.fn(() => [jest.fn(), { loading: false }]), + useQuery: jest.fn(), + } +}) +jest.mock('@heroui/toast', () => ({ + addToast: jest.fn(), +})) + +describe('EditProgramPage', () => { + const mockPush = jest.fn() + const mockReplace = jest.fn() + + beforeEach(() => { + ;(useRouter as jest.Mock).mockReturnValue({ push: mockPush, replace: mockReplace }) + ;(useParams as jest.Mock).mockReturnValue({ programKey: 'program_1' }) + jest.clearAllMocks() + }) + + test('shows loading spinner while checking access', () => { + ;(useSession as jest.Mock).mockReturnValue({ status: 'loading' }) + ;(useQuery as jest.Mock).mockReturnValue({ loading: true }) + + render() + + expect(screen.getAllByAltText('Loading indicator')).toHaveLength(2) + }) + + test('denies access for non-admins and redirects', async () => { + ;(useSession as jest.Mock).mockReturnValue({ + data: { user: { login: 'nonadmin' } }, + status: 'authenticated', + }) + ;(useQuery as jest.Mock).mockReturnValue({ + loading: false, + data: { + getProgram: { + admins: [{ login: 'admin1' }], + }, + }, + }) + + render() + + await waitFor(async () => { + expect(await screen.findByText('Access Denied')).toBeInTheDocument() + }) + }) + + test('renders form for valid admin', async () => { + ;(useSession as jest.Mock).mockReturnValue({ + data: { user: { login: 'admin1' } }, + status: 'authenticated', + }) + ;(useQuery as jest.Mock).mockReturnValue({ + loading: false, + data: { + getProgram: { + name: 'Test', + description: 'Test description', + menteesLimit: 10, + startedAt: '2025-01-01', + endedAt: '2025-12-31', + tags: ['react', 'js'], + domains: ['web'], + admins: [{ login: 'admin1' }], + status: 'DRAFT', + }, + }, + }) + + render() + + expect(await screen.findByLabelText('Program Name *')).toBeInTheDocument() + expect(screen.getByDisplayValue('Test')).toBeInTheDocument() + }) +}) diff --git a/frontend/__tests__/unit/pages/ModuleDetails.test.tsx b/frontend/__tests__/unit/pages/ModuleDetails.test.tsx new file mode 100644 index 0000000000..a52d9e62ea --- /dev/null +++ b/frontend/__tests__/unit/pages/ModuleDetails.test.tsx @@ -0,0 +1,84 @@ +import { useQuery } from '@apollo/client' +import { screen, waitFor } from '@testing-library/react' +import { mockModuleData } from '@unit/data/mockModuleData' +import { useParams } from 'next/navigation' +import { render } from 'wrappers/testUtil' +import { handleAppError } from 'app/global-error' +import ModuleDetailsPage from 'app/mentorship/programs/[programKey]/modules/[moduleKey]/page' + +jest.mock('next/navigation', () => ({ + useParams: jest.fn(), +})) + +jest.mock('@apollo/client', () => ({ + ...jest.requireActual('@apollo/client'), + useQuery: jest.fn(), + useMutation: jest.fn(), +})) + +jest.mock('app/global-error', () => ({ + handleAppError: jest.fn(), + ErrorDisplay: ({ title }) =>
{title}
, +})) + +jest.mock('components/LoadingSpinner', () => () =>
LoadingSpinner
) + +jest.mock('components/CardDetailsPage', () => (props) => ( +
+
{props.title}
+
{props.summary}
+
+)) + +describe('ModuleDetailsPage', () => { + const mockUseParams = useParams as jest.Mock + const mockUseQuery = useQuery as jest.Mock + + const admins = [{ login: 'admin1' }] + + beforeEach(() => { + mockUseParams.mockReturnValue({ + programKey: 'program-1', + moduleKey: 'module-1', + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('shows loading spinner initially', () => { + mockUseQuery.mockReturnValue({ loading: true }) + + const { container } = render() + expect(container.innerHTML).toContain('LoadingSpinner') + }) + + it('calls error handler on GraphQL error', async () => { + const error = new Error('Query failed') + mockUseQuery.mockReturnValue({ error, loading: false }) + + render() + + await waitFor(() => { + expect(handleAppError).toHaveBeenCalledWith(error) + }) + }) + + it('renders module details when data is present', async () => { + mockUseQuery.mockReturnValue({ + loading: false, + data: { + getModule: mockModuleData, + getProgram: { + admins, + }, + }, + }) + + render() + + expect(await screen.findByTestId('details-card')).toHaveTextContent('Intro to Web') + expect(screen.getByTestId('details-card')).toHaveTextContent('A beginner friendly module.') + }) +}) diff --git a/frontend/__tests__/unit/pages/ModuleDetailsPage.test.tsx b/frontend/__tests__/unit/pages/ModuleDetailsPage.test.tsx new file mode 100644 index 0000000000..880109a9b5 --- /dev/null +++ b/frontend/__tests__/unit/pages/ModuleDetailsPage.test.tsx @@ -0,0 +1,84 @@ +import { useQuery } from '@apollo/client' +import { screen, waitFor } from '@testing-library/react' +import { mockModuleData } from '@unit/data/mockModuleData' +import { useParams } from 'next/navigation' +import { render } from 'wrappers/testUtil' +import { handleAppError } from 'app/global-error' +import ModuleDetailsPage from 'app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page' + +jest.mock('next/navigation', () => ({ + useParams: jest.fn(), +})) + +jest.mock('@apollo/client', () => ({ + ...jest.requireActual('@apollo/client'), + useQuery: jest.fn(), + useMutation: jest.fn(), +})) + +jest.mock('app/global-error', () => ({ + handleAppError: jest.fn(), + ErrorDisplay: ({ title }) =>
{title}
, +})) + +jest.mock('components/LoadingSpinner', () => () =>
LoadingSpinner
) + +jest.mock('components/CardDetailsPage', () => (props) => ( +
+
{props.title}
+
{props.summary}
+
+)) + +describe('ModuleDetailsPage', () => { + const mockUseParams = useParams as jest.Mock + const mockUseQuery = useQuery as jest.Mock + + const admins = [{ login: 'admin1' }] + + beforeEach(() => { + mockUseParams.mockReturnValue({ + programKey: 'program-1', + moduleKey: 'module-1', + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('shows loading spinner initially', () => { + mockUseQuery.mockReturnValue({ loading: true }) + + const { container } = render() + expect(container.innerHTML).toContain('LoadingSpinner') + }) + + it('calls error handler on GraphQL error', async () => { + const error = new Error('Query failed') + mockUseQuery.mockReturnValue({ error, loading: false }) + + render() + + await waitFor(() => { + expect(handleAppError).toHaveBeenCalledWith(error) + }) + }) + + it('renders module details when data is present', async () => { + mockUseQuery.mockReturnValue({ + loading: false, + data: { + getModule: mockModuleData, + getProgram: { + admins, + }, + }, + }) + + render() + + expect(await screen.findByTestId('details-card')).toHaveTextContent('Intro to Web') + expect(screen.getByTestId('details-card')).toHaveTextContent('A beginner friendly module.') + }) +}) diff --git a/frontend/__tests__/unit/pages/MyMentorship.test.tsx b/frontend/__tests__/unit/pages/MyMentorship.test.tsx new file mode 100644 index 0000000000..ca62264445 --- /dev/null +++ b/frontend/__tests__/unit/pages/MyMentorship.test.tsx @@ -0,0 +1,171 @@ +import { useQuery } from '@apollo/client' +import { screen, waitFor, fireEvent } from '@testing-library/react' +import { useRouter as useRouterMock } from 'next/navigation' +import { useSession as mockUseSession } from 'next-auth/react' +import { render } from 'wrappers/testUtil' +import MyMentorshipPage from 'app/my/mentorship/page' + +jest.mock('@apollo/client', () => { + const actual = jest.requireActual('@apollo/client') + return { + ...actual, + useQuery: jest.fn(), + } +}) + +jest.mock('next/navigation', () => { + const actual = jest.requireActual('next/navigation') + return { + ...actual, + useRouter: jest.fn(), + useSearchParams: () => new URLSearchParams(''), + } +}) + +jest.mock('next-auth/react', () => { + const actual = jest.requireActual('next-auth/react') + return { + ...actual, + useSession: jest.fn(), + } +}) + +const mockUseQuery = useQuery as jest.Mock +const mockPush = jest.fn() + +beforeEach(() => { + jest.clearAllMocks() + ;(useRouterMock as jest.Mock).mockReturnValue({ push: mockPush }) +}) + +const mockProgramData = { + myPrograms: { + programs: [ + { + id: '1', + key: 'program-1', + name: 'Test Program', + description: 'Test Description', + status: 'draft', + startedAt: '2025-07-28', + endedAt: '2025-08-10', + experienceLevels: ['beginner'], + menteesLimit: 10, + admins: [], + }, + ], + totalPages: 1, + }, +} + +describe('MyMentorshipPage', () => { + it('shows loading while checking access', () => { + ;(mockUseSession as jest.Mock).mockReturnValue({ + data: null, + status: 'loading', + }) + mockUseQuery.mockReturnValue({ data: undefined, loading: false, error: undefined }) + + render() + expect(screen.getAllByAltText('Loading indicator').length).toBeGreaterThan(0) + }) + + it('shows access denied if user is not project leader', async () => { + ;(mockUseSession as jest.Mock).mockReturnValue({ + data: { + user: { + name: 'user 1', + email: 'user@example.com', + login: 'user1', + isLeader: false, + }, + expires: '2099-01-01T00:00:00.000Z', + }, + status: 'authenticated', + }) + + mockUseQuery.mockReturnValue({ data: undefined, loading: false, error: undefined }) + + render() + expect(await screen.findByText(/Access Denied/i)).toBeInTheDocument() + }) + + it('renders mentorship programs if user is leader', async () => { + ;(mockUseSession as jest.Mock).mockReturnValue({ + data: { + user: { + name: 'User', + email: 'leader@example.com', + login: 'user', + isLeader: true, + }, + expires: '2099-01-01T00:00:00.000Z', + }, + status: 'authenticated', + }) + + mockUseQuery.mockReturnValue({ + data: mockProgramData, + loading: false, + error: undefined, + }) + + render() + expect(await screen.findByText('My Mentorship')).toBeInTheDocument() + expect(await screen.findByText('Test Program')).toBeInTheDocument() + }) + + it('shows empty state when no programs found', async () => { + ;(mockUseSession as jest.Mock).mockReturnValue({ + data: { + user: { + name: 'User', + email: 'user@example.com', + login: 'user', + isLeader: true, + }, + expires: '2099-01-01T00:00:00.000Z', + }, + status: 'authenticated', + }) + + mockUseQuery.mockReturnValue({ + data: { myPrograms: { programs: [], totalPages: 1 } }, + loading: false, + error: undefined, + }) + + render() + expect(await screen.findByText(/Program not found/i)).toBeInTheDocument() + }) + + it('navigates to create page on clicking create button', async () => { + ;(mockUseSession as jest.Mock).mockReturnValue({ + data: { + user: { + name: 'User', + email: 'user@example.com', + login: 'User', + isLeader: true, + }, + expires: '2099-01-01T00:00:00.000Z', + }, + status: 'authenticated', + }) + + mockUseQuery.mockReturnValue({ + data: mockProgramData, + loading: false, + error: undefined, + }) + + render() + + const btn = await screen.findByRole('button', { name: /create program/i }) + fireEvent.click(btn) + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/create') + }) + }) +}) diff --git a/frontend/__tests__/unit/pages/Program.test.tsx b/frontend/__tests__/unit/pages/Program.test.tsx new file mode 100644 index 0000000000..09960da32d --- /dev/null +++ b/frontend/__tests__/unit/pages/Program.test.tsx @@ -0,0 +1,101 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { mockPrograms } from '@unit/data/mockProgramData' +import { useRouter } from 'next/navigation' +import { render } from 'wrappers/testUtil' +import ProgramsPage from 'app/mentorship/programs/page' + +import { fetchAlgoliaData } from 'server/fetchAlgoliaData' +jest.mock('server/fetchAlgoliaData', () => ({ + fetchAlgoliaData: jest.fn(), +})) + +const mockRouter = { + push: jest.fn(), +} +jest.mock('next/navigation', () => ({ + ...jest.requireActual('next/navigation'), + useRouter: jest.fn(() => mockRouter), + useSearchParams: () => new URLSearchParams(), +})) + +jest.mock('components/Pagination', () => + jest.fn(({ currentPage, onPageChange }) => ( +
+ +
+ )) +) + +jest.mock('@/components/MarkdownWrapper', () => { + return ({ content, className }: { content: string; className?: string }) => ( +
+ ) +}) + +describe('ProgramsPage Component', () => { + let mockRouter: { push: jest.Mock } + beforeEach(() => { + ;(fetchAlgoliaData as jest.Mock).mockResolvedValue({ + hits: mockPrograms, + totalPages: 2, + }) + mockRouter = { push: jest.fn() } + ;(useRouter as jest.Mock).mockReturnValue(mockRouter) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + test('renders published program cards', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Program 1')).toBeInTheDocument() + }) + + expect(screen.getByText('This is a summary of Program 1.')).toBeInTheDocument() + expect(screen.getByText('View Details')).toBeInTheDocument() + }) + + test('shows empty message when no programs found', async () => { + ;(fetchAlgoliaData as jest.Mock).mockResolvedValue({ hits: [], totalPages: 0 }) + + render() + + await waitFor(() => { + expect(screen.getByText('No programs found')).toBeInTheDocument() + }) + }) + + test('handles page change correctly', async () => { + window.scrollTo = jest.fn() + ;(fetchAlgoliaData as jest.Mock).mockResolvedValue({ + hits: mockPrograms, + totalPages: 2, + }) + render() + await waitFor(() => { + const nextPageButton = screen.getByText('Next Page') + fireEvent.click(nextPageButton) + }) + expect(window.scrollTo).toHaveBeenCalledWith({ + top: 0, + behavior: 'auto', + }) + }) + + test('navigates to program detail page on View Details click', async () => { + render() + + await waitFor(() => { + const viewButton = screen.getByText('View Details') + fireEvent.click(viewButton) + }) + + expect(mockRouter.push).toHaveBeenCalledWith('/mentorship/programs/program_1') + }) +}) diff --git a/frontend/__tests__/unit/pages/ProgramDetails.test.tsx b/frontend/__tests__/unit/pages/ProgramDetails.test.tsx new file mode 100644 index 0000000000..4231f31836 --- /dev/null +++ b/frontend/__tests__/unit/pages/ProgramDetails.test.tsx @@ -0,0 +1,73 @@ +import { useQuery } from '@apollo/client' +import { screen, waitFor } from '@testing-library/react' +import mockProgramDetailsData from '@unit/data/mockProgramData' +import { render } from 'wrappers/testUtil' +import ProgramDetailsPage from 'app/mentorship/programs/[programKey]/page' +import '@testing-library/jest-dom' + +jest.mock('@apollo/client', () => ({ + ...jest.requireActual('@apollo/client'), + useQuery: jest.fn(), + useMutation: jest.fn(() => [jest.fn()]), +})) + +jest.mock('next/navigation', () => ({ + ...jest.requireActual('next/navigation'), + useRouter: () => ({ replace: jest.fn() }), + useParams: () => ({ programKey: 'test-program' }), + useSearchParams: () => new URLSearchParams(), +})) + +describe('ProgramDetailsPage', () => { + beforeEach(() => { + ;(useQuery as jest.Mock).mockReturnValue({ + data: mockProgramDetailsData, + loading: false, + refetch: jest.fn(), + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + test('renders loading spinner when loading', async () => { + ;(useQuery as jest.Mock).mockReturnValue({ + loading: true, + data: null, + }) + + render() + + await waitFor(() => { + expect(screen.getAllByAltText('Loading indicator').length).toBeGreaterThan(0) + }) + }) + + test('renders 404 if no program found', async () => { + ;(useQuery as jest.Mock).mockReturnValue({ + loading: false, + data: { program: null }, + }) + + render() + + await waitFor(() => { + expect(screen.getByText('Program Not Found')).toBeInTheDocument() + }) + }) + + test('renders program details correctly', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Test Program')).toBeInTheDocument() + expect(screen.getByText('Sample summary')).toBeInTheDocument() + expect(screen.getByText('Draft')).toBeInTheDocument() + expect(screen.getByText('Jan 1, 2025')).toBeInTheDocument() + expect(screen.getByText('Dec 31, 2025')).toBeInTheDocument() + expect(screen.getByText('20')).toBeInTheDocument() + expect(screen.getByText('beginner, intermediate')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/__tests__/unit/pages/ProgramDetailsMentorship.test.tsx b/frontend/__tests__/unit/pages/ProgramDetailsMentorship.test.tsx new file mode 100644 index 0000000000..33e457e0d3 --- /dev/null +++ b/frontend/__tests__/unit/pages/ProgramDetailsMentorship.test.tsx @@ -0,0 +1,73 @@ +import { useQuery } from '@apollo/client' +import { screen, waitFor } from '@testing-library/react' +import mockProgramDetailsData from '@unit/data/mockProgramData' +import { render } from 'wrappers/testUtil' +import ProgramDetailsPage from 'app/my/mentorship/programs/[programKey]/page' +import '@testing-library/jest-dom' + +jest.mock('@apollo/client', () => ({ + ...jest.requireActual('@apollo/client'), + useQuery: jest.fn(), + useMutation: jest.fn(() => [jest.fn()]), +})) + +jest.mock('next/navigation', () => ({ + ...jest.requireActual('next/navigation'), + useRouter: () => ({ replace: jest.fn() }), + useParams: () => ({ programKey: 'test-program' }), + useSearchParams: () => new URLSearchParams(), +})) + +describe('ProgramDetailsPage', () => { + beforeEach(() => { + ;(useQuery as jest.Mock).mockReturnValue({ + data: mockProgramDetailsData, + loading: false, + refetch: jest.fn(), + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + test('renders loading spinner when loading', async () => { + ;(useQuery as jest.Mock).mockReturnValue({ + loading: true, + data: null, + }) + + render() + + await waitFor(() => { + expect(screen.getAllByAltText('Loading indicator').length).toBeGreaterThan(0) + }) + }) + + test('renders 404 if no program found', async () => { + ;(useQuery as jest.Mock).mockReturnValue({ + loading: false, + data: { program: null }, + }) + + render() + + await waitFor(() => { + expect(screen.getByText('Program Not Found')).toBeInTheDocument() + }) + }) + + test('renders program details correctly', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Test Program')).toBeInTheDocument() + expect(screen.getByText('Sample summary')).toBeInTheDocument() + expect(screen.getByText('Draft')).toBeInTheDocument() + expect(screen.getByText('Jan 1, 2025')).toBeInTheDocument() + expect(screen.getByText('Dec 31, 2025')).toBeInTheDocument() + expect(screen.getByText('20')).toBeInTheDocument() + expect(screen.getByText('beginner, intermediate')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts index ba6bb956ef..61dc4209c9 100644 --- a/frontend/jest.setup.ts +++ b/frontend/jest.setup.ts @@ -6,6 +6,20 @@ import 'core-js/actual/structured-clone' global.React = React global.TextEncoder = TextEncoder +jest.mock('next-auth/react', () => { + return { + ...jest.requireActual('next-auth/react'), + useSession: () => ({ + data: { + user: { name: 'Test User', email: 'test@example.com', login: 'testuser', isLeader: true }, + expires: '2099-01-01T00:00:00.000Z', + }, + status: 'authenticated', + loading: false, + }), + } +}) + if (!global.structuredClone) { global.structuredClone = (val) => JSON.parse(JSON.stringify(val)) } @@ -57,6 +71,7 @@ beforeEach(() => { dispatchEvent: jest.fn(), })), }) + global.runAnimationFrameCallbacks = jest.fn() global.removeAnimationFrameCallbacks = jest.fn() }) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 377740ffed..0d2233f399 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -61,7 +61,7 @@ importers: version: 15.4.6(next@15.4.6(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1) '@sentry/nextjs': specifier: ^10.4.0 - version: 10.4.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(next@15.4.6(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1)(webpack@5.101.0(@swc/core@1.13.3(@swc/helpers@0.5.17))) + version: 10.5.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(next@15.4.6(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1)(webpack@5.101.0(@swc/core@1.13.3(@swc/helpers@0.5.17))) '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) @@ -1437,21 +1437,21 @@ packages: resolution: {integrity: sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/gen-mapping@0.3.12': + resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/source-map@0.3.11': - resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + '@jridgewell/source-map@0.3.10': + resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==} - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.5.4': + resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==} - '@jridgewell/trace-mapping@0.3.30': - resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} + '@jridgewell/trace-mapping@0.3.29': + resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} @@ -2331,28 +2331,28 @@ packages: '@rushstack/eslint-patch@1.12.0': resolution: {integrity: sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==} - '@sentry-internal/browser-utils@10.4.0': - resolution: {integrity: sha512-rDGv6S18i9FyNVi/cXSOzfZZsGVR8MLOvAZUDdoCc39a3lrxG8Mdwk1KLAO62gXTElgfXQyYWrE7GUSOSXchhw==} + '@sentry-internal/browser-utils@10.5.0': + resolution: {integrity: sha512-4KIJdEj/8Ip9yqJleVSFe68r/U5bn5o/lYUwnFNEnDNxmpUbOlr7x3DXYuRFi1sfoMUxK9K1DrjnBkR7YYF00g==} engines: {node: '>=18'} - '@sentry-internal/feedback@10.4.0': - resolution: {integrity: sha512-uxcdDJFRNizp2MBMukR1A0yzxTWx/VP7Ckbn808lIj0FWWscOpJrTVfxJFrkOej2oBKvTX5qTlLmlQa8w86www==} + '@sentry-internal/feedback@10.5.0': + resolution: {integrity: sha512-x79P4VZwUxb1EGZb9OQ5EEgrDWFCUlrbzHBwV/oocQA5Ss1SFz5u6cP5Ak7yJtILiJtdGzAyAoQOy4GKD13D4Q==} engines: {node: '>=18'} - '@sentry-internal/replay-canvas@10.4.0': - resolution: {integrity: sha512-WLhelB7v/BkzTd0LFsK8INEH2aydqbq/UfCYsDsHrmlJrW0ivoACeQLDdR0WRAvWrB4jtZKg4JcTsuQoglwCsQ==} + '@sentry-internal/replay-canvas@10.5.0': + resolution: {integrity: sha512-5nrRKd5swefd9+sFXFZ/NeL3bz/VxBls3ubAQ3afak15FikkSyHq3oKRKpMOtDsiYKXE3Bc0y3rF5A+y3OXjIA==} engines: {node: '>=18'} - '@sentry-internal/replay@10.4.0': - resolution: {integrity: sha512-lCbLZOV3RHR7BovwG/Pba3nQ8S2RzIr52RmOwi1RntTMS410psirBb+NEY+RwS8xdQu9QsimCy3Nno7dG6zkHQ==} + '@sentry-internal/replay@10.5.0': + resolution: {integrity: sha512-Dp4coE/nPzhFrYH3iVrpVKmhNJ1m/jGXMEDBCNg3wJZRszI41Hrj0jCAM0Y2S3Q4IxYOmFYaFbGtVpAznRyOHg==} engines: {node: '>=18'} '@sentry/babel-plugin-component-annotate@4.1.0': resolution: {integrity: sha512-UkcnqC7Bp9ODyoBN7BKcRotd1jz/I2vyruE/qjNfRC7UnP+jIRItUWYaXxQPON1fTw+N+egKdByk0M1y2OPv/Q==} engines: {node: '>= 14'} - '@sentry/browser@10.4.0': - resolution: {integrity: sha512-tXvo7/vD9ZXFGufDm36JIo88szD5qhKt4ahsRl2ZmeHTj7UI1L6wl/rvk/9vn9jmiNjCQFY7ZMIQ+cf5S+4QmQ==} + '@sentry/browser@10.5.0': + resolution: {integrity: sha512-o5pEJeZ/iZ7Fmaz2sIirThfnmSVNiP5ZYhacvcDi0qc288TmBbikCX3fXxq3xiSkhXfe1o5QIbNyovzfutyuVw==} engines: {node: '>=18'} '@sentry/bundler-plugin-core@4.1.0': @@ -2411,18 +2411,18 @@ packages: engines: {node: '>= 10'} hasBin: true - '@sentry/core@10.4.0': - resolution: {integrity: sha512-QJRDUwnMe0QckGFzq42R/ulUEdN7iGvpDGNQD1Tho/0zkCz9vwBvskXj3MVFzhXMdLnqdAz8ximFdz/zdufjsA==} + '@sentry/core@10.5.0': + resolution: {integrity: sha512-jTJ8NhZSKB2yj3QTVRXfCCngQzAOLThQUxCl9A7Mv+XF10tP7xbH/88MVQ5WiOr2IzcmrB9r2nmUe36BnMlLjA==} engines: {node: '>=18'} - '@sentry/nextjs@10.4.0': - resolution: {integrity: sha512-ZLAIcK90zuLrFa1Pld91axDOSPpwMUdx6OBu8QtH84TFv8XsKNpyyQPuUbQsou2o0/PERteDNyRoGY2x1PCR7A==} + '@sentry/nextjs@10.5.0': + resolution: {integrity: sha512-CWozbPqbAX8qUx4DdVLgjEkjcG+JJ5vHyGczo8yiWVQQZAv/Ivd+TVxqAVMJiL68y+C4VQYfejGp64zsIYS3yw==} engines: {node: '>=18'} peerDependencies: next: ^13.2.0 || ^14.0 || ^15.0.0-rc.0 - '@sentry/node-core@10.4.0': - resolution: {integrity: sha512-LTi5nT8l43VJR2CpdYWJ/Ux33BqLE7TpPAdEfiKovZ8R+MGJcofAFEPjkeRkIscn7U/YlqEzjlMArzA9sP5E1g==} + '@sentry/node-core@10.5.0': + resolution: {integrity: sha512-VC4FCKMvvbUT32apTE0exfI/WigqKskzQA+VdFz61Y+T7mTCADngNrOjG3ilVYPBU7R9KEEziEd/oKgencqkmQ==} engines: {node: '>=18'} peerDependencies: '@opentelemetry/api': ^1.9.0 @@ -2433,12 +2433,12 @@ packages: '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.0.0 '@opentelemetry/semantic-conventions': ^1.34.0 - '@sentry/node@10.4.0': - resolution: {integrity: sha512-E5YhDkKY8M418xFcgKv+bdUJ54SCakjBUMLt7WH6bQIIhnIHgkPHwPA01do2ETvsLms1t73rC15i5R5v0pei2w==} + '@sentry/node@10.5.0': + resolution: {integrity: sha512-GqTkOc7tkWqRTKNjipysElh/bzIkhfLsvNGwH6+zel5kU15IdOCFtAqIri85ZLo9vbaIVtjQELXOzfo/5MMAFQ==} engines: {node: '>=18'} - '@sentry/opentelemetry@10.4.0': - resolution: {integrity: sha512-vquqWm+mhS/yZArZ7BbJ6rixUAZWfn3JUxWgRe/T1tdSqaRChwdYaKNN2KId1hR5tY80h3TrlBi6+cvUUkeGIA==} + '@sentry/opentelemetry@10.5.0': + resolution: {integrity: sha512-/Qva5vngtuh79YUUBA8kbbrD6w/A+u1vy1jnLoPMKDxWTfNPqT4tCiOOmWYotnITaE3QO0UtXK/j7LMX8FhtUA==} engines: {node: '>=18'} peerDependencies: '@opentelemetry/api': ^1.9.0 @@ -2447,14 +2447,14 @@ packages: '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.0.0 '@opentelemetry/semantic-conventions': ^1.34.0 - '@sentry/react@10.4.0': - resolution: {integrity: sha512-wuJY1KbVqvIfUG1Mk1BPjQjM1jm0Ui2jeyjCqLOrtZMD0ZsWekRo6vTcOmWtmR1b4DNia1xmZEWY/+D9+Mn4Yw==} + '@sentry/react@10.5.0': + resolution: {integrity: sha512-UHanvg+oIAvE/Hm76QCCdxYgb+tIuF0JszQoROApl5C5RxRfJJcU643pASQs6BDvrtxbuMQ/AHTacLTYpsn0cg==} engines: {node: '>=18'} peerDependencies: react: ^16.14.0 || 17.x || 18.x || 19.x - '@sentry/vercel-edge@10.4.0': - resolution: {integrity: sha512-ijJ0FxR5zHkox65ZaqgCDRsrwKrUJAiO2arjAjLvUj6ea0Gf9ZsdQTS4RaypWZXDmXmnl1JcOoLQJG74BRS3yQ==} + '@sentry/vercel-edge@10.5.0': + resolution: {integrity: sha512-DoH+BrKyI9uVUHyEh6raSba2OUgQ0CLtFeitG0geU90VPgAlINNnjhNeKJPLp0rR3v1KesdHebnRNGUUlvXalA==} engines: {node: '>=18'} '@sentry/webpack-plugin@4.1.0': @@ -2862,16 +2862,32 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/project-service@8.39.0': + resolution: {integrity: sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/project-service@8.39.1': resolution: {integrity: sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/scope-manager@8.39.0': + resolution: {integrity: sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.39.1': resolution: {integrity: sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.39.0': + resolution: {integrity: sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/tsconfig-utils@8.39.1': resolution: {integrity: sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2885,16 +2901,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/types@8.39.0': + resolution: {integrity: sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.39.1': resolution: {integrity: sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.39.0': + resolution: {integrity: sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/typescript-estree@8.39.1': resolution: {integrity: sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/utils@8.39.0': + resolution: {integrity: sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/utils@8.39.1': resolution: {integrity: sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2902,6 +2935,10 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/visitor-keys@8.39.0': + resolution: {integrity: sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.39.1': resolution: {integrity: sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3327,8 +3364,11 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001734: - resolution: {integrity: sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==} + caniuse-lite@1.0.30001731: + resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==} + + caniuse-lite@1.0.30001733: + resolution: {integrity: sha512-e4QKw/O2Kavj2VQTKZWrwzkt3IxOmIlU6ajRb6LP64LHpBo1J67k2Hi4Vu/TgJWsNtynurfS0uK3MaUTCPfu5Q==} chalk@3.0.0: resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} @@ -3594,8 +3634,8 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - electron-to-chromium@1.5.200: - resolution: {integrity: sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==} + electron-to-chromium@1.5.199: + resolution: {integrity: sha512-3gl0S7zQd88kCAZRO/DnxtBKuhMO4h0EaQIN3YgZfV6+pW+5+bf2AdQeHNESCoaQqo/gjGVYEf2YM4O5HJQqpQ==} emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} @@ -6139,8 +6179,8 @@ snapshots: '@ampproject/remapping@2.3.0': dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/gen-mapping': 0.3.12 + '@jridgewell/trace-mapping': 0.3.29 '@apollo/client@3.13.9(@types/react@19.1.10)(graphql@16.11.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: @@ -6209,8 +6249,8 @@ snapshots: dependencies: '@babel/parser': 7.28.0 '@babel/types': 7.28.2 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/gen-mapping': 0.3.12 + '@jridgewell/trace-mapping': 0.3.29 jsesc: 3.1.0 '@babel/helper-compilation-targets@7.27.2': @@ -7788,7 +7828,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/trace-mapping': 0.3.29 '@types/node': 22.17.1 chalk: 4.1.2 collect-v8-coverage: 1.0.2 @@ -7820,7 +7860,7 @@ snapshots: '@jest/source-map@29.6.3': dependencies: - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/trace-mapping': 0.3.29 callsites: 3.1.0 graceful-fs: 4.2.11 @@ -7842,7 +7882,7 @@ snapshots: dependencies: '@babel/core': 7.28.0 '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/trace-mapping': 0.3.29 babel-plugin-istanbul: 6.1.1 chalk: 4.1.2 convert-source-map: 2.0.0 @@ -7877,29 +7917,29 @@ snapshots: '@types/yargs': 17.0.33 chalk: 4.1.2 - '@jridgewell/gen-mapping@0.3.13': + '@jridgewell/gen-mapping@0.3.12': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/sourcemap-codec': 1.5.4 + '@jridgewell/trace-mapping': 0.3.29 '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/source-map@0.3.11': + '@jridgewell/source-map@0.3.10': dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/gen-mapping': 0.3.12 + '@jridgewell/trace-mapping': 0.3.29 - '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.5.4': {} - '@jridgewell/trace-mapping@0.3.30': + '@jridgewell/trace-mapping@0.3.29': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.5.4 '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.5.4 '@napi-rs/wasm-runtime@0.2.12': dependencies: @@ -9100,33 +9140,33 @@ snapshots: '@rushstack/eslint-patch@1.12.0': {} - '@sentry-internal/browser-utils@10.4.0': + '@sentry-internal/browser-utils@10.5.0': dependencies: - '@sentry/core': 10.4.0 + '@sentry/core': 10.5.0 - '@sentry-internal/feedback@10.4.0': + '@sentry-internal/feedback@10.5.0': dependencies: - '@sentry/core': 10.4.0 + '@sentry/core': 10.5.0 - '@sentry-internal/replay-canvas@10.4.0': + '@sentry-internal/replay-canvas@10.5.0': dependencies: - '@sentry-internal/replay': 10.4.0 - '@sentry/core': 10.4.0 + '@sentry-internal/replay': 10.5.0 + '@sentry/core': 10.5.0 - '@sentry-internal/replay@10.4.0': + '@sentry-internal/replay@10.5.0': dependencies: - '@sentry-internal/browser-utils': 10.4.0 - '@sentry/core': 10.4.0 + '@sentry-internal/browser-utils': 10.5.0 + '@sentry/core': 10.5.0 '@sentry/babel-plugin-component-annotate@4.1.0': {} - '@sentry/browser@10.4.0': + '@sentry/browser@10.5.0': dependencies: - '@sentry-internal/browser-utils': 10.4.0 - '@sentry-internal/feedback': 10.4.0 - '@sentry-internal/replay': 10.4.0 - '@sentry-internal/replay-canvas': 10.4.0 - '@sentry/core': 10.4.0 + '@sentry-internal/browser-utils': 10.5.0 + '@sentry-internal/feedback': 10.5.0 + '@sentry-internal/replay': 10.5.0 + '@sentry-internal/replay-canvas': 10.5.0 + '@sentry/core': 10.5.0 '@sentry/bundler-plugin-core@4.1.0': dependencies: @@ -9186,19 +9226,19 @@ snapshots: - encoding - supports-color - '@sentry/core@10.4.0': {} + '@sentry/core@10.5.0': {} - '@sentry/nextjs@10.4.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(next@15.4.6(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1)(webpack@5.101.0(@swc/core@1.13.3(@swc/helpers@0.5.17)))': + '@sentry/nextjs@10.5.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(next@15.4.6(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1)(webpack@5.101.0(@swc/core@1.13.3(@swc/helpers@0.5.17)))': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.36.0 '@rollup/plugin-commonjs': 28.0.1(rollup@4.46.2) - '@sentry-internal/browser-utils': 10.4.0 - '@sentry/core': 10.4.0 - '@sentry/node': 10.4.0 - '@sentry/opentelemetry': 10.4.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0) - '@sentry/react': 10.4.0(react@19.1.1) - '@sentry/vercel-edge': 10.4.0 + '@sentry-internal/browser-utils': 10.5.0 + '@sentry/core': 10.5.0 + '@sentry/node': 10.5.0 + '@sentry/opentelemetry': 10.5.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0) + '@sentry/react': 10.5.0(react@19.1.1) + '@sentry/vercel-edge': 10.5.0 '@sentry/webpack-plugin': 4.1.0(webpack@5.101.0(@swc/core@1.13.3(@swc/helpers@0.5.17))) chalk: 3.0.0 next: 15.4.6(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -9214,7 +9254,7 @@ snapshots: - supports-color - webpack - '@sentry/node-core@10.4.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)': + '@sentry/node-core@10.5.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/context-async-hooks': 2.0.1(@opentelemetry/api@1.9.0) @@ -9223,11 +9263,11 @@ snapshots: '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.36.0 - '@sentry/core': 10.4.0 - '@sentry/opentelemetry': 10.4.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0) + '@sentry/core': 10.5.0 + '@sentry/opentelemetry': 10.5.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0) import-in-the-middle: 1.14.2 - '@sentry/node@10.4.0': + '@sentry/node@10.5.0': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/context-async-hooks': 2.0.1(@opentelemetry/api@1.9.0) @@ -9259,35 +9299,35 @@ snapshots: '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.36.0 '@prisma/instrumentation': 6.13.0(@opentelemetry/api@1.9.0) - '@sentry/core': 10.4.0 - '@sentry/node-core': 10.4.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0) - '@sentry/opentelemetry': 10.4.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0) + '@sentry/core': 10.5.0 + '@sentry/node-core': 10.5.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0) + '@sentry/opentelemetry': 10.5.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0) import-in-the-middle: 1.14.2 minimatch: 9.0.5 transitivePeerDependencies: - supports-color - '@sentry/opentelemetry@10.4.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)': + '@sentry/opentelemetry@10.5.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/context-async-hooks': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.36.0 - '@sentry/core': 10.4.0 + '@sentry/core': 10.5.0 - '@sentry/react@10.4.0(react@19.1.1)': + '@sentry/react@10.5.0(react@19.1.1)': dependencies: - '@sentry/browser': 10.4.0 - '@sentry/core': 10.4.0 + '@sentry/browser': 10.5.0 + '@sentry/core': 10.5.0 hoist-non-react-statics: 3.3.2 react: 19.1.1 - '@sentry/vercel-edge@10.4.0': + '@sentry/vercel-edge@10.5.0': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) - '@sentry/core': 10.4.0 + '@sentry/core': 10.5.0 '@sentry/webpack-plugin@4.1.0(webpack@5.101.0(@swc/core@1.13.3(@swc/helpers@0.5.17)))': dependencies: @@ -9695,6 +9735,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.39.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.39.0(typescript@5.8.3) + '@typescript-eslint/types': 8.39.0 + debug: 4.4.1 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/project-service@8.39.1(typescript@5.8.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.39.1(typescript@5.8.3) @@ -9704,11 +9753,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/scope-manager@8.39.0': + dependencies: + '@typescript-eslint/types': 8.39.0 + '@typescript-eslint/visitor-keys': 8.39.0 + '@typescript-eslint/scope-manager@8.39.1': dependencies: '@typescript-eslint/types': 8.39.1 '@typescript-eslint/visitor-keys': 8.39.1 + '@typescript-eslint/tsconfig-utils@8.39.0(typescript@5.8.3)': + dependencies: + typescript: 5.8.3 + '@typescript-eslint/tsconfig-utils@8.39.1(typescript@5.8.3)': dependencies: typescript: 5.8.3 @@ -9725,8 +9783,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/types@8.39.0': {} + '@typescript-eslint/types@8.39.1': {} + '@typescript-eslint/typescript-estree@8.39.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/project-service': 8.39.0(typescript@5.8.3) + '@typescript-eslint/tsconfig-utils': 8.39.0(typescript@5.8.3) + '@typescript-eslint/types': 8.39.0 + '@typescript-eslint/visitor-keys': 8.39.0 + debug: 4.4.1 + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.2 + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/typescript-estree@8.39.1(typescript@5.8.3)': dependencies: '@typescript-eslint/project-service': 8.39.1(typescript@5.8.3) @@ -9743,6 +9819,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.39.0(eslint@9.33.0(jiti@2.5.1))(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.7.0(eslint@9.33.0(jiti@2.5.1)) + '@typescript-eslint/scope-manager': 8.39.0 + '@typescript-eslint/types': 8.39.0 + '@typescript-eslint/typescript-estree': 8.39.0(typescript@5.8.3) + eslint: 9.33.0(jiti@2.5.1) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.39.1(eslint@9.33.0(jiti@2.5.1))(typescript@5.8.3)': dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.33.0(jiti@2.5.1)) @@ -9754,6 +9841,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/visitor-keys@8.39.0': + dependencies: + '@typescript-eslint/types': 8.39.0 + eslint-visitor-keys: 4.2.1 + '@typescript-eslint/visitor-keys@8.39.1': dependencies: '@typescript-eslint/types': 8.39.1 @@ -10087,7 +10179,7 @@ snapshots: autoprefixer@10.4.21(postcss@8.5.6): dependencies: browserslist: 4.25.2 - caniuse-lite: 1.0.30001734 + caniuse-lite: 1.0.30001731 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.1.1 @@ -10178,8 +10270,8 @@ snapshots: browserslist@4.25.2: dependencies: - caniuse-lite: 1.0.30001734 - electron-to-chromium: 1.5.200 + caniuse-lite: 1.0.30001733 + electron-to-chromium: 1.5.199 node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.2) @@ -10222,7 +10314,9 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001734: {} + caniuse-lite@1.0.30001731: {} + + caniuse-lite@1.0.30001733: {} chalk@3.0.0: dependencies: @@ -10450,7 +10544,7 @@ snapshots: eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.200: {} + electron-to-chromium@1.5.199: {} emittery@0.13.1: {} @@ -10673,7 +10767,7 @@ snapshots: eslint-plugin-jest@29.0.1(@typescript-eslint/eslint-plugin@8.39.1(@typescript-eslint/parser@8.39.1(eslint@9.33.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.33.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.33.0(jiti@2.5.1))(jest@29.7.0(@types/node@22.17.1)(ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@22.17.1)(typescript@5.8.3)))(typescript@5.8.3): dependencies: - '@typescript-eslint/utils': 8.39.1(eslint@9.33.0(jiti@2.5.1))(typescript@5.8.3) + '@typescript-eslint/utils': 8.39.0(eslint@9.33.0(jiti@2.5.1))(typescript@5.8.3) eslint: 9.33.0(jiti@2.5.1) optionalDependencies: '@typescript-eslint/eslint-plugin': 8.39.1(@typescript-eslint/parser@8.39.1(eslint@9.33.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.33.0(jiti@2.5.1))(typescript@5.8.3) @@ -11925,11 +12019,11 @@ snapshots: magic-string@0.30.17: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.5.4 magic-string@0.30.8: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.5.4 make-dir@4.0.0: dependencies: @@ -12049,7 +12143,7 @@ snapshots: dependencies: '@next/env': 15.4.6 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001734 + caniuse-lite: 1.0.30001731 postcss: 8.4.31 react: 19.1.1 react-dom: 19.1.1(react@19.1.1) @@ -12827,7 +12921,7 @@ snapshots: sucrase@3.35.0: dependencies: - '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/gen-mapping': 0.3.12 commander: 4.1.1 glob: 10.4.5 lines-and-columns: 1.2.4 @@ -12907,7 +13001,7 @@ snapshots: terser-webpack-plugin@5.3.14(@swc/core@1.13.3(@swc/helpers@0.5.17))(webpack@5.101.0(@swc/core@1.13.3(@swc/helpers@0.5.17))): dependencies: - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/trace-mapping': 0.3.29 jest-worker: 27.5.1 schema-utils: 4.3.2 serialize-javascript: 6.0.2 @@ -12918,7 +13012,7 @@ snapshots: terser@5.43.1: dependencies: - '@jridgewell/source-map': 0.3.11 + '@jridgewell/source-map': 0.3.10 acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -13179,7 +13273,7 @@ snapshots: v8-to-istanbul@9.3.0: dependencies: - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/trace-mapping': 0.3.29 '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 diff --git a/frontend/src/app/api/auth/[...nextauth]/route.ts b/frontend/src/app/api/auth/[...nextauth]/route.ts index d856dee9dc..50ecd52902 100644 --- a/frontend/src/app/api/auth/[...nextauth]/route.ts +++ b/frontend/src/app/api/auth/[...nextauth]/route.ts @@ -1,5 +1,7 @@ import NextAuth, { type AuthOptions } from 'next-auth' import GitHubProvider from 'next-auth/providers/github' +import { apolloClient } from 'server/apolloClient' +import { IS_PROJECT_LEADER_QUERY, IS_MENTOR_QUERY } from 'server/queries/mentorshipQueries' import { ExtendedProfile, ExtendedSession } from 'types/auth' import { GITHUB_CLIENT_ID, @@ -8,6 +10,34 @@ import { NEXTAUTH_SECRET, } from 'utils/credentials' +async function checkIfProjectLeader(login: string): Promise { + try { + const client = await apolloClient + const { data } = await client.query({ + query: IS_PROJECT_LEADER_QUERY, + variables: { login }, + fetchPolicy: 'no-cache', + }) + return data?.isProjectLeader ?? false + } catch (err) { + throw new Error('Failed to fetch project leader status Error', err) + } +} + +async function checkIfMentor(login: string): Promise { + try { + const client = await apolloClient + const { data } = await client.query({ + query: IS_MENTOR_QUERY, + variables: { login }, + fetchPolicy: 'no-cache', + }) + return data?.isMentor ?? false + } catch (err) { + throw new Error('Failed to fetch mentor status Error', err) + } +} + const providers = [] if (IS_GITHUB_AUTH_ENABLED) { @@ -42,9 +72,17 @@ const authOptions: AuthOptions = { if (account?.access_token) { token.accessToken = account.access_token } + if ((profile as ExtendedProfile)?.login) { - token.login = (profile as ExtendedProfile)?.login + const login = (profile as ExtendedProfile).login + token.login = login + + const isLeader = await checkIfProjectLeader(login) + const isMentor = await checkIfMentor(login) + token.isLeader = isLeader + token.isMentor = isMentor } + if (trigger === 'update' && session) { token.isOwaspStaff = (session as ExtendedSession).user.isOwaspStaff || false } @@ -53,8 +91,11 @@ const authOptions: AuthOptions = { async session({ session, token }) { ;(session as ExtendedSession).accessToken = token.accessToken as string + if (session.user) { ;(session as ExtendedSession).user.login = token.login as string + ;(session as ExtendedSession).user.isMentor = token.isMentor as boolean + ;(session as ExtendedSession).user.isLeader = token.isLeader as boolean ;(session as ExtendedSession).user.isOwaspStaff = token.isOwaspStaff as boolean } return session diff --git a/frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx b/frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx new file mode 100644 index 0000000000..c2f4e2b1c6 --- /dev/null +++ b/frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx @@ -0,0 +1,75 @@ +'use client' + +import { useQuery } from '@apollo/client' +import upperFirst from 'lodash/upperFirst' +import { useParams } from 'next/navigation' +import { useEffect, useState } from 'react' +import { ErrorDisplay, handleAppError } from 'app/global-error' +import { GET_PROGRAM_ADMINS_AND_MODULES } from 'server/queries/moduleQueries' +import type { Module } from 'types/mentorship' +import { formatDate } from 'utils/dateFormatter' +import DetailsCard from 'components/CardDetailsPage' +import LoadingSpinner from 'components/LoadingSpinner' +import { getSimpleDuration } from 'components/ModuleCard' + +const ModuleDetailsPage = () => { + const { programKey, moduleKey } = useParams() + const [module, setModule] = useState(null) + const [admins, setAdmins] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + const { data, error } = useQuery(GET_PROGRAM_ADMINS_AND_MODULES, { + variables: { + programKey, + moduleKey, + }, + }) + + useEffect(() => { + if (data?.getModule) { + setModule(data.getModule) + setAdmins(data.getProgram.admins) + setIsLoading(false) + } else if (error) { + handleAppError(error) + setIsLoading(false) + } + }, [data, error]) + + if (isLoading) return + + if (!module) { + return ( + + ) + } + + const moduleDetails = [ + { label: 'Experience Level', value: upperFirst(module.experienceLevel) }, + { label: 'Start Date', value: formatDate(module.startedAt) }, + { label: 'End Date', value: formatDate(module.endedAt) }, + { + label: 'Duration', + value: getSimpleDuration(module.startedAt, module.endedAt), + }, + ] + + return ( + + ) +} + +export default ModuleDetailsPage diff --git a/frontend/src/app/mentorship/programs/[programKey]/page.tsx b/frontend/src/app/mentorship/programs/[programKey]/page.tsx new file mode 100644 index 0000000000..ccd7bbd687 --- /dev/null +++ b/frontend/src/app/mentorship/programs/[programKey]/page.tsx @@ -0,0 +1,96 @@ +'use client' + +import { useQuery } from '@apollo/client' +import upperFirst from 'lodash/upperFirst' +import { useParams, useSearchParams, useRouter } from 'next/navigation' +import { useEffect, useState } from 'react' +import { ErrorDisplay } from 'app/global-error' +import { GET_PROGRAM_AND_MODULES } from 'server/queries/programsQueries' +import type { Module, Program } from 'types/mentorship' +import { formatDate } from 'utils/dateFormatter' +import DetailsCard from 'components/CardDetailsPage' +import LoadingSpinner from 'components/LoadingSpinner' + +const ProgramDetailsPage = () => { + const { programKey } = useParams() as { programKey: string } + const searchParams = useSearchParams() + const router = useRouter() + const shouldRefresh = searchParams.get('refresh') === 'true' + const { + data, + refetch, + loading: isQueryLoading, + } = useQuery(GET_PROGRAM_AND_MODULES, { + variables: { programKey }, + skip: !programKey, + notifyOnNetworkStatusChange: true, + }) + + const [program, setProgram] = useState(null) + const [modules, setModules] = useState([]) + const [isRefetching, setIsRefetching] = useState(false) + + const isLoading = isQueryLoading || isRefetching + + useEffect(() => { + const processResult = async () => { + if (shouldRefresh) { + setIsRefetching(true) + try { + await refetch() + } finally { + setIsRefetching(false) + + const params = new URLSearchParams(searchParams.toString()) + params.delete('refresh') + const cleaned = params.toString() + router.replace(cleaned ? `?${cleaned}` : window.location.pathname, { scroll: false }) + } + } + + if (data?.getProgram) { + setProgram(data.getProgram) + setModules(data.getProgramModules || []) + } + } + + processResult() + }, [shouldRefresh, data, refetch, router, searchParams]) + + if (isLoading) return + + if (!program && !isLoading) { + return ( + + ) + } + + const programDetails = [ + { label: 'Status', value: upperFirst(program.status) }, + { label: 'Start Date', value: formatDate(program.startedAt) }, + { label: 'End Date', value: formatDate(program.endedAt) }, + { label: 'Mentees Limit', value: String(program.menteesLimit) }, + { + label: 'Experience Levels', + value: program.experienceLevels?.join(', ') || 'N/A', + }, + ] + + return ( + + ) +} + +export default ProgramDetailsPage diff --git a/frontend/src/app/mentorship/programs/page.tsx b/frontend/src/app/mentorship/programs/page.tsx new file mode 100644 index 0000000000..66f78a897e --- /dev/null +++ b/frontend/src/app/mentorship/programs/page.tsx @@ -0,0 +1,60 @@ +'use client' + +import { useSearchPage } from 'hooks/useSearchPage' +import { useRouter } from 'next/navigation' +import { Program } from 'types/mentorship' +import ProgramCard from 'components/ProgramCard' +import SearchPageLayout from 'components/SearchPageLayout' + +const ProgramsPage = () => { + const { + items: programs, + isLoaded, + currentPage, + totalPages, + searchQuery, + handleSearch, + handlePageChange, + } = useSearchPage({ + indexName: 'programs', + pageTitle: 'OWASP Programs', + hitsPerPage: 24, + }) + + const router = useRouter() + + const renderProgramCard = (program: Program) => { + const handleButtonClick = () => { + router.push(`/mentorship/programs/${program.key}`) + } + + return ( + + ) + } + + return ( + +
+ {programs && programs.filter((p) => p.status === 'published').map(renderProgramCard)} +
+
+ ) +} + +export default ProgramsPage diff --git a/frontend/src/app/my/mentorship/page.tsx b/frontend/src/app/my/mentorship/page.tsx new file mode 100644 index 0000000000..183a3a8ecb --- /dev/null +++ b/frontend/src/app/my/mentorship/page.tsx @@ -0,0 +1,154 @@ +'use client' + +import { useQuery } from '@apollo/client' +import { faPlus, faGraduationCap } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { addToast } from '@heroui/toast' +import { debounce } from 'lodash' +import { useRouter, useSearchParams } from 'next/navigation' +import { useSession } from 'next-auth/react' +import React, { useEffect, useMemo, useState } from 'react' + +import { GET_MY_PROGRAMS } from 'server/queries/programsQueries' +import type { ExtendedSession } from 'types/auth' +import type { Program } from 'types/mentorship' + +import ActionButton from 'components/ActionButton' +import LoadingSpinner from 'components/LoadingSpinner' +import ProgramCard from 'components/ProgramCard' +import SearchPageLayout from 'components/SearchPageLayout' + +const MyMentorshipPage: React.FC = () => { + const router = useRouter() + const searchParams = useSearchParams() + const { data: session } = useSession() + const username = (session as ExtendedSession)?.user?.login + + const initialQuery = searchParams.get('q') || '' + const initialPage = parseInt(searchParams.get('page') || '1', 10) + + const [searchQuery, setSearchQuery] = useState(initialQuery) + const [debouncedQuery, setDebouncedQuery] = useState(initialQuery) + const [page, setPage] = useState(initialPage) + const [programs, setPrograms] = useState([]) + const [totalPages, setTotalPages] = useState(1) + + const debounceSearch = useMemo(() => debounce((q) => setDebouncedQuery(q), 400), []) + + useEffect(() => { + debounceSearch(searchQuery) + return () => debounceSearch.cancel() + }, [debounceSearch, searchQuery]) + + useEffect(() => { + const params = new URLSearchParams() + if (searchQuery) params.set('q', searchQuery) + if (page > 1) params.set('page', String(page)) + const nextUrl = params.toString() ? `?${params}` : window.location.pathname + if (window.location.search !== `?${params}`) { + router.push(nextUrl, { scroll: false }) + } + }, [searchQuery, page, router]) + + const { + data: programData, + loading: loadingPrograms, + error, + } = useQuery(GET_MY_PROGRAMS, { + variables: { search: debouncedQuery, page, limit: 24 }, + fetchPolicy: 'cache-and-network', + errorPolicy: 'all', + }) + const isProjectLeader = (session as ExtendedSession)?.user.isLeader + + useEffect(() => { + if (programData?.myPrograms) { + setPrograms(programData.myPrograms.programs) + setTotalPages(programData.myPrograms.totalPages || 1) + } + }, [programData]) + + useEffect(() => { + if (error) { + addToast({ + title: 'GraphQL Error', + description: 'Failed to fetch your programs', + color: 'danger', + timeout: 3000, + variant: 'solid', + }) + } + }, [error]) + + const handleCreate = () => router.push('/my/mentorship/programs/create') + const handleView = (key: string) => router.push(`/my/mentorship/programs/${key}`) + const handleEdit = (key: string) => router.push(`/my/mentorship/programs/${key}/edit`) + + if (!username) { + return + } + + if (!isProjectLeader) { + return ( +
+ +

Access Denied

+

+ Only project leaders can access this page. +

+
+ ) + } + + return ( +
+
+
+

My Mentorship

+

Programs you’ve created or joined

+
+ + + {'Create Program'} + +
+ + { + setPage(p) + window.scrollTo({ top: 0, behavior: 'smooth' }) + }} + onSearch={(q) => { + setSearchQuery(q) + setPage(1) + }} + searchQuery={searchQuery} + searchPlaceholder="Search your programs" + indexName="my-programs" + > +
+ {programs.length === 0 ? ( +
+

Program not found

+
+ ) : ( + programs.map((p) => ( + + )) + )} +
+
+
+ ) +} + +export default MyMentorshipPage diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/edit/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/edit/page.tsx new file mode 100644 index 0000000000..cdfcec0ca1 --- /dev/null +++ b/frontend/src/app/my/mentorship/programs/[programKey]/edit/page.tsx @@ -0,0 +1,152 @@ +'use client' +import { useQuery, useMutation } from '@apollo/client' +import { addToast } from '@heroui/toast' +import { useRouter, useParams } from 'next/navigation' +import { useSession } from 'next-auth/react' +import type React from 'react' +import { useState, useEffect } from 'react' +import { ErrorDisplay, handleAppError } from 'app/global-error' +import { UPDATE_PROGRAM } from 'server/mutations/programsMutations' +import { GET_PROGRAM_DETAILS } from 'server/queries/programsQueries' +import type { ExtendedSession } from 'types/auth' +import { formatDateForInput } from 'utils/dateFormatter' +import { parseCommaSeparated } from 'utils/parser' +import slugify from 'utils/slugify' +import LoadingSpinner from 'components/LoadingSpinner' +import ProgramForm from 'components/ProgramForm' +const EditProgramPage = () => { + const router = useRouter() + const { programKey } = useParams() as { programKey: string } + const { data: session, status: sessionStatus } = useSession() + const [updateProgram, { loading: mutationLoading }] = useMutation(UPDATE_PROGRAM) + const { + data, + error, + loading: queryLoading, + } = useQuery(GET_PROGRAM_DETAILS, { + variables: { programKey }, + skip: !programKey, + fetchPolicy: 'network-only', + }) + const [formData, setFormData] = useState({ + name: '', + description: '', + menteesLimit: 5, + startedAt: '', + endedAt: '', + tags: '', + domains: '', + adminLogins: '', + status: 'DRAFT', + }) + const [accessStatus, setAccessStatus] = useState<'checking' | 'allowed' | 'denied'>('checking') + useEffect(() => { + if (sessionStatus === 'loading' || queryLoading) { + return + } + if (!data?.getProgram || sessionStatus === 'unauthenticated') { + setAccessStatus('denied') + return + } + + const isAdmin = data.getProgram.admins?.some( + (admin: { login: string }) => admin.login === (session as ExtendedSession)?.user?.login + ) + + if (isAdmin) { + setAccessStatus('allowed') + } else { + setAccessStatus('denied') + addToast({ + title: 'Access Denied', + description: 'Only program admins can edit this page.', + color: 'danger', + variant: 'solid', + timeout: 4000, + }) + setTimeout(() => router.replace('/my/mentorship/programs'), 1500) + } + }, [sessionStatus, session, data, queryLoading, router]) + useEffect(() => { + if (accessStatus === 'allowed' && data?.getProgram) { + const { getProgram: program } = data + setFormData({ + name: program.name || '', + description: program.description || '', + menteesLimit: program.menteesLimit ?? 5, + startedAt: formatDateForInput(program.startedAt), + endedAt: formatDateForInput(program.endedAt), + tags: (program.tags || []).join(', '), + domains: (program.domains || []).join(', '), + adminLogins: (program.admins || []) + .map((admin: { login: string }) => admin.login) + .join(', '), + status: program.status || 'DRAFT', + }) + } else if (error) { + handleAppError(error) + } + }, [accessStatus, data, error]) + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + try { + const input = { + key: programKey, + name: formData.name, + description: formData.description, + menteesLimit: Number(formData.menteesLimit), + startedAt: formData.startedAt, + endedAt: formData.endedAt, + tags: parseCommaSeparated(formData.tags), + domains: parseCommaSeparated(formData.domains), + adminLogins: parseCommaSeparated(formData.adminLogins), + status: formData.status, + } + + await updateProgram({ variables: { input } }) + + addToast({ + title: 'Program Updated', + description: 'The program has been successfully updated.', + color: 'success', + variant: 'solid', + timeout: 3000, + }) + + router.push(`/my/mentorship/programs/${slugify(formData.name)}?refresh=true`) + } catch (err) { + addToast({ + title: 'Update Failed', + description: 'There was an error updating the program.', + color: 'danger', + variant: 'solid', + timeout: 3000, + }) + handleAppError(err) + } + } + if (accessStatus === 'checking') { + return + } + if (accessStatus === 'denied') { + return ( + + ) + } + return ( + + ) +} +export default EditProgramPage diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx new file mode 100644 index 0000000000..679eeeb803 --- /dev/null +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx @@ -0,0 +1,153 @@ +'use client' + +import { useMutation, useQuery } from '@apollo/client' +import { addToast } from '@heroui/toast' +import { useParams, useRouter } from 'next/navigation' +import { useSession } from 'next-auth/react' +import React, { useEffect, useState } from 'react' +import { ErrorDisplay, handleAppError } from 'app/global-error' +import { UPDATE_MODULE } from 'server/mutations/moduleMutations' +import { GET_PROGRAM_ADMINS_AND_MODULES } from 'server/queries/moduleQueries' +import type { ExtendedSession } from 'types/auth' +import { EXPERIENCE_LEVELS, type ModuleFormData } from 'types/mentorship' +import { formatDateForInput } from 'utils/dateFormatter' +import { parseCommaSeparated } from 'utils/parser' +import LoadingSpinner from 'components/LoadingSpinner' +import ModuleForm from 'components/ModuleForm' + +const EditModulePage = () => { + const { programKey, moduleKey } = useParams() as { programKey: string; moduleKey: string } + const router = useRouter() + const { data: sessionData, status: sessionStatus } = useSession() + + const [formData, setFormData] = useState(null) + const [accessStatus, setAccessStatus] = useState<'checking' | 'allowed' | 'denied'>('checking') + + const [updateModule, { loading: mutationLoading }] = useMutation(UPDATE_MODULE) + + const { + data, + loading: queryLoading, + error: queryError, + } = useQuery(GET_PROGRAM_ADMINS_AND_MODULES, { + variables: { programKey, moduleKey }, + skip: !programKey || !moduleKey, + fetchPolicy: 'network-only', + }) + + useEffect(() => { + if (sessionStatus === 'loading' || queryLoading) { + return + } + + if ( + queryError || + !data?.getProgram || + !data?.getModule || + sessionStatus === 'unauthenticated' + ) { + setAccessStatus('denied') + return + } + + const currentUserLogin = (sessionData as ExtendedSession)?.user?.login + const isAdmin = data.getProgram.admins?.some( + (admin: { login: string }) => admin.login === currentUserLogin + ) + + if (isAdmin) { + setAccessStatus('allowed') + } else { + setAccessStatus('denied') + addToast({ + title: 'Access Denied', + description: 'Only program admins can edit modules.', + color: 'danger', + variant: 'solid', + timeout: 4000, + }) + setTimeout(() => router.replace(`/my/mentorship/programs/${programKey}`), 1500) + } + }, [sessionStatus, sessionData, queryLoading, data, programKey, queryError, router]) + + useEffect(() => { + if (accessStatus === 'allowed' && data?.getModule) { + const m = data.getModule + setFormData({ + name: m.name || '', + description: m.description || '', + experienceLevel: m.experienceLevel || EXPERIENCE_LEVELS.BEGINNER, + startedAt: formatDateForInput(m.startedAt), + endedAt: formatDateForInput(m.endedAt), + domains: (m.domains || []).join(', '), + projectName: m.projectName, + tags: (m.tags || []).join(', '), + projectId: m.projectId || '', + mentorLogins: (m.mentors || []).map((mentor: { login: string }) => mentor.login).join(', '), + }) + } + }, [accessStatus, data]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!formData) return + + try { + const input = { + key: moduleKey, + programKey: programKey, + name: formData.name, + description: formData.description, + experienceLevel: formData.experienceLevel, + startedAt: formData.startedAt || null, + endedAt: formData.endedAt || null, + domains: parseCommaSeparated(formData.domains), + tags: parseCommaSeparated(formData.tags), + projectName: formData.projectName, + projectId: formData.projectId, + mentorLogins: parseCommaSeparated(formData.mentorLogins), + } + + await updateModule({ variables: { input } }) + + addToast({ + title: 'Module Updated', + description: 'The module has been successfully updated.', + color: 'success', + variant: 'solid', + timeout: 3000, + }) + router.push(`/my/mentorship/programs/${programKey}?refresh=true`) + } catch (err) { + handleAppError(err) + } + } + + if (accessStatus === 'checking' || !formData) { + return + } + + if (accessStatus === 'denied') { + return ( + + ) + } + + return ( + + ) +} + +export default EditModulePage diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx new file mode 100644 index 0000000000..dc0a7356b9 --- /dev/null +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx @@ -0,0 +1,76 @@ +'use client' + +import { useQuery } from '@apollo/client' +import upperFirst from 'lodash/upperFirst' +import { useParams } from 'next/navigation' +import { useEffect, useState } from 'react' +import { ErrorDisplay, handleAppError } from 'app/global-error' +import { GET_PROGRAM_ADMINS_AND_MODULES } from 'server/queries/moduleQueries' +import type { Module } from 'types/mentorship' +import { formatDate } from 'utils/dateFormatter' +import DetailsCard from 'components/CardDetailsPage' +import LoadingSpinner from 'components/LoadingSpinner' +import { getSimpleDuration } from 'components/ModuleCard' + +const ModuleDetailsPage = () => { + const { programKey, moduleKey } = useParams() + const [module, setModule] = useState(null) + const [admins, setAdmins] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + const { data, error } = useQuery(GET_PROGRAM_ADMINS_AND_MODULES, { + variables: { + programKey, + moduleKey, + }, + }) + + useEffect(() => { + if (data?.getModule) { + setModule(data.getModule) + setAdmins(data.getProgram.admins) + setIsLoading(false) + } else if (error) { + handleAppError(error) + setIsLoading(false) + } + }, [data, error]) + + if (isLoading) return + + if (!module) { + return ( + + ) + } + + const moduleDetails = [ + { label: 'Experience Level', value: upperFirst(module.experienceLevel) }, + { label: 'Start Date', value: formatDate(module.startedAt) }, + { label: 'End Date', value: formatDate(module.endedAt) }, + { + label: 'Duration', + value: getSimpleDuration(module.startedAt, module.endedAt), + }, + ] + + return ( + + ) +} + +export default ModuleDetailsPage diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/create/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/create/page.tsx new file mode 100644 index 0000000000..8cc96d6d5b --- /dev/null +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/create/page.tsx @@ -0,0 +1,146 @@ +'use client' + +import { useMutation, useQuery } from '@apollo/client' +import { addToast } from '@heroui/toast' +import { useRouter, useParams } from 'next/navigation' +import { useSession } from 'next-auth/react' +import React, { useEffect, useState } from 'react' +import { ErrorDisplay } from 'app/global-error' +import { CREATE_MODULE } from 'server/mutations/moduleMutations' +import { GET_PROGRAM_ADMIN_DETAILS } from 'server/queries/programsQueries' +import type { ExtendedSession } from 'types/auth' +import { EXPERIENCE_LEVELS } from 'types/mentorship' +import { parseCommaSeparated } from 'utils/parser' +import LoadingSpinner from 'components/LoadingSpinner' +import ModuleForm from 'components/ModuleForm' + +const CreateModulePage = () => { + const router = useRouter() + const { programKey } = useParams() as { programKey: string } + const { data: sessionData, status: sessionStatus } = useSession() + + const [createModule, { loading: mutationLoading }] = useMutation(CREATE_MODULE) + + const { + data: programData, + loading: queryLoading, + error: queryError, + } = useQuery(GET_PROGRAM_ADMIN_DETAILS, { + variables: { programKey }, + skip: !programKey, + fetchPolicy: 'network-only', + }) + + const [formData, setFormData] = useState({ + name: '', + description: '', + experienceLevel: EXPERIENCE_LEVELS.BEGINNER, + startedAt: '', + endedAt: '', + domains: '', + tags: '', + projectId: '', + projectName: '', + mentorLogins: '', + }) + + const [accessStatus, setAccessStatus] = useState<'checking' | 'allowed' | 'denied'>('checking') + + useEffect(() => { + if (sessionStatus === 'loading' || queryLoading) { + return + } + + if (queryError || !programData?.getProgram || sessionStatus === 'unauthenticated') { + setAccessStatus('denied') + return + } + + const currentUserLogin = (sessionData as ExtendedSession)?.user?.login + const isAdmin = programData.getProgram.admins?.some( + (admin: { login: string }) => admin.login === currentUserLogin + ) + + if (isAdmin) { + setAccessStatus('allowed') + } else { + setAccessStatus('denied') + addToast({ + title: 'Access Denied', + description: 'Only program admins can create modules.', + color: 'danger', + variant: 'solid', + timeout: 4000, + }) + setTimeout(() => router.replace('/my/mentorship'), 1500) + } + }, [sessionStatus, sessionData, queryLoading, programData, programKey, queryError, router]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + try { + const input = { + name: formData.name, + description: formData.description, + experienceLevel: formData.experienceLevel, + startedAt: formData.startedAt || null, + endedAt: formData.endedAt || null, + domains: parseCommaSeparated(formData.domains), + tags: parseCommaSeparated(formData.tags), + programKey: programKey, + projectId: formData.projectId, + projectName: formData.projectName, + mentorLogins: parseCommaSeparated(formData.mentorLogins), + } + + await createModule({ variables: { input } }) + + addToast({ + title: 'Module Created', + description: 'The new module has been successfully created.', + color: 'success', + variant: 'solid', + timeout: 3000, + }) + + router.push(`/my/mentorship/programs/${programKey}?refresh=true`) + } catch (err) { + addToast({ + title: 'Creation Failed', + description: err.message || 'Something went wrong while creating the module.', + color: 'danger', + variant: 'solid', + timeout: 4000, + }) + } + } + + if (accessStatus === 'checking') { + return + } + + if (accessStatus === 'denied') { + return ( + + ) + } + + return ( + + ) +} + +export default CreateModulePage diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/page.tsx new file mode 100644 index 0000000000..a28ef94602 --- /dev/null +++ b/frontend/src/app/my/mentorship/programs/[programKey]/page.tsx @@ -0,0 +1,159 @@ +'use client' + +import { useQuery, useMutation } from '@apollo/client' +import { addToast } from '@heroui/toast' +import upperFirst from 'lodash/upperFirst' +import { useParams, useSearchParams, useRouter } from 'next/navigation' +import { useSession } from 'next-auth/react' +import { useEffect, useMemo, useState } from 'react' +import { ErrorDisplay, handleAppError } from 'app/global-error' +import { UPDATE_PROGRAM_STATUS_MUTATION } from 'server/mutations/programsMutations' +import { GET_PROGRAM_AND_MODULES } from 'server/queries/programsQueries' +import type { ExtendedSession } from 'types/auth' +import type { Module, Program } from 'types/mentorship' +import { ProgramStatusEnum } from 'types/mentorship' +import { formatDate } from 'utils/dateFormatter' +import DetailsCard from 'components/CardDetailsPage' +import LoadingSpinner from 'components/LoadingSpinner' + +const ProgramDetailsPage = () => { + const { programKey } = useParams() as { programKey: string } + const searchParams = useSearchParams() + const router = useRouter() + const shouldRefresh = searchParams.get('refresh') === 'true' + + const { data: session } = useSession() + const username = (session as ExtendedSession)?.user?.login + + const [program, setProgram] = useState(null) + const [modules, setModules] = useState([]) + const [isRefetching, setIsRefetching] = useState(false) + + const [updateProgram] = useMutation(UPDATE_PROGRAM_STATUS_MUTATION, { + onError: handleAppError, + }) + + const { + data, + refetch, + loading: isQueryLoading, + } = useQuery(GET_PROGRAM_AND_MODULES, { + variables: { programKey }, + skip: !programKey, + notifyOnNetworkStatusChange: true, + }) + + const isLoading = isQueryLoading || isRefetching + + const isAdmin = useMemo( + () => !!program?.admins?.some((admin) => admin.login === username), + [program, username] + ) + + const canUpdateStatus = useMemo(() => { + if (!isAdmin || !program?.status) return false + return true + }, [isAdmin, program]) + + const updateStatus = async (newStatus: ProgramStatusEnum) => { + if (!program || !isAdmin) { + addToast({ + title: 'Permission Denied', + description: 'Only admins can update the program status.', + variant: 'solid', + color: 'danger', + timeout: 3000, + }) + return + } + + try { + await updateProgram({ + variables: { + inputData: { + key: program.key, + name: program.name, + status: newStatus, + }, + }, + refetchQueries: [{ query: GET_PROGRAM_AND_MODULES, variables: { programKey } }], + }) + + addToast({ + title: `Program status updated to ${upperFirst(newStatus)}`, + description: 'The status has been successfully updated.', + variant: 'solid', + color: 'success', + timeout: 3000, + }) + } catch (err) { + handleAppError(err) + } + } + + useEffect(() => { + const processResult = async () => { + if (shouldRefresh) { + setIsRefetching(true) + try { + await refetch() + } finally { + setIsRefetching(false) + const params = new URLSearchParams(searchParams.toString()) + params.delete('refresh') + const cleaned = params.toString() + router.replace(cleaned ? `?${cleaned}` : window.location.pathname, { scroll: false }) + } + } + + if (data?.getProgram) { + setProgram(data.getProgram) + setModules(data.getProgramModules || []) + } + } + + processResult() + }, [shouldRefresh, data, refetch, router, searchParams]) + + if (isLoading) return + + if (!program && !isLoading) { + return ( + + ) + } + + const programDetails = [ + { label: 'Status', value: upperFirst(program.status) }, + { label: 'Start Date', value: formatDate(program.startedAt) }, + { label: 'End Date', value: formatDate(program.endedAt) }, + { label: 'Mentees Limit', value: String(program.menteesLimit) }, + { + label: 'Experience Levels', + value: program.experienceLevels?.join(', ') || 'N/A', + }, + ] + + return ( + + ) +} + +export default ProgramDetailsPage diff --git a/frontend/src/app/my/mentorship/programs/create/page.tsx b/frontend/src/app/my/mentorship/programs/create/page.tsx new file mode 100644 index 0000000000..3b3c0cbd37 --- /dev/null +++ b/frontend/src/app/my/mentorship/programs/create/page.tsx @@ -0,0 +1,105 @@ +'use client' + +import { useMutation } from '@apollo/client' +import { addToast } from '@heroui/toast' +import { useRouter } from 'next/navigation' +import { useSession } from 'next-auth/react' +import React, { useEffect, useState } from 'react' + +import { CREATE_PROGRAM } from 'server/mutations/programsMutations' +import { ExtendedSession } from 'types/auth' +import { parseCommaSeparated } from 'utils/parser' +import LoadingSpinner from 'components/LoadingSpinner' +import ProgramForm from 'components/ProgramForm' + +const CreateProgramPage = () => { + const router = useRouter() + const { data: session, status } = useSession() + const isProjectLeader = (session as ExtendedSession)?.user.isLeader + + const [redirected, setRedirected] = useState(false) + + const [createProgram, { loading }] = useMutation(CREATE_PROGRAM) + + const [formData, setFormData] = useState({ + name: '', + description: '', + menteesLimit: 5, + startedAt: '', + endedAt: '', + tags: '', + domains: '', + }) + + useEffect(() => { + if (status === 'loading') return + + if (!session || !isProjectLeader) { + addToast({ + title: 'Access Denied', + description: 'You must be project leader to create a program.', + color: 'danger', + variant: 'solid', + timeout: 3000, + shouldShowTimeoutProgress: true, + }) + router.push('/') + setRedirected(true) + } + }, [session, status, router, isProjectLeader]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + try { + const input = { + name: formData.name, + description: formData.description, + menteesLimit: Number(formData.menteesLimit), + startedAt: formData.startedAt, + endedAt: formData.endedAt, + tags: parseCommaSeparated(formData.tags), + domains: parseCommaSeparated(formData.domains), + } + + await createProgram({ variables: { input } }) + + addToast({ + description: 'Program created successfully!', + title: 'Success', + timeout: 3000, + shouldShowTimeoutProgress: true, + color: 'success', + variant: 'solid', + }) + + router.push('/my/mentorship') + } catch (err) { + addToast({ + description: err?.message || 'Unable to complete the requested operation.', + title: 'GraphQL Request Failed', + timeout: 3000, + shouldShowTimeoutProgress: true, + color: 'danger', + variant: 'solid', + }) + } + } + + if (status === 'loading' || !session || redirected) { + return + } + + return ( + + ) +} + +export default CreateProgramPage diff --git a/frontend/src/components/Card.tsx b/frontend/src/components/Card.tsx index e048527872..2c3556967e 100644 --- a/frontend/src/components/Card.tsx +++ b/frontend/src/components/Card.tsx @@ -1,14 +1,17 @@ +import { faCalendar } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { Tooltip } from '@heroui/tooltip' import Link from 'next/link' import FontAwesomeIconWrapper from 'wrappers/FontAwesomeIconWrapper' import type { CardProps } from 'types/card' import { ICONS } from 'utils/data' +import { formatDateRange } from 'utils/dateFormatter' import { getSocialIcon } from 'utils/urlIconMappings' import ActionButton from 'components/ActionButton' import ContributorAvatar from 'components/ContributorAvatar' import DisplayIcon from 'components/DisplayIcon' import Markdown from 'components/MarkdownWrapper' +import ModuleList from 'components/ModuleList' const Card = ({ title, @@ -20,8 +23,10 @@ const Card = ({ button, projectName, projectLink, + modules, social, tooltipLabel, + timeline, }: CardProps) => { return (
@@ -73,6 +78,14 @@ const Card = ({ )}
)} + + {/* Timeline Section (Optional) */} + {timeline?.start && timeline?.end && ( +
+ + {formatDateRange(timeline.start, timeline.end)} +
+ )}
{/* Project name link (if provided) */} @@ -90,7 +103,8 @@ const Card = ({ {/* Project summary */} - {/* Bottom section with social links, contributors and action button */} + {/* Modules section (if available) */} +
{/* Social icons section */} {social && social.length > 0 && ( diff --git a/frontend/src/components/CardDetailsPage.tsx b/frontend/src/components/CardDetailsPage.tsx index 2edfa53bc8..ca88e50724 100644 --- a/frontend/src/components/CardDetailsPage.tsx +++ b/frontend/src/components/CardDetailsPage.tsx @@ -11,6 +11,9 @@ import { import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import upperFirst from 'lodash/upperFirst' import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { useSession } from 'next-auth/react' +import type { ExtendedSession } from 'types/auth' import type { DetailsCardProps } from 'types/card' import { IS_PROJECT_HEALTH_ENABLED } from 'utils/credentials' import { getSocialIcon } from 'utils/urlIconMappings' @@ -21,6 +24,8 @@ import InfoBlock from 'components/InfoBlock' import LeadersList from 'components/LeadersList' import MetricsScoreCircle from 'components/MetricsScoreCircle' import Milestones from 'components/Milestones' +import ModuleCard from 'components/ModuleCard' +import ProgramActions from 'components/ProgramActions' import RecentIssues from 'components/RecentIssues' import RecentPullRequests from 'components/RecentPullRequests' import RecentReleases from 'components/RecentReleases' @@ -33,6 +38,15 @@ import TopContributorsList from 'components/TopContributorsList' const DetailsCard = ({ description, details, + accessLevel, + status, + setStatus, + canUpdateStatus, + tags, + domains, + modules, + mentors, + admins, entityKey, geolocationData = null, healthMetricsData, @@ -55,12 +69,32 @@ const DetailsCard = ({ type, userSummary, }: DetailsCardProps) => { + const { data } = useSession() + const router = useRouter() return (

{title}

+ {type === 'program' && accessLevel === 'admin' && canUpdateStatus && ( + + )} + {type === 'module' && + accessLevel === 'admin' && + admins?.some( + (admin) => admin.login === ((data as ExtendedSession)?.user?.login as string) + ) && ( + + )} {IS_PROJECT_HEALTH_ENABLED && type === 'project' && healthMetricsData.length > 0 && ( @@ -98,7 +132,13 @@ const DetailsCard = ({ } - className={`${type !== 'chapter' ? 'md:col-span-5' : 'md:col-span-3'} gap-2`} + className={ + type === 'program' || type === 'module' + ? 'gap-2 md:col-span-7' + : type !== 'chapter' + ? 'gap-2 md:col-span-5' + : 'gap-2 md:col-span-3' + } > {details?.map((detail) => detail?.label === 'Leaders' ? ( @@ -171,6 +211,28 @@ const DetailsCard = ({ )}
)} + {(type === 'program' || type === 'module') && ( +
+ {tags?.length > 0 && ( + } + isDisabled={true} + /> + )} + {domains?.length > 0 && ( + } + isDisabled={true} + /> + )} +
+ )} {topContributors && ( )} + {admins && admins.length > 0 && type === 'program' && ( + + )} + {mentors && mentors.length > 0 && ( + + )} {(type === 'project' || type === 'repository' || type === 'user' || @@ -213,6 +291,14 @@ const DetailsCard = ({ )} + {type === 'program' && modules.length > 0 && ( + } + > + + + )} {IS_PROJECT_HEALTH_ENABLED && type === 'project' && healthMetricsData.length > 0 && ( )} @@ -230,7 +316,7 @@ const DetailsCard = ({ export default DetailsCard -const SocialLinks = ({ urls }) => { +export const SocialLinks = ({ urls }) => { if (!urls || urls.length === 0) return null return (
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index afc6a1b56a..287e1955b2 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -82,23 +82,30 @@ export default function Header({ isGitHubAuthEnabled }: { readonly isGitHubAuthE {/* Desktop Header Links */}
- {headerLinks.map((link, i) => { - return link.submenu ? ( - - ) : ( - - {link.text} - - ) - })} + {headerLinks + .filter((link) => { + if (link.requiresGitHubAuth) { + return isGitHubAuthEnabled + } + return true + }) + .map((link, i) => { + return link.submenu ? ( + + ) : ( + + {link.text} + + ) + })}
@@ -170,43 +177,50 @@ export default function Header({ isGitHubAuthEnabled }: { readonly isGitHubAuthE
- {headerLinks.map((link) => - link.submenu ? ( -
-
- {link.text} -
-
- {link.submenu.map((sub, i) => ( - - {sub.text} - - ))} + {headerLinks + .filter((link) => { + if (link.requiresGitHubAuth) { + return isGitHubAuthEnabled + } + return true + }) + .map((link) => + link.submenu ? ( +
+
+ {link.text} +
+
+ {link.submenu.map((sub, i) => ( + + {sub.text} + + ))} +
-
- ) : ( - - {link.text} - - ) - )} + ) : ( + + {link.text} + + ) + )}
diff --git a/frontend/src/components/InfoItem.tsx b/frontend/src/components/InfoItem.tsx index 3e8c12532b..9bc10ca295 100644 --- a/frontend/src/components/InfoItem.tsx +++ b/frontend/src/components/InfoItem.tsx @@ -34,4 +34,21 @@ const InfoItem = ({ ) } +export const TextInfoItem = ({ + icon, + label, + value, +}: { + icon: IconDefinition + label: string + value: string +}) => { + return ( +
+ + {label}: {value} +
+ ) +} + export default InfoItem diff --git a/frontend/src/components/ModuleCard.tsx b/frontend/src/components/ModuleCard.tsx new file mode 100644 index 0000000000..7f1e184b9e --- /dev/null +++ b/frontend/src/components/ModuleCard.tsx @@ -0,0 +1,110 @@ +import { + faChevronDown, + faChevronUp, + faLevelUpAlt, + faCalendarAlt, + faHourglassHalf, +} from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import upperFirst from 'lodash/upperFirst' +import { useRouter } from 'next/navigation' +import { useState } from 'react' +import type { Module } from 'types/mentorship' +import { formatDate } from 'utils/dateFormatter' +import { TextInfoItem } from 'components/InfoItem' +import SingleModuleCard from 'components/SingleModuleCard' +import { TruncatedText } from 'components/TruncatedText' + +interface ModuleCardProps { + modules: Module[] + accessLevel?: string + admins?: { login: string }[] +} + +const ModuleCard = ({ modules, accessLevel, admins }: ModuleCardProps) => { + const [showAllModule, setShowAllModule] = useState(false) + + if (modules.length === 1) { + return ( + + ) + } + + const displayedModule = showAllModule ? modules : modules.slice(0, 4) + + return ( +
+
+ {displayedModule.map((module) => { + return + })} +
+ {modules.length > 4 && ( +
+ +
+ )} +
+ ) +} + +const ModuleItem = ({ details }: { details: Module }) => { + const router = useRouter() + const handleClick = () => { + router.push(`${window.location.pathname}/modules/${details.key}`) + } + + return ( +
+ + + + +
+ ) +} + +export default ModuleCard + +export const getSimpleDuration = (start: string, end: string): string => { + const startDate = new Date(start) + const endDate = new Date(end) + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + return 'Invalid duration' + } + + const ms = endDate.getTime() - startDate.getTime() + const days = Math.floor(ms / (1000 * 60 * 60 * 24)) + + const weeks = Math.ceil(days / 7) + return `${weeks} week${weeks !== 1 ? 's' : ''}` +} diff --git a/frontend/src/components/ModuleForm.tsx b/frontend/src/components/ModuleForm.tsx new file mode 100644 index 0000000000..27da71d3f7 --- /dev/null +++ b/frontend/src/components/ModuleForm.tsx @@ -0,0 +1,380 @@ +'use client' + +import { useApolloClient } from '@apollo/client' +import clsx from 'clsx' +import debounce from 'lodash/debounce' +import { useRouter } from 'next/navigation' +import type React from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' +import { SEARCH_PROJECTS } from 'server/queries/projectQueries' + +interface ModuleFormProps { + formData: { + name: string + description: string + experienceLevel: string + startedAt: string + endedAt: string + domains: string + tags: string + projectId: string + projectName: string + mentorLogins: string + } + setFormData: React.Dispatch> + onSubmit: (e: React.FormEvent) => void + loading: boolean + isEdit?: boolean + title: string + submitText?: string +} + +const EXPERIENCE_LEVELS = [ + { key: 'BEGINNER', label: 'Beginner' }, + { key: 'INTERMEDIATE', label: 'Intermediate' }, + { key: 'ADVANCED', label: 'Advanced' }, + { key: 'EXPERT', label: 'Expert' }, +] + +const ModuleForm = ({ + formData, + setFormData, + onSubmit, + loading, + title, + isEdit, + submitText = 'Save', +}: ModuleFormProps) => { + const router = useRouter() + const handleInputChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target + setFormData((prev) => ({ ...prev, [name]: value })) + } + + return ( +
+
+

{title}

+
+ +
+
+
+
+

+ Module Information +

+
+
+ + +
+ +
+ +