diff --git a/.github/ansible/.ansible-lint.yaml b/.github/ansible/.ansible-lint.yaml new file mode 100644 index 0000000000..b89a3744d8 --- /dev/null +++ b/.github/ansible/.ansible-lint.yaml @@ -0,0 +1 @@ +profile: production diff --git a/.github/ansible/production/nest.yaml b/.github/ansible/production/nest.yaml index 90f9594ca2..b727088a9e 100644 --- a/.github/ansible/production/nest.yaml +++ b/.github/ansible/production/nest.yaml @@ -1,76 +1,90 @@ - - name: Deploy Nest to Production - hosts: production_nest - tasks: - - name: Copy docker-compose.yaml - ansible.builtin.copy: - src: '{{ github_workspace }}/docker-compose/production/compose.yaml' - dest: ~/docker-compose.yaml - mode: '0644' +- name: Deploy Nest to Production + hosts: production_nest + tasks: + - name: Copy docker-compose.yaml + ansible.builtin.copy: + src: '{{ github_workspace }}/docker-compose/production/compose.yaml' + dest: ~/docker-compose.yaml + mode: '0644' - - name: Sync Makefile structure - ansible.builtin.synchronize: - src: '{{ github_workspace }}/' - dest: '~/' - recursive: yes - rsync_opts: - - '--include=*/' - - '--include=Makefile' - - '--include=*/Makefile' - - '--include=*/**/Makefile' - - '--include=*/**/**/Makefile' - - '--exclude=*' + - name: Sync Makefile structure + ansible.posix.synchronize: + src: '{{ github_workspace }}/' + dest: '~/' + recursive: true + rsync_opts: + - '--include=*/' + - '--include=Makefile' + - '--include=*/Makefile' + - '--include=*/**/Makefile' + - '--include=*/**/**/Makefile' + - '--exclude=*' - - name: Update Makefiles for production environment - shell: | - sed -i '/e2e-\|fuzz-/! s/\bnest-backend\b/production-nest-backend/g' ~/backend/Makefile - sed -i '/e2e-\|fuzz-/! s/\bnest-db\b/production-nest-db/g' ~/backend/Makefile - sed -i 's/\bnest-frontend\b/production-nest-frontend/g' ~/frontend/Makefile + - name: Update Makefiles for production environment + ansible.builtin.command: + argv: + - sed + - -i + - "{{ item.sed_expr }}" + - "{{ item.path }}" + loop: + - sed_expr: '/e2e-\|fuzz-/! s/\bnest-backend\b/production-nest-backend/g' + path: '{{ ansible_env.HOME }}/backend/Makefile' + - sed_expr: '/e2e-\|fuzz-/! s/\bnest-db\b/production-nest-db/g' + path: '{{ ansible_env.HOME }}/backend/Makefile' + - sed_expr: 's/\bnest-frontend\b/production-nest-frontend/g' + path: '{{ ansible_env.HOME }}/frontend/Makefile' + changed_when: false - - name: Copy secrets - copy: - src: '{{ github_workspace }}/{{ item }}' - dest: ~/ - mode: '0400' - loop: - - .env.backend - - .env.cache - - .env.db - - .env.frontend - - .github.pem + - name: Copy secrets + ansible.builtin.copy: + src: '{{ github_workspace }}/{{ item }}' + dest: ~/ + mode: '0400' + loop: + - .env.backend + - .env.cache + - .env.db + - .env.frontend + - .github.pem - - name: Clean up secrets - delegate_to: localhost - file: - path: '{{ github_workspace }}/{{ item }}' - state: absent - loop: - - .env.backend - - .env.cache - - .env.db - - .env.frontend - - .github.pem - run_once: true + - name: Clean up secrets + delegate_to: localhost + ansible.builtin.file: + path: '{{ github_workspace }}/{{ item }}' + state: absent + loop: + - .env.backend + - .env.cache + - .env.db + - .env.frontend + - .github.pem - - name: Copy crontab - copy: - src: '{{ github_workspace }}/cron/production' - dest: /tmp/production_crontab - mode: '0600' + - name: Copy crontab + ansible.builtin.copy: + src: '{{ github_workspace }}/cron/production' + dest: /tmp/production_crontab + mode: '0600' - - name: Install crontab - ansible.builtin.command: - cmd: crontab /tmp/production_crontab + - name: Install crontab + ansible.builtin.command: + cmd: crontab /tmp/production_crontab + changed_when: false - - name: Restart services - shell: - cmd: docker compose up -d --pull always + - name: Restart services + ansible.builtin.command: + cmd: docker compose up -d --pull always + changed_when: false - - name: Prune docker images - shell: - cmd: docker image prune -f + - name: Prune docker images + ansible.builtin.command: + cmd: docker image prune -f + changed_when: false - - name: Index data - async: 1800 # 30 minutes - poll: 0 - shell: | - make index-data > /var/log/nest/production/index-data.log 2>&1 + - name: Index data + async: 1800 # 30 minutes + poll: 0 + # Shell required for stdout/stderr redirect to log file. + ansible.builtin.shell: | + make index-data > /var/log/nest/production/index-data.log 2>&1 + changed_when: false diff --git a/.github/ansible/production/proxy.yaml b/.github/ansible/production/proxy.yaml index 0bf1bebccd..5c06a7200e 100644 --- a/.github/ansible/production/proxy.yaml +++ b/.github/ansible/production/proxy.yaml @@ -2,7 +2,7 @@ hosts: production_nest_proxy tasks: - name: Copy proxy configuration files - copy: + ansible.builtin.copy: src: '{{ github_workspace }}/proxy/{{ item }}' dest: ~/ mode: '0644' @@ -15,15 +15,26 @@ - redirects.conf - name: Copy docker compose file - copy: + ansible.builtin.copy: src: '{{ github_workspace }}/docker-compose/proxy/compose.yaml' dest: ~/docker-compose.yaml mode: '0644' + - name: Pull and start services + ansible.builtin.command: + cmd: docker compose up -d --pull always + args: + chdir: "{{ ansible_env.HOME }}" + changed_when: false + - name: Restart services - shell: - cmd: docker compose up -d --pull always && docker compose restart + ansible.builtin.command: + cmd: docker compose restart + args: + chdir: "{{ ansible_env.HOME }}" + changed_when: false - name: Prune docker images - shell: + ansible.builtin.command: cmd: docker image prune -f + changed_when: false diff --git a/.github/ansible/requirements.yml b/.github/ansible/requirements.yml new file mode 100644 index 0000000000..e3639a122c --- /dev/null +++ b/.github/ansible/requirements.yml @@ -0,0 +1,2 @@ +collections: + - name: ansible.posix diff --git a/.github/workflows/check-pr-issue-skip-usernames.txt b/.github/workflows/check-pr-issue-skip-usernames.txt index 2b0b3285f1..15b9d21ab2 100644 --- a/.github/workflows/check-pr-issue-skip-usernames.txt +++ b/.github/workflows/check-pr-issue-skip-usernames.txt @@ -172,6 +172,7 @@ edwingozeling eivindarvesen elarlang elvinmollinedo +emaybu emergar07 emomartin-owasp epabloseven diff --git a/.github/workflows/run-ci-cd.yaml b/.github/workflows/run-ci-cd.yaml index 21dbcc6155..09d91bd1da 100644 --- a/.github/workflows/run-ci-cd.yaml +++ b/.github/workflows/run-ci-cd.yaml @@ -1201,9 +1201,13 @@ jobs: "$NEST_GITHUB_APP_PRIVATE_KEY" EOF - - name: Run Nest deploy + - name: Install Ansible collections + run: ansible-galaxy collection install -r requirements.yml working-directory: .github/ansible + + - name: Run Nest deploy run: ansible-playbook -i inventory.yaml production/nest.yaml -e "github_workspace=$GITHUB_WORKSPACE" + working-directory: .github/ansible timeout-minutes: 5 deploy-production-nest-proxy: @@ -1237,9 +1241,13 @@ jobs: $PROXY_SSH_PRIVATE_KEY EOF - - name: Run proxy deploy + - name: Install Ansible collections + run: ansible-galaxy collection install -r requirements.yml working-directory: .github/ansible + + - name: Run proxy deploy run: ansible-playbook -i inventory.yaml production/proxy.yaml -e "github_workspace=$GITHUB_WORKSPACE" + working-directory: .github/ansible timeout-minutes: 5 run-production-zap-baseline-scan: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2a0390f585..0368950645 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,6 +9,20 @@ repos: - --strict exclude: (.github|pnpm-lock.yaml) + - repo: https://github.com/ansible/ansible-lint + rev: v26.1.1 + hooks: + - id: ansible-lint + additional_dependencies: + - ansible-core + args: + - -c + - .github/ansible/.ansible-lint.yaml + - .github/ansible + files: ^\.github/ansible/.*\.ya?ml$ + language_version: python3 + pass_filenames: true + - repo: https://github.com/antonbabenko/pre-commit-terraform rev: v1.104.0 hooks: @@ -94,6 +108,6 @@ repos: exclude: pnpm-lock.yaml - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.12.1 + rev: v2.14.2 hooks: - id: pyproject-fmt diff --git a/MENTORS.md b/MENTORS.md index 7e93159fcf..448953a419 100644 --- a/MENTORS.md +++ b/MENTORS.md @@ -20,6 +20,14 @@ Senior DevOps Engineer | OWASP GenAI Data Security Risk and Mitigations Initiati Illia is a Senior DevOps Engineer with experience building and operating large-scale systems, with a focus on infrastructure, automation, reliability, and security. +### Ime Iyonsi + +Software Engineer | Application Security (in transition) + +[GitHub](https://github.com/emaybu/) • [LinkedIn](https://linkedin.com/in/imeiyonsi/) • [Slack](https://owasp.slack.com/team/U06H942K9UY) • [United States (Eastern Time)](https://time-time.net/times/time-zones/usa-canada/current-eastern-time-est.php) + +Ime (Emay) is a software engineer with experience building and supporting production applications. She is currently focusing more on application security, with interests in secure coding and application security fundamentals. She enjoys supporting and sharing knowledge with folks as they navigate real-world open-source projects. + ### Kate Golovanova Senior Software Engineer at Skill Struck, CC | OWASP Nest Project Leader @@ -60,14 +68,6 @@ Senior GRC & Technology Risk Leader, CISSP Marie is a cybersecurity professional focused on Governance, Risk, and Compliance. She enjoys helping people navigate the sometimes confusing world of GRC, whether they are just starting out, prepping for their CISSP, or tackling real-world risk management challenges. She is happy to share what she has learned, answer questions, and help others build practical skills. She welcomes outreach on LinkedIn or Slack and is always happy to chat about security, risk, or career paths in this field. -### Muhammad Salman - -Software Developer | GSoC'25 contributor at FOSSology - -[GitHub](https://github.com/SalmanDeveloperz/) • [LinkedIn](https://www.linkedin.com/in/msalman199/) • [Slack](https://owasp.slack.com/team/U0A7YV0SPNE) • [Pakistan (Pakistan Standard Time)](https://time-time.net/time/islamabad-pakistan.php) - -Muhammad Salman is a Linux-focused software engineer and GSoC'25 contributor at FOSSology (Linux Foundation). He works on infrastructure, backend reliability, and production debugging in cloud-native systems. For GSoC 2026, his focus is infrastructure reliability, observability, and operational best practices. - ### Noland Crane Application Security Analyst at Bloomreach, CISSP diff --git a/backend/apps/api/rest/v0/committee.py b/backend/apps/api/rest/v0/committee.py index d14a9cbf0b..887d289358 100644 --- a/backend/apps/api/rest/v0/committee.py +++ b/backend/apps/api/rest/v0/committee.py @@ -78,11 +78,11 @@ def list_committees( summary="Get committee", ) @decorate_view(cache_response()) -def get_chapter( +def get_committee( request: HttpRequest, committee_id: str = Path(example="project"), ) -> CommitteeDetail | CommitteeError: - """Get chapter.""" + """Get committee.""" if committee := CommitteeModel.active_committees.filter( is_active=True, key__iexact=( diff --git a/backend/apps/api/rest/v0/structured_search.py b/backend/apps/api/rest/v0/structured_search.py index 5e9f3a87dd..3d39ff2f4c 100644 --- a/backend/apps/api/rest/v0/structured_search.py +++ b/backend/apps/api/rest/v0/structured_search.py @@ -59,7 +59,7 @@ def apply_structured_search( } try: - parser = QueryParser(field_schema=parser_schema, strict=False) + parser = QueryParser(case_sensitive=True, field_schema=parser_schema, strict=False) conditions = parser.parse(query) except QueryParserError: # Fail safely diff --git a/backend/apps/common/eleven_labs.py b/backend/apps/common/eleven_labs.py index e81313980b..a2b401b6f9 100644 --- a/backend/apps/common/eleven_labs.py +++ b/backend/apps/common/eleven_labs.py @@ -23,10 +23,10 @@ def __init__( model_id: str = "eleven_multilingual_v2", output_format: str = "mp3_44100_128", similarity_boost: float = 0.75, - speed: float = 1.0, + speed: float = 0.85, stability: float = 0.5, style: float = 0.0, - voice_id: str = "1SM7GgM6IMuvQlz2BwM3", # cspell:disable-line + voice_id: str = "TX3LPaxmHKxFdv7VOQHJ", # Liam # cspell:disable-line *, use_speaker_boost: bool = True, ) -> None: @@ -36,7 +36,7 @@ def __init__( model_id (str): The model to use. output_format (str): Audio output format. similarity_boost (float): Voice consistency (0.0-1.0). - speed (float): Speech speed (0.25-4.0, default 1.0). + speed (float): Speech speed (0.25-4.0). stability (float): Voice stability (0.0-1.0). style (float): Style exaggeration (0.0-1.0). use_speaker_boost (bool): Enable speaker clarity boost. diff --git a/backend/apps/core/api/internal/algolia.py b/backend/apps/core/api/internal/algolia.py index c5cccdcf01..d6d20acf00 100644 --- a/backend/apps/core/api/internal/algolia.py +++ b/backend/apps/core/api/internal/algolia.py @@ -53,6 +53,9 @@ def algolia_search(request: HttpRequest) -> JsonResponse | HttpResponseNotAllowe query = data.get("query", "") cache_key = f"{CACHE_PREFIX}:{index_name}:{query}:{page}:{limit}" + if facet_filters: + cache_key = f"{cache_key}:{json.dumps(facet_filters, sort_keys=True)}" + if index_name == "chapters": cache_key = f"{cache_key}:{ip_address}" diff --git a/backend/apps/github/api/internal/nodes/issue.py b/backend/apps/github/api/internal/nodes/issue.py index 7f0c5be076..bfc7591b49 100644 --- a/backend/apps/github/api/internal/nodes/issue.py +++ b/backend/apps/github/api/internal/nodes/issue.py @@ -2,13 +2,22 @@ import strawberry import strawberry_django -from django.db.models import Exists, OuterRef +from django.db.models import Prefetch from apps.github.api.internal.nodes.pull_request import PullRequestNode from apps.github.api.internal.nodes.user import UserNode from apps.github.models.issue import Issue from apps.github.models.pull_request import PullRequest +MERGED_PULL_REQUESTS_PREFETCH = Prefetch( + "pull_requests", + queryset=PullRequest.objects.filter( + merged_at__isnull=False, + state="closed", + ), + to_attr="merged_pull_requests", +) + @strawberry_django.type( Issue, @@ -47,20 +56,10 @@ def labels(self, root: Issue) -> list[str]: """Resolve label names for the issue.""" return [label.name for label in root.labels.all()] - @strawberry_django.field( - annotate={ - "is_merged": Exists( - PullRequest.objects.filter( - merged_at__isnull=False, - related_issues=OuterRef("pk"), - state="closed", - ) - ) - } - ) + @strawberry_django.field(prefetch_related=[MERGED_PULL_REQUESTS_PREFETCH]) def is_merged(self, root: Issue) -> bool: """Return True if this issue has at least one merged pull request.""" - return root.is_merged + return bool(getattr(root, "merged_pull_requests", None)) @strawberry_django.field(prefetch_related=["participant_interests__user"]) def interested_users(self, root: Issue) -> list[UserNode]: diff --git a/backend/apps/mentorship/api/internal/nodes/module.py b/backend/apps/mentorship/api/internal/nodes/module.py index 56edf42a32..ead2c53f37 100644 --- a/backend/apps/mentorship/api/internal/nodes/module.py +++ b/backend/apps/mentorship/api/internal/nodes/module.py @@ -5,7 +5,7 @@ import strawberry from apps.common.utils import normalize_limit -from apps.github.api.internal.nodes.issue import IssueNode +from apps.github.api.internal.nodes.issue import MERGED_PULL_REQUESTS_PREFETCH, IssueNode from apps.github.api.internal.nodes.pull_request import PullRequestNode from apps.github.api.internal.nodes.user import UserNode from apps.github.models import Label @@ -86,7 +86,9 @@ def issues( return [] queryset = self.issues.select_related("repository", "author").prefetch_related( - "assignees", "labels" + "assignees", + "labels", + MERGED_PULL_REQUESTS_PREFETCH, ) if label and label != "all": @@ -120,7 +122,11 @@ def issue_by_number(self, number: int) -> IssueNode | None: """Return a single issue by its GitHub number within this module's linked issues.""" return ( self.issues.select_related("repository", "author") - .prefetch_related("assignees", "labels") + .prefetch_related( + "assignees", + "labels", + MERGED_PULL_REQUESTS_PREFETCH, + ) .filter(number=number) .first() ) diff --git a/backend/apps/mentorship/api/internal/queries/mentorship.py b/backend/apps/mentorship/api/internal/queries/mentorship.py index 970b8b6622..3246db0044 100644 --- a/backend/apps/mentorship/api/internal/queries/mentorship.py +++ b/backend/apps/mentorship/api/internal/queries/mentorship.py @@ -9,7 +9,7 @@ from django.db.models import Prefetch from apps.common.utils import normalize_limit -from apps.github.api.internal.nodes.issue import IssueNode +from apps.github.api.internal.nodes.issue import MERGED_PULL_REQUESTS_PREFETCH, IssueNode from apps.github.models import Label from apps.github.models.user import User as GithubUser from apps.mentorship.api.internal.nodes.mentee import MenteeNode @@ -119,11 +119,12 @@ def get_mentee_module_issues( module.issues.filter(assignees=github_user) .only("id", "number", "title", "state", "created_at", "url") .prefetch_related( - Prefetch("labels", queryset=Label.objects.only("id", "name")), Prefetch( "assignees", queryset=GithubUser.objects.only("id", "login", "name", "avatar_url"), ), + Prefetch("labels", queryset=Label.objects.only("id", "name")), + MERGED_PULL_REQUESTS_PREFETCH, ) .order_by("-created_at") ) diff --git a/backend/apps/owasp/api/internal/nodes/project.py b/backend/apps/owasp/api/internal/nodes/project.py index b6f2335c53..cc1faf6b4f 100644 --- a/backend/apps/owasp/api/internal/nodes/project.py +++ b/backend/apps/owasp/api/internal/nodes/project.py @@ -53,11 +53,15 @@ def contribution_stats(self, root: Project) -> strawberry.scalars.JSON | None: def health_metrics_list( self, root: Project, limit: int = 30 ) -> list[ProjectHealthMetricsNode]: - """Resolve project health metrics.""" + """Resolve project health metrics for chart display. + + Returns the N most recent metrics in chronological order (oldest to newest) + so charts display correctly from left to right. + """ if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: return [] - return root.health_metrics.order_by("nest_created_at")[:normalized_limit] + return list(reversed(root.health_metrics.order_by("-nest_created_at")[:normalized_limit])) @strawberry_django.field(prefetch_related=["health_metrics"]) def health_metrics_latest(self, root: Project) -> ProjectHealthMetricsNode | None: diff --git a/backend/apps/slack/management/commands/slack_sync_messages.py b/backend/apps/slack/management/commands/slack_sync_messages.py index 2b0c48e136..81960cdcae 100644 --- a/backend/apps/slack/management/commands/slack_sync_messages.py +++ b/backend/apps/slack/management/commands/slack_sync_messages.py @@ -243,6 +243,7 @@ def _sync_user_messages( ) self._handle_slack_response(response, "search_messages") + retry_count = 0 messages_data = response.get("messages", {}).get("matches", []) if not messages_data: @@ -399,9 +400,9 @@ def _fetch_messages( """Fetch all parent messages (non-thread) for a conversation.""" cursor = None has_more = True + retry_count = 0 while has_more: try: - retry_count = 0 response = client.conversations_history( channel=conversation.slack_channel_id, cursor=cursor, @@ -410,6 +411,7 @@ def _fetch_messages( ) self._handle_slack_response(response, "conversations_history") + retry_count = 0 messages = [ message @@ -475,8 +477,8 @@ def _fetch_replies( try: cursor = None has_more = True + retry_count = 0 while has_more: - retry_count = 0 try: params = { "channel": message.conversation.slack_channel_id, @@ -490,6 +492,7 @@ def _fetch_replies( } response = client.conversations_replies(**params) self._handle_slack_response(response, "conversations_replies") + retry_count = 0 messages = response.get("messages", []) if not messages: diff --git a/backend/data/nest.dump b/backend/data/nest.dump index 34f4fd6ec3..2fa457e6d9 100644 Binary files a/backend/data/nest.dump and b/backend/data/nest.dump differ diff --git a/backend/generated_videos/community_snapshot_video_2025/2025_snapshot.mkv b/backend/generated_videos/community_snapshot_video_2025/2025_snapshot.mkv deleted file mode 100644 index e56e51725d..0000000000 Binary files a/backend/generated_videos/community_snapshot_video_2025/2025_snapshot.mkv and /dev/null differ diff --git a/backend/poetry.lock b/backend/poetry.lock index f6ecc3de9c..c51c6e1b93 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -306,18 +306,18 @@ wrapt = "*" [[package]] name = "boto3" -version = "1.42.44" +version = "1.42.45" description = "The AWS SDK for Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "boto3-1.42.44-py3-none-any.whl", hash = "sha256:32e995b0d56e19422cff22f586f698e8924c792eb00943de9c517ff4607e4e18"}, - {file = "boto3-1.42.44.tar.gz", hash = "sha256:d5601ea520d30674c1d15791a1f98b5c055e973c775e1d9952ccc09ee5913c4e"}, + {file = "boto3-1.42.45-py3-none-any.whl", hash = "sha256:5074e074a718a6f3c2b519cbb9ceab258f17b331a143d23351d487984f2a412f"}, + {file = "boto3-1.42.45.tar.gz", hash = "sha256:4db50b8b39321fab87ff7f40ab407887d436d004c1f2b0dfdf56e42b4884709b"}, ] [package.dependencies] -botocore = ">=1.42.44,<1.43.0" +botocore = ">=1.42.45,<1.43.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.16.0,<0.17.0" @@ -326,14 +326,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.42.44" +version = "1.42.45" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "botocore-1.42.44-py3-none-any.whl", hash = "sha256:ba406b9243a20591ee87d53abdb883d46416705cebccb639a7f1c923f9dd82df"}, - {file = "botocore-1.42.44.tar.gz", hash = "sha256:47ba27360f2afd2c2721545d8909217f7be05fdee16dd8fc0b09589535a0701c"}, + {file = "botocore-1.42.45-py3-none-any.whl", hash = "sha256:a5ea5d1b7c46c2d5d113879e45b21eaf7d60dc865f4bcb46dfcf0703fe3429f4"}, + {file = "botocore-1.42.45.tar.gz", hash = "sha256:40b577d07b91a0ed26879da9e4658d82d3a400382446af1014d6ad3957497545"}, ] [package.dependencies] @@ -759,104 +759,118 @@ markers = {main = "platform_system == \"Windows\"", test = "sys_platform == \"wi [[package]] name = "coverage" -version = "7.13.3" +version = "7.13.4" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" groups = ["test"] files = [ - {file = "coverage-7.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b4f345f7265cdbdb5ec2521ffff15fa49de6d6c39abf89fc7ad68aa9e3a55f0"}, - {file = "coverage-7.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:96c3be8bae9d0333e403cc1a8eb078a7f928b5650bae94a18fb4820cc993fb9b"}, - {file = "coverage-7.13.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d6f4a21328ea49d38565b55599e1c02834e76583a6953e5586d65cb1efebd8f8"}, - {file = "coverage-7.13.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fc970575799a9d17d5c3fafd83a0f6ccf5d5117cdc9ad6fbd791e9ead82418b0"}, - {file = "coverage-7.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ff33b652b3556b05e204ae20793d1f872161b0fa5ec8a9ac76f8430e152ed6"}, - {file = "coverage-7.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7df8759ee57b9f3f7b66799b7660c282f4375bef620ade1686d6a7b03699e75f"}, - {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f45c9bcb16bee25a798ccba8a2f6a1251b19de6a0d617bb365d7d2f386c4e20e"}, - {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:318b2e4753cbf611061e01b6cc81477e1cdfeb69c36c4a14e6595e674caadb56"}, - {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:24db3959de8ee394eeeca89ccb8ba25305c2da9a668dd44173394cbd5aa0777f"}, - {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:be14d0622125edef21b3a4d8cd2d138c4872bf6e38adc90fd92385e3312f406a"}, - {file = "coverage-7.13.3-cp310-cp310-win32.whl", hash = "sha256:53be4aab8ddef18beb6188f3a3fdbf4d1af2277d098d4e618be3a8e6c88e74be"}, - {file = "coverage-7.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:bfeee64ad8b4aae3233abb77eb6b52b51b05fa89da9645518671b9939a78732b"}, - {file = "coverage-7.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5907605ee20e126eeee2abe14aae137043c2c8af2fa9b38d2ab3b7a6b8137f73"}, - {file = "coverage-7.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a88705500988c8acad8b8fd86c2a933d3aa96bec1ddc4bc5cb256360db7bbd00"}, - {file = "coverage-7.13.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bbb5aa9016c4c29e3432e087aa29ebee3f8fda089cfbfb4e6d64bd292dcd1c2"}, - {file = "coverage-7.13.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c2be202a83dde768937a61cdc5d06bf9fb204048ca199d93479488e6247656c"}, - {file = "coverage-7.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f45e32ef383ce56e0ca099b2e02fcdf7950be4b1b56afaab27b4ad790befe5b"}, - {file = "coverage-7.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ed2e787249b922a93cd95c671cc9f4c9797a106e81b455c83a9ddb9d34590c0"}, - {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:05dd25b21afffe545e808265897c35f32d3e4437663923e0d256d9ab5031fb14"}, - {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:46d29926349b5c4f1ea4fca95e8c892835515f3600995a383fa9a923b5739ea4"}, - {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fae6a21537519c2af00245e834e5bf2884699cc7c1055738fd0f9dc37a3644ad"}, - {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c672d4e2f0575a4ca2bf2aa0c5ced5188220ab806c1bb6d7179f70a11a017222"}, - {file = "coverage-7.13.3-cp311-cp311-win32.whl", hash = "sha256:fcda51c918c7a13ad93b5f89a58d56e3a072c9e0ba5c231b0ed81404bf2648fb"}, - {file = "coverage-7.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:d1a049b5c51b3b679928dd35e47c4a2235e0b6128b479a7596d0ef5b42fa6301"}, - {file = "coverage-7.13.3-cp311-cp311-win_arm64.whl", hash = "sha256:79f2670c7e772f4917895c3d89aad59e01f3dbe68a4ed2d0373b431fad1dcfba"}, - {file = "coverage-7.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ed48b4170caa2c4420e0cd27dc977caaffc7eecc317355751df8373dddcef595"}, - {file = "coverage-7.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8f2adf4bcffbbec41f366f2e6dffb9d24e8172d16e91da5799c9b7ed6b5716e6"}, - {file = "coverage-7.13.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01119735c690786b6966a1e9f098da4cd7ca9174c4cfe076d04e653105488395"}, - {file = "coverage-7.13.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8bb09e83c603f152d855f666d70a71765ca8e67332e5829e62cb9466c176af23"}, - {file = "coverage-7.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b607a40cba795cfac6d130220d25962931ce101f2f478a29822b19755377fb34"}, - {file = "coverage-7.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f14a62f5da2e9aedf9080e01d2cda61df39197d48e323538ec037336d68da8"}, - {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:debf29e0b157769843dff0981cc76f79e0ed04e36bb773c6cac5f6029054bd8a"}, - {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:824bb95cd71604031ae9a48edb91fd6effde669522f960375668ed21b36e3ec4"}, - {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8f1010029a5b52dc427c8e2a8dbddb2303ddd180b806687d1acd1bb1d06649e7"}, - {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cd5dee4fd7659d8306ffa79eeaaafd91fa30a302dac3af723b9b469e549247e0"}, - {file = "coverage-7.13.3-cp312-cp312-win32.whl", hash = "sha256:f7f153d0184d45f3873b3ad3ad22694fd73aadcb8cdbc4337ab4b41ea6b4dff1"}, - {file = "coverage-7.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:03a6e5e1e50819d6d7436f5bc40c92ded7e484e400716886ac921e35c133149d"}, - {file = "coverage-7.13.3-cp312-cp312-win_arm64.whl", hash = "sha256:51c4c42c0e7d09a822b08b6cf79b3c4db8333fffde7450da946719ba0d45730f"}, - {file = "coverage-7.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:853c3d3c79ff0db65797aad79dee6be020efd218ac4510f15a205f1e8d13ce25"}, - {file = "coverage-7.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f75695e157c83d374f88dcc646a60cb94173304a9258b2e74ba5a66b7614a51a"}, - {file = "coverage-7.13.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d098709621d0819039f3f1e471ee554f55a0b2ac0d816883c765b14129b5627"}, - {file = "coverage-7.13.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16d23d6579cf80a474ad160ca14d8b319abaa6db62759d6eef53b2fc979b58c8"}, - {file = "coverage-7.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00d34b29a59d2076e6f318b30a00a69bf63687e30cd882984ed444e753990cc1"}, - {file = "coverage-7.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab6d72bffac9deb6e6cb0f61042e748de3f9f8e98afb0375a8e64b0b6e11746b"}, - {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e129328ad1258e49cae0123a3b5fcb93d6c2fa90d540f0b4c7cdcdc019aaa3dc"}, - {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2213a8d88ed35459bda71597599d4eec7c2ebad201c88f0bfc2c26fd9b0dd2ea"}, - {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:00dd3f02de6d5f5c9c3d95e3e036c3c2e2a669f8bf2d3ceb92505c4ce7838f67"}, - {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9bada7bc660d20b23d7d312ebe29e927b655cf414dadcdb6335a2075695bd86"}, - {file = "coverage-7.13.3-cp313-cp313-win32.whl", hash = "sha256:75b3c0300f3fa15809bd62d9ca8b170eb21fcf0100eb4b4154d6dc8b3a5bbd43"}, - {file = "coverage-7.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:a2f7589c6132c44c53f6e705e1a6677e2b7821378c22f7703b2cf5388d0d4587"}, - {file = "coverage-7.13.3-cp313-cp313-win_arm64.whl", hash = "sha256:123ceaf2b9d8c614f01110f908a341e05b1b305d6b2ada98763b9a5a59756051"}, - {file = "coverage-7.13.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc7fd0f726795420f3678ac82ff882c7fc33770bd0074463b5aef7293285ace9"}, - {file = "coverage-7.13.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d358dc408edc28730aed5477a69338e444e62fba0b7e9e4a131c505fadad691e"}, - {file = "coverage-7.13.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d67b9ed6f7b5527b209b24b3df9f2e5bf0198c1bbf99c6971b0e2dcb7e2a107"}, - {file = "coverage-7.13.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59224bfb2e9b37c1335ae35d00daa3a5b4e0b1a20f530be208fff1ecfa436f43"}, - {file = "coverage-7.13.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9306b5299e31e31e0d3b908c66bcb6e7e3ddca143dea0266e9ce6c667346d3"}, - {file = "coverage-7.13.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:343aaeb5f8bb7bcd38620fd7bc56e6ee8207847d8c6103a1e7b72322d381ba4a"}, - {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2182129f4c101272ff5f2f18038d7b698db1bf8e7aa9e615cb48440899ad32e"}, - {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:94d2ac94bd0cc57c5626f52f8c2fffed1444b5ae8c9fc68320306cc2b255e155"}, - {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:65436cde5ecabe26fb2f0bf598962f0a054d3f23ad529361326ac002c61a2a1e"}, - {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db83b77f97129813dbd463a67e5335adc6a6a91db652cc085d60c2d512746f96"}, - {file = "coverage-7.13.3-cp313-cp313t-win32.whl", hash = "sha256:dfb428e41377e6b9ba1b0a32df6db5409cb089a0ed1d0a672dc4953ec110d84f"}, - {file = "coverage-7.13.3-cp313-cp313t-win_amd64.whl", hash = "sha256:5badd7e596e6b0c89aa8ec6d37f4473e4357f982ce57f9a2942b0221cd9cf60c"}, - {file = "coverage-7.13.3-cp313-cp313t-win_arm64.whl", hash = "sha256:989aa158c0eb19d83c76c26f4ba00dbb272485c56e452010a3450bdbc9daafd9"}, - {file = "coverage-7.13.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6f6169bbdbdb85aab8ac0392d776948907267fcc91deeacf6f9d55f7a83ae3b"}, - {file = "coverage-7.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f5e731627a3d5ef11a2a35aa0c6f7c435867c7ccbc391268eb4f2ca5dbdcc10"}, - {file = "coverage-7.13.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9db3a3285d91c0b70fab9f39f0a4aa37d375873677efe4e71e58d8321e8c5d39"}, - {file = "coverage-7.13.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06e49c5897cb12e3f7ecdc111d44e97c4f6d0557b81a7a0204ed70a8b038f86f"}, - {file = "coverage-7.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb25061a66802df9fc13a9ba1967d25faa4dae0418db469264fd9860a921dde4"}, - {file = "coverage-7.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:99fee45adbb1caeb914da16f70e557fb7ff6ddc9e4b14de665bd41af631367ef"}, - {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:318002f1fd819bdc1651c619268aa5bc853c35fa5cc6d1e8c96bd9cd6c828b75"}, - {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:71295f2d1d170b9977dc386d46a7a1b7cbb30e5405492529b4c930113a33f895"}, - {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b1ad2e0dc672625c44bc4fe34514602a9fd8b10d52ddc414dc585f74453516c"}, - {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2beb64c145593a50d90db5c7178f55daeae129123b0d265bdb3cbec83e5194a"}, - {file = "coverage-7.13.3-cp314-cp314-win32.whl", hash = "sha256:3d1aed4f4e837a832df2f3b4f68a690eede0de4560a2dbc214ea0bc55aabcdb4"}, - {file = "coverage-7.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f9efbbaf79f935d5fbe3ad814825cbce4f6cdb3054384cb49f0c0f496125fa0"}, - {file = "coverage-7.13.3-cp314-cp314-win_arm64.whl", hash = "sha256:31b6e889c53d4e6687ca63706148049494aace140cffece1c4dc6acadb70a7b3"}, - {file = "coverage-7.13.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c5e9787cec750793a19a28df7edd85ac4e49d3fb91721afcdc3b86f6c08d9aa8"}, - {file = "coverage-7.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5b86db331c682fd0e4be7098e6acee5e8a293f824d41487c667a93705d415ca"}, - {file = "coverage-7.13.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:edc7754932682d52cf6e7a71806e529ecd5ce660e630e8bd1d37109a2e5f63ba"}, - {file = "coverage-7.13.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3a16d6398666510a6886f67f43d9537bfd0e13aca299688a19daa84f543122f"}, - {file = "coverage-7.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:303d38b19626c1981e1bb067a9928236d88eb0e4479b18a74812f05a82071508"}, - {file = "coverage-7.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:284e06eadfe15ddfee2f4ee56631f164ef897a7d7d5a15bca5f0bb88889fc5ba"}, - {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d401f0864a1d3198422816878e4e84ca89ec1c1bf166ecc0ae01380a39b888cd"}, - {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3f379b02c18a64de78c4ccdddf1c81c2c5ae1956c72dacb9133d7dd7809794ab"}, - {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7a482f2da9086971efb12daca1d6547007ede3674ea06e16d7663414445c683e"}, - {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:562136b0d401992118d9b49fbee5454e16f95f85b120a4226a04d816e33fe024"}, - {file = "coverage-7.13.3-cp314-cp314t-win32.whl", hash = "sha256:ca46e5c3be3b195098dd88711890b8011a9fa4feca942292bb84714ce5eab5d3"}, - {file = "coverage-7.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:06d316dbb3d9fd44cca05b2dbcfbef22948493d63a1f28e828d43e6cc505fed8"}, - {file = "coverage-7.13.3-cp314-cp314t-win_arm64.whl", hash = "sha256:299d66e9218193f9dc6e4880629ed7c4cd23486005166247c283fb98531656c3"}, - {file = "coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910"}, - {file = "coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac"}, + {file = "coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415"}, + {file = "coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b"}, + {file = "coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a"}, + {file = "coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f"}, + {file = "coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012"}, + {file = "coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def"}, + {file = "coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256"}, + {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda"}, + {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92"}, + {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c"}, + {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58"}, + {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9"}, + {file = "coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf"}, + {file = "coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95"}, + {file = "coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053"}, + {file = "coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11"}, + {file = "coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa"}, + {file = "coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7"}, + {file = "coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00"}, + {file = "coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef"}, + {file = "coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903"}, + {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f"}, + {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299"}, + {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505"}, + {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6"}, + {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9"}, + {file = "coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9"}, + {file = "coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f"}, + {file = "coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f"}, + {file = "coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459"}, + {file = "coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3"}, + {file = "coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634"}, + {file = "coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3"}, + {file = "coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa"}, + {file = "coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3"}, + {file = "coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a"}, + {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7"}, + {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc"}, + {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47"}, + {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985"}, + {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0"}, + {file = "coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246"}, + {file = "coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126"}, + {file = "coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d"}, + {file = "coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9"}, + {file = "coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac"}, + {file = "coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea"}, + {file = "coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b"}, + {file = "coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525"}, + {file = "coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242"}, + {file = "coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148"}, + {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a"}, + {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23"}, + {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80"}, + {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea"}, + {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a"}, + {file = "coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d"}, + {file = "coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd"}, + {file = "coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af"}, + {file = "coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d"}, + {file = "coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12"}, + {file = "coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b"}, + {file = "coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9"}, + {file = "coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092"}, + {file = "coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9"}, + {file = "coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26"}, + {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2"}, + {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940"}, + {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c"}, + {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0"}, + {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b"}, + {file = "coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9"}, + {file = "coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd"}, + {file = "coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997"}, + {file = "coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601"}, + {file = "coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a"}, + {file = "coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5"}, + {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0"}, + {file = "coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb"}, + {file = "coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505"}, + {file = "coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2"}, + {file = "coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056"}, + {file = "coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72"}, + {file = "coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39"}, + {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0"}, + {file = "coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea"}, + {file = "coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932"}, + {file = "coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b"}, + {file = "coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0"}, + {file = "coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91"}, ] [package.extras] @@ -1173,14 +1187,14 @@ files = [ [[package]] name = "elevenlabs" -version = "2.34.0" +version = "2.35.0" description = "" optional = false python-versions = "<4.0,>=3.8" groups = ["video"] files = [ - {file = "elevenlabs-2.34.0-py3-none-any.whl", hash = "sha256:3a46b40e69ac2841b2183a00d651a68bd11733d95d32a5ed8163d3aa6a0b13be"}, - {file = "elevenlabs-2.34.0.tar.gz", hash = "sha256:ddd35b25cb7b419ec13c40fab88e0de697db4f763779ae53ecd7794446d5f7ac"}, + {file = "elevenlabs-2.35.0-py3-none-any.whl", hash = "sha256:e27be0ccafccc46f619a6e29b04054a3e668ac32e68fe69616d7768e79c7f9e4"}, + {file = "elevenlabs-2.35.0.tar.gz", hash = "sha256:7276ef1f928c51fa623c9de90ea0675fc69460046b52eab4ae510a437e18fea6"}, ] [package.dependencies] @@ -1590,14 +1604,14 @@ test = ["objgraph", "psutil", "setuptools"] [[package]] name = "gunicorn" -version = "25.0.2" +version = "25.0.3" description = "WSGI HTTP Server for UNIX" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "gunicorn-25.0.2-py3-none-any.whl", hash = "sha256:288c002141d73ec8d05fdbb7c8453e3d01d3209d8ff6ad425df0ae1430153ca2"}, - {file = "gunicorn-25.0.2.tar.gz", hash = "sha256:8e44f2f7cf791de60c84ce119221c26121fd2ffcb27badfbced5a1c919d35d67"}, + {file = "gunicorn-25.0.3-py3-none-any.whl", hash = "sha256:aca364c096c81ca11acd4cede0aaeea91ba76ca74e2c0d7f879154db9d890f35"}, + {file = "gunicorn-25.0.3.tar.gz", hash = "sha256:b53a7fff1a07b825b962af320554de44ae77a26abfa373711ff3f83d57d3506d"}, ] [package.dependencies] @@ -2115,14 +2129,14 @@ orjson = ">=3.10.1" [[package]] name = "langsmith" -version = "0.6.9" +version = "0.7.1" description = "Client library to connect to the LangSmith Observability and Evaluation Platform." optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "langsmith-0.6.9-py3-none-any.whl", hash = "sha256:86ba521e042397f6fbb79d63991df9d5f7b6a6dd6a6323d4f92131291478dcff"}, - {file = "langsmith-0.6.9.tar.gz", hash = "sha256:aae04cec6e6d8e133f63ba71c332ce0fbd2cda95260db7746ff4c3b6a3c41db1"}, + {file = "langsmith-0.7.1-py3-none-any.whl", hash = "sha256:92cfa54253d35417184c297ad25bfd921d95f15d60a1ca75f14d4e7acd152a29"}, + {file = "langsmith-0.7.1.tar.gz", hash = "sha256:e3fec2f97f7c5192f192f4873d6a076b8c6469768022323dded07087d8cb70a4"}, ] [package.dependencies] @@ -2302,14 +2316,14 @@ htmlsoup = ["BeautifulSoup4"] [[package]] name = "markdown" -version = "3.10.1" +version = "3.10.2" description = "Python implementation of John Gruber's Markdown." optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3"}, - {file = "markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a"}, + {file = "markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36"}, + {file = "markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950"}, ] [package.extras] @@ -2687,14 +2701,14 @@ files = [ [[package]] name = "openai" -version = "2.17.0" +version = "2.18.0" description = "The official Python library for the openai API" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "openai-2.17.0-py3-none-any.whl", hash = "sha256:4f393fd886ca35e113aac7ff239bcd578b81d8f104f5aedc7d3693eb2af1d338"}, - {file = "openai-2.17.0.tar.gz", hash = "sha256:47224b74bd20f30c6b0a6a329505243cb2f26d5cf84d9f8d0825ff8b35e9c999"}, + {file = "openai-2.18.0-py3-none-any.whl", hash = "sha256:538f97e1c77a00e3a99507688c878cda7e9e63031807ba425c68478854d48b30"}, + {file = "openai-2.18.0.tar.gz", hash = "sha256:5018d3bcb6651c5aac90e6d0bf9da5cde1bdd23749f67b45b37c522b6e6353af"}, ] [package.dependencies] @@ -3580,34 +3594,34 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pypdfium2" -version = "5.3.0" +version = "5.4.0" description = "Python bindings to PDFium" optional = false python-versions = ">=3.6" groups = ["video"] files = [ - {file = "pypdfium2-5.3.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:885df6c78d41600cb086dc0c76b912d165b5bd6931ca08138329ea5a991b3540"}, - {file = "pypdfium2-5.3.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:6e53dee6b333ee77582499eff800300fb5aa0c7eb8f52f95ccb5ca35ebc86d48"}, - {file = "pypdfium2-5.3.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ce4466bdd62119fe25a5f74d107acc9db8652062bf217057630c6ff0bb419523"}, - {file = "pypdfium2-5.3.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:cc2647fd03db42b8a56a8835e8bc7899e604e2042cd6fedeea53483185612907"}, - {file = "pypdfium2-5.3.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35e205f537ddb4069e4b4e22af7ffe84fcf2d686c3fee5e5349f73268a0ef1ca"}, - {file = "pypdfium2-5.3.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5795298f44050797ac030994fc2525ea35d2d714efe70058e0ee22e5f613f27"}, - {file = "pypdfium2-5.3.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7cd43dfceb77137e69e74c933d41506da1dddaff70f3a794fb0ad0d73e90d75"}, - {file = "pypdfium2-5.3.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5956867558fd3a793e58691cf169718864610becb765bfe74dd83f05cbf1ae3"}, - {file = "pypdfium2-5.3.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3ff1071e9a782625822658dfe6e29e3a644a66960f8713bb17819f5a0ac5987"}, - {file = "pypdfium2-5.3.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f319c46ead49d289ab8c1ed2ea63c91e684f35bdc4cf4dc52191c441182ac481"}, - {file = "pypdfium2-5.3.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6dc67a186da0962294321cace6ccc0a4d212dbc5e9522c640d35725a812324b8"}, - {file = "pypdfium2-5.3.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0ad0afd3d2b5b54d86287266fd6ae3fef0e0a1a3df9d2c4984b3e3f8f70e6330"}, - {file = "pypdfium2-5.3.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1afe35230dc3951b3e79b934c0c35a2e79e2372d06503fce6cf1926d2a816f47"}, - {file = "pypdfium2-5.3.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:00385793030cadce08469085cd21b168fd8ff981b009685fef3103bdc5fc4686"}, - {file = "pypdfium2-5.3.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:d911e82676398949697fef80b7f412078df14d725a91c10e383b727051530285"}, - {file = "pypdfium2-5.3.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:ca1dc625ed347fac3d9002a3ed33d521d5803409bd572e7b3f823c12ab2ef58f"}, - {file = "pypdfium2-5.3.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:ea4f9db2d3575f22cd41f4c7a855240ded842f135e59a961b5b1351a65ce2b6e"}, - {file = "pypdfium2-5.3.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0ea24409613df350223c6afc50911c99dca0d43ddaf2616c5a1ebdffa3e1bcb5"}, - {file = "pypdfium2-5.3.0-py3-none-win32.whl", hash = "sha256:5bf695d603f9eb8fdd7c1786add5cf420d57fbc81df142ed63c029ce29614df9"}, - {file = "pypdfium2-5.3.0-py3-none-win_amd64.whl", hash = "sha256:8365af22a39d4373c265f8e90e561cd64d4ddeaf5e6a66546a8caed216ab9574"}, - {file = "pypdfium2-5.3.0-py3-none-win_arm64.whl", hash = "sha256:0b2c6bf825e084d91d34456be54921da31e9199d9530b05435d69d1a80501a12"}, - {file = "pypdfium2-5.3.0.tar.gz", hash = "sha256:2873ffc95fcb01f329257ebc64a5fdce44b36447b6b171fe62f7db5dc3269885"}, + {file = "pypdfium2-5.4.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:8bc51a12a8c8eabbdbd7499d3e5ec47bcf56ba18e07b52bdd07d321cc1252c90"}, + {file = "pypdfium2-5.4.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:a414ef5b685824cc6c7acbe19b7dbc735de2023cf473321a8ebfe8d7f5d8a41f"}, + {file = "pypdfium2-5.4.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0e83657db8da5971434ff5683bf3faa007ee1f3a56b61f245b8aa5b60442c23a"}, + {file = "pypdfium2-5.4.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:e42b1d14db642e96bb3a57167f620b4247e9c843d22b9fb569b16a7c35a18f47"}, + {file = "pypdfium2-5.4.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0698c9a002f839127e74ec0185147e08b64e47a1e6caeaee95df434c05b26e8c"}, + {file = "pypdfium2-5.4.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:22e9d4c73fc48b18b022977ea6fe78df43adf95440e1135020ed35fea9595017"}, + {file = "pypdfium2-5.4.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f0619f8a8ae3eb71b2cdc1fbd2a8f5d43f0fc6bee66d1b3aac2c9c23e44a3bf"}, + {file = "pypdfium2-5.4.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50124415d815c41de8ce7e21cee5450f74f6f1240a140573bb71ccac804d5e5f"}, + {file = "pypdfium2-5.4.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce482d76e5447e745d761307401eaa366616ca44032b86cf7fbe6be918ade64e"}, + {file = "pypdfium2-5.4.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16b9c6b07f3dbe7eda209bf7aaf131ca9614e1dae527e9764180dd58bcbaf411"}, + {file = "pypdfium2-5.4.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3b08d48b7cca3b51aefaad7855bc0e9e251432a6eef1356d532ff438be84855e"}, + {file = "pypdfium2-5.4.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0a1526e2a2bde7f2f13bec0f471d9fd475f7bbac2c0c860d48c35af8394d5931"}, + {file = "pypdfium2-5.4.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:40cea0bceb1e60a71b3855e2b04d175d2199b7da06212bb80f0c78067d065810"}, + {file = "pypdfium2-5.4.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7a116f8fbeae7aa3a18ff2d1fa331ac647831cc16b589d4fbbbb66d64ecc8793"}, + {file = "pypdfium2-5.4.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:55c7fc894718db5fa2981d46dee45fe3a4fcd60d26f5095ad8f7779600fa8b6f"}, + {file = "pypdfium2-5.4.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:dfc1c0c7e6e7ba258ebb338aaf664eb933bff1854cda76e4ee530886ea39b31a"}, + {file = "pypdfium2-5.4.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:4c0a48ede7180f804c029c509c2b6ea0c66813a3fde9eb9afc390183f947164d"}, + {file = "pypdfium2-5.4.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dea22d15c44a275702fd95ad664ba6eaa3c493d53d58b4d69272a04bdfb0df70"}, + {file = "pypdfium2-5.4.0-py3-none-win32.whl", hash = "sha256:35c643827ed0f4dae9cedf3caf836f94cba5b31bd2c115b80a7c85f004636de9"}, + {file = "pypdfium2-5.4.0-py3-none-win_amd64.whl", hash = "sha256:f9d9ce3c6901294d6984004d4a797dea110f8248b1bde33a823d25b45d3c2685"}, + {file = "pypdfium2-5.4.0-py3-none-win_arm64.whl", hash = "sha256:2b78ea216fb92e7709b61c46241ebf2cc0c60cf18ad2fb4633af665d7b4e21e6"}, + {file = "pypdfium2-5.4.0.tar.gz", hash = "sha256:7219e55048fb3999fc8adcaea467088507207df4676ff9e521a3ae15a67d99c4"}, ] [[package]] @@ -3967,14 +3981,14 @@ all = ["numpy"] [[package]] name = "redis" -version = "7.1.0" +version = "7.1.1" description = "Python client for Redis database and key-value store" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b"}, - {file = "redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c"}, + {file = "redis-7.1.1-py3-none-any.whl", hash = "sha256:f77817f16071c2950492c67d40b771fa493eb3fccc630a424a10976dbb794b7a"}, + {file = "redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43"}, ] [package.extras] @@ -5484,4 +5498,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "9e8cbd78322363d2cb4439d71126bf77cedd013a44c491ab7842ace5a4d8d668" +content-hash = "ec5a1019616316931281cb03ddabca567ede7c2eb796a5395a3e3eb0fb5e85a0" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6a52f75a01..f2c5a30e43 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -8,69 +8,61 @@ version = "0.0.0" description = "Your gateway to OWASP" authors = [ "Arkadii Yakovets " ] readme = "README.md" - packages = [ { include = "apps" } ] - -[tool.poetry.dependencies] -algoliasearch = "^4.13.2" -algoliasearch-django = "^4.0.0" -aws-xray-sdk = "^2.15.0" -django = "^6.0" -django-configurations = "^2.5.1" -django-cors-headers = "^4.7.0" -django-ninja = "^1.4.3" -django-redis = "^6.0.0" -django-rq = "^3.1" -django-storages = { extras = [ "s3" ], version = "^1.14.4" } -emoji = "^2.14.1" -geopy = "^2.4.1" -gunicorn = "^25.0.0" -humanize = "^4.11.0" -jinja2 = "^3.1.6" -langchain = "^0.3.26" -langchain-community = "^0.3.26" -langgraph = "^1.0.1" -lxml = "^6.0.0" -markdown = "^3.7" -openai = "^2.0.1" -owasp-schema = "^0.1.46" -pgvector = "^0.4.1" -psycopg2-binary = "^2.9.9" -pydantic = "^2.11.1" -pydantic-core = "^2.33.0" -pygithub = "^2.5.0" -pyparsing = "^3.2.3" -python = "^3.13" -python-dateutil = "^2.9.0.post0" -pyyaml = "^6.0.2" -reportlab = "^4.4.2" -requests = "^2.32.5" -sentry-sdk = { extras = [ "django" ], version = "^2.20.0" } -slack-bolt = "^1.22.0" -slack-sdk = "^3.37.0" -strawberry-graphql = { extras = [ "django" ], version = "^0.291.0" } -strawberry-graphql-django = "^0.75.0" -thefuzz = "^0.22.1" -zappa = "^0.61.4" - -[tool.poetry.group.test.dependencies] -pytest = "^9.0.1" -pytest-cov = "^7.0" -pytest-django = "^4.5" -pytest-mock = "^3.0" -pytest-xdist = "^3.0" -python-dotenv = "^1.0.1" - -[tool.poetry.group.video.dependencies] -elevenlabs = "^2.27.0" -ffmpeg-python = "^0.2.0" -pillow = "^12.1.0" -pypdfium2 = "^5.2.0" -weasyprint = "^68.0" +dependencies.algoliasearch = "^4.13.2" +dependencies.algoliasearch-django = "^4.0.0" +dependencies.aws-xray-sdk = "^2.15.0" +dependencies.django = "^6.0" +dependencies.django-configurations = "^2.5.1" +dependencies.django-cors-headers = "^4.7.0" +dependencies.django-ninja = "^1.5.3" +dependencies.django-redis = "^6.0.0" +dependencies.django-rq = "^3.1" +dependencies.django-storages = { extras = [ "s3" ], version = "^1.14.4" } +dependencies.emoji = "^2.14.1" +dependencies.geopy = "^2.4.1" +dependencies.gunicorn = "^25.0.0" +dependencies.humanize = "^4.11.0" +dependencies.jinja2 = "^3.1.6" +dependencies.langchain = "^0.3.26" +dependencies.langchain-community = "^0.3.26" +dependencies.langgraph = "^1.0.1" +dependencies.lxml = "^6.0.0" +dependencies.markdown = "^3.7" +dependencies.openai = "^2.0.1" +dependencies.owasp-schema = "^0.1.46" +dependencies.pgvector = "^0.4.1" +dependencies.psycopg2-binary = "^2.9.9" +dependencies.pydantic = "^2.11.1" +dependencies.pydantic-core = "^2.33.0" +dependencies.pygithub = "^2.5.0" +dependencies.python = "^3.13" +dependencies.python-dateutil = "^2.9.0.post0" +dependencies.pyyaml = "^6.0.2" +dependencies.reportlab = "^4.4.2" +dependencies.requests = "^2.32.5" +dependencies.sentry-sdk = { extras = [ "django" ], version = "^2.20.0" } +dependencies.slack-bolt = "^1.22.0" +dependencies.slack-sdk = "^3.37.0" +dependencies.strawberry-graphql = { extras = [ "django" ], version = "^0.291.0" } +dependencies.strawberry-graphql-django = "^0.75.0" +dependencies.thefuzz = "^0.22.1" +dependencies.pyparsing = "^3.2.3" +dependencies.zappa = "^0.61.4" +group.test.dependencies.pytest = "^9.0.1" +group.test.dependencies.pytest-cov = "^7.0" +group.test.dependencies.pytest-django = "^4.5" +group.test.dependencies.pytest-mock = "^3.0" +group.test.dependencies.pytest-xdist = "^3.0" +group.test.dependencies.python-dotenv = "^1.0.1" +group.video.dependencies.elevenlabs = "^2.27.0" +group.video.dependencies.ffmpeg-python = "^0.2.0" +group.video.dependencies.pillow = "^12.1.0" +group.video.dependencies.pypdfium2 = "^5.4.0" +group.video.dependencies.weasyprint = "^68.0" [tool.ruff] target-version = "py313" - line-length = 99 lint.select = [ "ALL" ] lint.extend-select = [ "I" ] @@ -127,16 +119,15 @@ lint.per-file-ignores."**/tests/**/*.py" = [ "S101", # https://docs.astral.sh/ruff/rules/assert/ "SLF001", # https://docs.astral.sh/ruff/rules/private-member-access/ ] - lint.per-file-ignores."apps/api/rest/**/*.py" = [ "ARG001", # https://docs.astral.sh/ruff/rules/unused-function-argument/ "B008", # https://docs.astral.sh/ruff/rules/function-call-in-default-argument/ ] -[tool.pytest.ini_options] -DJANGO_CONFIGURATION = "Test" -DJANGO_SETTINGS_MODULE = "settings.test" -addopts = [ +[tool.pytest] +ini_options.DJANGO_CONFIGURATION = "Test" +ini_options.DJANGO_SETTINGS_MODULE = "settings.test" +ini_options.addopts = [ "--cov-config=pyproject.toml", "--cov-fail-under=80", "--cov-precision=2", @@ -149,16 +140,16 @@ addopts = [ "--no-cov-on-fail", "--numprocesses=auto", ] -filterwarnings = [ +ini_options.filterwarnings = [ "ignore::DeprecationWarning:pytest_cov", "ignore::DeprecationWarning:xdist", "ignore::pydantic.warnings.PydanticDeprecatedSince20", ] -log_level = "INFO" +ini_options.log_level = "INFO" -[tool.coverage.run] -branch = true -omit = [ +[tool.coverage] +run.branch = true +run.omit = [ "__init__.py", "**/admin.py", "**/apps.py", diff --git a/backend/tests/apps/ai/common/extractors/repository_test.py b/backend/tests/apps/ai/common/extractors/repository_test.py index 62afe0f3f8..727fa7eb64 100644 --- a/backend/tests/apps/ai/common/extractors/repository_test.py +++ b/backend/tests/apps/ai/common/extractors/repository_test.py @@ -52,8 +52,9 @@ def create_mock_repository(**kwargs): class TestRepositoryContentExtractor: """Test cases for repository content extraction.""" + @patch("time.sleep") @patch("apps.ai.common.extractors.repository.get_repository_file_content") - def test_extract_repository_content_full_data(self, mock_get_content): + def test_extract_repository_content_full_data(self, mock_get_content, mock_sleep): """Test extraction with complete repository data.""" mock_get_content.return_value = "[]" @@ -185,8 +186,9 @@ def test_extract_repository_content_empty_repository(self): assert "Repository Name: empty-repo" in metadata assert "Repository Key: empty-repo-key" in metadata + @patch("time.sleep") @patch("apps.ai.common.extractors.repository.get_repository_file_content") - def test_extract_repository_content_with_organization_only(self, mock_get_content): + def test_extract_repository_content_with_organization_only(self, mock_get_content, mock_sleep): """Test extraction when repository has organization but no owner.""" mock_get_content.return_value = "[]" organization = MagicMock() @@ -208,8 +210,9 @@ def test_extract_repository_content_with_organization_only(self, mock_get_conten assert "Repository Key: org-repo-key" in metadata assert "Organization: test-org" in metadata + @patch("time.sleep") @patch("apps.ai.common.extractors.repository.get_repository_file_content") - def test_extract_repository_content_with_owner_only(self, mock_get_content): + def test_extract_repository_content_with_owner_only(self, mock_get_content, mock_sleep): """Test extraction when repository has owner but no organization.""" mock_get_content.return_value = "[]" owner = MagicMock() @@ -257,8 +260,11 @@ def test_extract_repository_content_delimiter_usage(self): class TestRepositoryMarkdownContentExtractor: """Test cases for repository markdown content extraction.""" + @patch("time.sleep") @patch("apps.ai.common.extractors.repository.get_repository_file_content") - def test_extract_repository_markdown_content_with_description(self, mock_get_content): + def test_extract_repository_markdown_content_with_description( + self, mock_get_content, mock_sleep + ): """Test extraction with repository description.""" organization = MagicMock() organization.login = "test-org" @@ -283,8 +289,9 @@ def test_extract_repository_markdown_content_with_description(self, mock_get_con assert "Repository Key: test-repo" in metadata assert "Organization: test-org" in metadata + @patch("time.sleep") @patch("apps.ai.common.extractors.repository.get_repository_file_content") - def test_extract_repository_markdown_content_with_readme(self, mock_get_content): + def test_extract_repository_markdown_content_with_readme(self, mock_get_content, mock_sleep): """Test extraction with README.md file.""" organization = MagicMock() organization.login = "test-org" @@ -311,8 +318,11 @@ def test_extract_repository_markdown_content_with_readme(self, mock_get_content) == "# Test Repository\n\nThis is a test repository." ) + @patch("time.sleep") @patch("apps.ai.common.extractors.repository.get_repository_file_content") - def test_extract_repository_markdown_content_with_owner_fallback(self, mock_get_content): + def test_extract_repository_markdown_content_with_owner_fallback( + self, mock_get_content, mock_sleep + ): """Test extraction when organization is None, falls back to owner.""" owner = MagicMock() owner.login = "test-user" @@ -335,9 +345,10 @@ def test_extract_repository_markdown_content_with_owner_fallback(self, mock_get_ assert "markdown_content" in data assert "README.md" in data["markdown_content"] + @patch("time.sleep") @patch("apps.ai.common.extractors.repository.get_repository_file_content") def test_extract_repository_markdown_content_with_default_branch_fallback( - self, mock_get_content + self, mock_get_content, mock_sleep ): """Test extraction when default_branch is None, falls back to 'main'.""" organization = MagicMock() @@ -360,8 +371,11 @@ def test_extract_repository_markdown_content_with_default_branch_fallback( assert "markdown_content" in data assert "README.md" in data["markdown_content"] + @patch("time.sleep") @patch("apps.ai.common.extractors.repository.get_repository_file_content") - def test_extract_repository_markdown_content_multiple_files(self, mock_get_content): + def test_extract_repository_markdown_content_multiple_files( + self, mock_get_content, mock_sleep + ): """Test extraction with multiple markdown files.""" organization = MagicMock() organization.login = "test-org" @@ -402,8 +416,11 @@ def mock_content_side_effect(url): assert "leaders.md" in data["markdown_content"] assert data["markdown_content"]["leaders.md"] == "# Leaders Content" + @patch("time.sleep") @patch("apps.ai.common.extractors.repository.get_repository_file_content") - def test_extract_repository_markdown_content_file_fetch_exception(self, mock_get_content): + def test_extract_repository_markdown_content_file_fetch_exception( + self, mock_get_content, mock_sleep + ): """Test extraction when file fetching raises an exception.""" organization = MagicMock() organization.login = "test-org" @@ -438,8 +455,11 @@ def side_effect(url): assert "Organization: test-org" in metadata mock_get_content.assert_any_call(problematic_url) + @patch("time.sleep") @patch("apps.ai.common.extractors.repository.get_repository_file_content") - def test_extract_repository_markdown_content_empty_file_content(self, mock_get_content): + def test_extract_repository_markdown_content_empty_file_content( + self, mock_get_content, mock_sleep + ): """Test extraction when file content is empty or whitespace.""" organization = MagicMock() organization.login = "test-org" @@ -461,8 +481,11 @@ def test_extract_repository_markdown_content_empty_file_content(self, mock_get_c assert data["ownership"]["organization"] == "test-org" assert "markdown_content" not in data + @patch("time.sleep") @patch("apps.ai.common.extractors.repository.get_repository_file_content") - def test_extract_repository_markdown_content_no_description(self, mock_get_content): + def test_extract_repository_markdown_content_no_description( + self, mock_get_content, mock_sleep + ): """Test extraction when repository has no description.""" organization = MagicMock() organization.login = "test-org" @@ -490,8 +513,11 @@ def test_extract_repository_markdown_content_no_description(self, mock_get_conte assert "Repository Key: test-repo" in metadata assert "Organization: test-org" in metadata + @patch("time.sleep") @patch("apps.ai.common.extractors.repository.get_repository_file_content") - def test_extract_repository_markdown_content_url_construction(self, mock_get_content): + def test_extract_repository_markdown_content_url_construction( + self, mock_get_content, mock_sleep + ): """Test that URLs are constructed correctly for file fetching.""" organization = MagicMock() organization.login = "test-org" @@ -530,8 +556,9 @@ def test_extract_repository_markdown_content_no_owner_or_org(self): assert "Repository Name: test-repo" in metadata assert "Repository Key: test-repo" in metadata + @patch("time.sleep") @patch("apps.ai.common.extractors.repository.get_repository_file_content") - def test_extract_repository_markdown_content_no_key(self, mock_get_content): + def test_extract_repository_markdown_content_no_key(self, mock_get_content, mock_sleep): """Test extraction when repository has no key.""" mock_get_content.return_value = "[]" organization = MagicMock() @@ -555,10 +582,11 @@ def test_extract_repository_markdown_content_no_key(self, mock_get_content): assert "Repository Name: test-repo" in metadata assert "Organization: test-org" in metadata + @patch("time.sleep") @patch("apps.ai.common.extractors.repository.logger") @patch("apps.ai.common.extractors.repository.get_repository_file_content") def test_extract_repository_markdown_content_logs_debug_on_exception( - self, mock_get_content, mock_logger + self, mock_get_content, mock_logger, mock_sleep ): """Test that debug logging occurs when file fetching fails.""" organization = MagicMock() diff --git a/backend/tests/apps/api/rest/v0/chapter_test.py b/backend/tests/apps/api/rest/v0/chapter_test.py index cc2969edf9..4cdd7e01fd 100644 --- a/backend/tests/apps/api/rest/v0/chapter_test.py +++ b/backend/tests/apps/api/rest/v0/chapter_test.py @@ -1,8 +1,10 @@ from datetime import datetime +from http import HTTPStatus +from unittest.mock import MagicMock, patch import pytest -from apps.api.rest.v0.chapter import ChapterDetail +from apps.api.rest.v0.chapter import ChapterDetail, get_chapter, list_chapters @pytest.mark.parametrize( @@ -65,3 +67,77 @@ def __init__(self, data): assert chapter.name == chapter_data["name"] assert chapter.region == chapter_data["region"] assert chapter.updated_at == datetime.fromisoformat(chapter_data["updated_at"]) + + +class TestListChapters: + """Tests for list_chapters endpoint.""" + + @patch("apps.api.rest.v0.chapter.ChapterModel") + def test_list_chapters_no_filter(self, mock_chapter_model): + """Test listing chapters without filters.""" + mock_request = MagicMock() + mock_filters = MagicMock() + mock_queryset = MagicMock() + mock_chapter_model.active_chapters.order_by.return_value = mock_queryset + mock_filters.filter.return_value = mock_queryset + + result = list_chapters(mock_request, mock_filters, ordering=None) + + mock_chapter_model.active_chapters.order_by.assert_called_with("-created_at") + assert result == mock_queryset + + @patch("apps.api.rest.v0.chapter.ChapterModel") + def test_list_chapters_with_ordering(self, mock_chapter_model): + """Test listing chapters with custom ordering.""" + mock_request = MagicMock() + mock_filters = MagicMock() + mock_queryset = MagicMock() + mock_chapter_model.active_chapters.order_by.return_value = mock_queryset + mock_filters.filter.return_value = mock_queryset + + result = list_chapters(mock_request, mock_filters, ordering="latitude") + + mock_chapter_model.active_chapters.order_by.assert_called_with("latitude") + assert result == mock_queryset + + +class TestGetChapter: + """Tests for get_chapter endpoint.""" + + @patch("apps.api.rest.v0.chapter.ChapterModel") + def test_get_chapter_success(self, mock_chapter_model): + """Test getting a chapter when found.""" + mock_request = MagicMock() + mock_chapter = MagicMock() + mock_chapter_model.active_chapters.filter.return_value.first.return_value = mock_chapter + + result = get_chapter(mock_request, "London") + + mock_chapter_model.active_chapters.filter.assert_called_with( + key__iexact="www-chapter-London" + ) + assert result == mock_chapter + + @patch("apps.api.rest.v0.chapter.ChapterModel") + def test_get_chapter_with_prefix(self, mock_chapter_model): + """Test getting a chapter with www-chapter- prefix.""" + mock_request = MagicMock() + mock_chapter = MagicMock() + mock_chapter_model.active_chapters.filter.return_value.first.return_value = mock_chapter + + result = get_chapter(mock_request, "www-chapter-London") + + mock_chapter_model.active_chapters.filter.assert_called_with( + key__iexact="www-chapter-London" + ) + assert result == mock_chapter + + @patch("apps.api.rest.v0.chapter.ChapterModel") + def test_get_chapter_not_found(self, mock_chapter_model): + """Test getting a chapter when not found.""" + mock_request = MagicMock() + mock_chapter_model.active_chapters.filter.return_value.first.return_value = None + + result = get_chapter(mock_request, "NonExistent") + + assert result.status_code == HTTPStatus.NOT_FOUND diff --git a/backend/tests/apps/api/rest/v0/committee_test.py b/backend/tests/apps/api/rest/v0/committee_test.py index 22ef0e910b..aa15a753c3 100644 --- a/backend/tests/apps/api/rest/v0/committee_test.py +++ b/backend/tests/apps/api/rest/v0/committee_test.py @@ -1,8 +1,10 @@ from datetime import datetime +from http import HTTPStatus +from unittest.mock import MagicMock, patch import pytest -from apps.api.rest.v0.committee import CommitteeDetail +from apps.api.rest.v0.committee import CommitteeDetail, get_committee, list_committees @pytest.mark.parametrize( @@ -38,3 +40,77 @@ def __init__(self, data): assert committee.key == committee_data["key"] assert committee.name == committee_data["name"] assert committee.updated_at == datetime.fromisoformat(committee_data["updated_at"]) + + +class TestListCommittees: + """Tests for list_committees endpoint.""" + + @patch("apps.api.rest.v0.committee.CommitteeModel") + def test_list_committees_no_ordering(self, mock_committee_model): + """Test listing committees without ordering.""" + mock_request = MagicMock() + mock_queryset = MagicMock() + mock_committee_model.active_committees.order_by.return_value = mock_queryset + + result = list_committees(mock_request, ordering=None) + + mock_committee_model.active_committees.order_by.assert_called_with("-created_at") + assert result == mock_queryset + + @patch("apps.api.rest.v0.committee.CommitteeModel") + def test_list_committees_with_ordering(self, mock_committee_model): + """Test listing committees with custom ordering.""" + mock_request = MagicMock() + mock_queryset = MagicMock() + mock_committee_model.active_committees.order_by.return_value = mock_queryset + + result = list_committees(mock_request, ordering="updated_at") + + mock_committee_model.active_committees.order_by.assert_called_with("updated_at") + assert result == mock_queryset + + +class TestGetCommittee: + """Tests for get_committee endpoint.""" + + @patch("apps.api.rest.v0.committee.CommitteeModel") + def test_get_committee_success(self, mock_committee_model): + """Test getting a committee when found.""" + mock_request = MagicMock() + mock_committee = MagicMock() + mock_committee_model.active_committees.filter.return_value.first.return_value = ( + mock_committee + ) + + result = get_committee(mock_request, "project") + + mock_committee_model.active_committees.filter.assert_called_with( + is_active=True, key__iexact="www-committee-project" + ) + assert result == mock_committee + + @patch("apps.api.rest.v0.committee.CommitteeModel") + def test_get_committee_with_prefix(self, mock_committee_model): + """Test getting a committee with www-committee- prefix.""" + mock_request = MagicMock() + mock_committee = MagicMock() + mock_committee_model.active_committees.filter.return_value.first.return_value = ( + mock_committee + ) + + result = get_committee(mock_request, "www-committee-project") + + mock_committee_model.active_committees.filter.assert_called_with( + is_active=True, key__iexact="www-committee-project" + ) + assert result == mock_committee + + @patch("apps.api.rest.v0.committee.CommitteeModel") + def test_get_committee_not_found(self, mock_committee_model): + """Test getting a committee when not found.""" + mock_request = MagicMock() + mock_committee_model.active_committees.filter.return_value.first.return_value = None + + result = get_committee(mock_request, "NonExistent") + + assert result.status_code == HTTPStatus.NOT_FOUND diff --git a/backend/tests/apps/api/rest/v0/event_test.py b/backend/tests/apps/api/rest/v0/event_test.py index fd2cc2e507..0f010b5461 100644 --- a/backend/tests/apps/api/rest/v0/event_test.py +++ b/backend/tests/apps/api/rest/v0/event_test.py @@ -1,9 +1,11 @@ from datetime import datetime +from http import HTTPStatus +from unittest.mock import MagicMock, patch import pytest from django.utils import timezone -from apps.api.rest.v0.event import EventDetail +from apps.api.rest.v0.event import EventDetail, get_event, list_events from apps.owasp.models.event import Event as EventModel current_timezone = timezone.get_current_timezone() @@ -46,3 +48,75 @@ def test_event_serializer_validation(event_object: EventModel): assert event.name == event_object.name assert event.start_date == event_object.start_date.isoformat() assert event.url == event_object.url + + +class TestListEvents: + """Tests for list_events endpoint.""" + + @patch("apps.api.rest.v0.event.EventModel") + def test_list_events_default(self, mock_event_model): + """Test listing events with default ordering.""" + mock_request = MagicMock() + mock_filters = MagicMock() + mock_queryset = MagicMock() + mock_event_model.objects.order_by.return_value = mock_queryset + mock_filters.filter.return_value = mock_queryset + + result = list_events(mock_request, mock_filters, ordering=None, is_upcoming=None) + + mock_event_model.objects.order_by.assert_called_with("-start_date", "-end_date") + assert result == mock_queryset + + @patch("apps.api.rest.v0.event.EventModel") + def test_list_events_with_ordering(self, mock_event_model): + """Test listing events with custom ordering.""" + mock_request = MagicMock() + mock_filters = MagicMock() + mock_queryset = MagicMock() + mock_event_model.objects.order_by.return_value = mock_queryset + mock_filters.filter.return_value = mock_queryset + + result = list_events(mock_request, mock_filters, ordering="latitude", is_upcoming=None) + + mock_event_model.objects.order_by.assert_called_with("latitude", "-end_date") + assert result == mock_queryset + + @patch("apps.api.rest.v0.event.EventModel") + def test_list_events_upcoming(self, mock_event_model): + """Test listing upcoming events.""" + mock_request = MagicMock() + mock_filters = MagicMock() + mock_upcoming_qs = MagicMock() + mock_event_model.upcoming_events.return_value.order_by.return_value = mock_upcoming_qs + mock_filters.filter.return_value = mock_upcoming_qs + + result = list_events(mock_request, mock_filters, ordering=None, is_upcoming=True) + + mock_event_model.upcoming_events.assert_called_once() + assert result == mock_upcoming_qs + + +class TestGetEvent: + """Tests for get_event endpoint.""" + + @patch("apps.api.rest.v0.event.EventModel") + def test_get_event_success(self, mock_event_model): + """Test getting an event when found.""" + mock_request = MagicMock() + mock_event = MagicMock() + mock_event_model.objects.filter.return_value.first.return_value = mock_event + + result = get_event(mock_request, "sample-event") + + mock_event_model.objects.filter.assert_called_with(key__iexact="sample-event") + assert result == mock_event + + @patch("apps.api.rest.v0.event.EventModel") + def test_get_event_not_found(self, mock_event_model): + """Test getting an event when not found.""" + mock_request = MagicMock() + mock_event_model.objects.filter.return_value.first.return_value = None + + result = get_event(mock_request, "nonexistent-event") + + assert result.status_code == HTTPStatus.NOT_FOUND diff --git a/backend/tests/apps/api/rest/v0/label_test.py b/backend/tests/apps/api/rest/v0/label_test.py index 037f36de40..4cc93ba806 100644 --- a/backend/tests/apps/api/rest/v0/label_test.py +++ b/backend/tests/apps/api/rest/v0/label_test.py @@ -1,6 +1,8 @@ +from unittest.mock import MagicMock, patch + import pytest -from apps.api.rest.v0.label import LabelDetail +from apps.api.rest.v0.label import LabelDetail, list_label class TestLabelSchema: @@ -25,3 +27,50 @@ def test_label_schema(self, label_data): assert label.color == label_data["color"] assert label.description == label_data["description"] assert label.name == label_data["name"] + + +class TestListLabels: + """Tests for list_label endpoint.""" + + @patch("apps.api.rest.v0.label.LabelModel") + def test_list_labels_no_ordering(self, mock_label_model): + """Test list labels without ordering.""" + mock_request = MagicMock() + mock_filters = MagicMock() + mock_queryset = MagicMock() + mock_filters.filter.return_value = mock_queryset + + result = list_label(mock_request, mock_filters, ordering=None) + + assert result == mock_queryset + mock_queryset.order_by.assert_not_called() + + @patch("apps.api.rest.v0.label.LabelModel") + def test_list_labels_with_ordering(self, mock_label_model): + """Test list labels with ordering.""" + mock_request = MagicMock() + mock_filters = MagicMock() + mock_queryset = MagicMock() + mock_ordered_queryset = MagicMock() + mock_filters.filter.return_value = mock_queryset + mock_queryset.order_by.return_value = mock_ordered_queryset + + result = list_label(mock_request, mock_filters, ordering="nest_created_at") + + mock_queryset.order_by.assert_called_with("nest_created_at") + assert result == mock_ordered_queryset + + @patch("apps.api.rest.v0.label.LabelModel") + def test_list_labels_with_desc_ordering(self, mock_label_model): + """Test list labels with descending ordering.""" + mock_request = MagicMock() + mock_filters = MagicMock() + mock_queryset = MagicMock() + mock_ordered_queryset = MagicMock() + mock_filters.filter.return_value = mock_queryset + mock_queryset.order_by.return_value = mock_ordered_queryset + + result = list_label(mock_request, mock_filters, ordering="-nest_updated_at") + + mock_queryset.order_by.assert_called_with("-nest_updated_at") + assert result == mock_ordered_queryset diff --git a/backend/tests/apps/api/rest/v0/member_test.py b/backend/tests/apps/api/rest/v0/member_test.py index 8444702118..44988fc314 100644 --- a/backend/tests/apps/api/rest/v0/member_test.py +++ b/backend/tests/apps/api/rest/v0/member_test.py @@ -1,8 +1,10 @@ from datetime import datetime +from http import HTTPStatus +from unittest.mock import MagicMock, patch import pytest -from apps.api.rest.v0.member import MemberDetail +from apps.api.rest.v0.member import MemberDetail, get_member, list_members class TestMemberSchema: @@ -47,3 +49,65 @@ def test_user_schema(self, member_data): assert member.url == member_data["url"] assert not hasattr(member, "email") + + +class TestListMembers: + """Tests for list_members endpoint.""" + + @patch("apps.api.rest.v0.member.UserModel") + def test_list_members_no_ordering(self, mock_user_model): + """Test listing members without ordering.""" + mock_request = MagicMock() + mock_filters = MagicMock() + mock_ordered_queryset = MagicMock() + mock_filtered_queryset = MagicMock() + mock_user_model.objects.order_by.return_value = mock_ordered_queryset + mock_filters.filter.return_value = mock_filtered_queryset + + result = list_members(mock_request, mock_filters, ordering=None) + + mock_user_model.objects.order_by.assert_called_with("-created_at") + mock_filters.filter.assert_called_once_with(mock_ordered_queryset) + assert result == mock_filtered_queryset + + @patch("apps.api.rest.v0.member.UserModel") + def test_list_members_with_ordering(self, mock_user_model): + """Test listing members with custom ordering.""" + mock_request = MagicMock() + mock_filters = MagicMock() + mock_ordered_queryset = MagicMock() + mock_filtered_queryset = MagicMock() + mock_user_model.objects.order_by.return_value = mock_ordered_queryset + mock_filters.filter.return_value = mock_filtered_queryset + + result = list_members(mock_request, mock_filters, ordering="updated_at") + + mock_user_model.objects.order_by.assert_called_with("updated_at") + mock_filters.filter.assert_called_once_with(mock_ordered_queryset) + assert result == mock_filtered_queryset + + +class TestGetMember: + """Tests for get_member endpoint.""" + + @patch("apps.api.rest.v0.member.UserModel") + def test_get_member_success(self, mock_user_model): + """Test getting a member when found.""" + mock_request = MagicMock() + mock_member = MagicMock() + mock_user_model.objects.filter.return_value.first.return_value = mock_member + + result = get_member(mock_request, "johndoe") + + mock_user_model.objects.filter.assert_called_with(login__iexact="johndoe") + assert result == mock_member + + @patch("apps.api.rest.v0.member.UserModel") + def test_get_member_not_found(self, mock_user_model): + """Test getting a member when not found.""" + mock_request = MagicMock() + mock_user_model.objects.filter.return_value.first.return_value = None + + result = get_member(mock_request, "nonexistent") + + assert result.status_code == HTTPStatus.NOT_FOUND diff --git a/backend/tests/apps/api/rest/v0/organization_test.py b/backend/tests/apps/api/rest/v0/organization_test.py index 73ae839e66..dd42dcdc6d 100644 --- a/backend/tests/apps/api/rest/v0/organization_test.py +++ b/backend/tests/apps/api/rest/v0/organization_test.py @@ -1,8 +1,14 @@ from datetime import datetime +from http import HTTPStatus +from unittest.mock import MagicMock, patch import pytest -from apps.api.rest.v0.organization import OrganizationDetail +from apps.api.rest.v0.organization import ( + OrganizationDetail, + get_organization, + list_organization, +) class TestOrganizationSchema: @@ -36,3 +42,64 @@ def test_organization_schema(self, organization_data): assert organization.login == organization_data["login"] assert organization.name == organization_data["name"] assert organization.updated_at == datetime.fromisoformat(organization_data["updated_at"]) + + +class TestListOrganization: + """Tests for list_organization endpoint.""" + + @patch("apps.api.rest.v0.organization.OrganizationModel") + def test_list_organization_no_ordering(self, mock_org_model): + """Test listing organizations without ordering.""" + mock_request = MagicMock() + mock_filters = MagicMock() + mock_queryset = MagicMock() + mock_org_model.objects.filter.return_value.order_by.return_value = mock_queryset + mock_filters.filter.return_value = mock_queryset + + result = list_organization(mock_request, mock_filters, ordering=None) + + mock_org_model.objects.filter.assert_called_with(is_owasp_related_organization=True) + mock_org_model.objects.filter.return_value.order_by.assert_called_with("-created_at") + assert result == mock_queryset + + @patch("apps.api.rest.v0.organization.OrganizationModel") + def test_list_organization_with_ordering(self, mock_org_model): + """Test listing organizations with custom ordering.""" + mock_request = MagicMock() + mock_filters = MagicMock() + mock_queryset = MagicMock() + mock_org_model.objects.filter.return_value.order_by.return_value = mock_queryset + mock_filters.filter.return_value = mock_queryset + + result = list_organization(mock_request, mock_filters, ordering="updated_at") + + mock_org_model.objects.filter.return_value.order_by.assert_called_with("updated_at") + assert result == mock_queryset + + +class TestGetOrganization: + """Tests for get_organization endpoint.""" + + @patch("apps.api.rest.v0.organization.OrganizationModel") + def test_get_organization_success(self, mock_org_model): + """Test getting an organization when found.""" + mock_request = MagicMock() + mock_org = MagicMock() + mock_org_model.objects.filter.return_value.first.return_value = mock_org + + result = get_organization(mock_request, "OWASP") + + mock_org_model.objects.filter.assert_called_with( + is_owasp_related_organization=True, login__iexact="OWASP" + ) + assert result == mock_org + + @patch("apps.api.rest.v0.organization.OrganizationModel") + def test_get_organization_not_found(self, mock_org_model): + """Test getting an organization when not found.""" + mock_request = MagicMock() + mock_org_model.objects.filter.return_value.first.return_value = None + + result = get_organization(mock_request, "NonExistent") + + assert result.status_code == HTTPStatus.NOT_FOUND diff --git a/backend/tests/apps/api/rest/v0/project_test.py b/backend/tests/apps/api/rest/v0/project_test.py index e197ab05f7..c1bf53a479 100644 --- a/backend/tests/apps/api/rest/v0/project_test.py +++ b/backend/tests/apps/api/rest/v0/project_test.py @@ -1,8 +1,10 @@ from datetime import datetime +from http import HTTPStatus +from unittest.mock import MagicMock, patch import pytest -from apps.api.rest.v0.project import ProjectDetail +from apps.api.rest.v0.project import ProjectDetail, get_project, list_projects @pytest.mark.parametrize( @@ -59,3 +61,90 @@ def __init__(self, data): assert project.level == project_data["level"] assert project.name == project_data["name"] assert project.updated_at == datetime.fromisoformat(project_data["updated_at"]) + + +class TestListProjects: + """Tests for list_projects endpoint.""" + + @patch("apps.api.rest.v0.project.apply_structured_search") + @patch("apps.api.rest.v0.project.ProjectModel") + def test_list_projects_without_level_filter(self, mock_project_model, mock_apply_search): + """Test list projects without level filter.""" + mock_request = MagicMock() + mock_filters = MagicMock() + mock_filters.level = None + mock_filters.q = None + + mock_queryset = MagicMock() + mock_apply_search.return_value = mock_queryset + mock_queryset.order_by.return_value = mock_queryset + + result = list_projects(mock_request, mock_filters, ordering=None) + + mock_queryset.order_by.assert_called_with("-level_raw", "-stars_count", "-forks_count") + assert result == mock_queryset + + @patch("apps.api.rest.v0.project.apply_structured_search") + @patch("apps.api.rest.v0.project.ProjectModel") + def test_list_projects_with_level_filter(self, mock_project_model, mock_apply_search): + """Test list projects with level filter.""" + mock_request = MagicMock() + mock_filters = MagicMock() + mock_filters.level = "flagship" + mock_filters.q = "name:security" + + mock_queryset = MagicMock() + mock_filtered_queryset = MagicMock() + mock_apply_search.return_value = mock_queryset + mock_queryset.filter.return_value = mock_filtered_queryset + mock_filtered_queryset.order_by.return_value = mock_filtered_queryset + + result = list_projects(mock_request, mock_filters, ordering="created_at") + + mock_queryset.filter.assert_called_with(level="flagship") + mock_filtered_queryset.order_by.assert_called_with( + "created_at", "-stars_count", "-forks_count" + ) + assert result == mock_filtered_queryset + + +class TestGetProject: + """Tests for get_project endpoint.""" + + @patch("apps.api.rest.v0.project.ProjectModel") + def test_get_project_success(self, mock_project_model): + """Test get project when found.""" + mock_request = MagicMock() + mock_project = MagicMock() + mock_project_model.active_projects.filter.return_value.first.return_value = mock_project + + result = get_project(mock_request, "Nest") + + mock_project_model.active_projects.filter.assert_called_with( + key__iexact="www-project-Nest" + ) + assert result == mock_project + + @patch("apps.api.rest.v0.project.ProjectModel") + def test_get_project_with_prefix(self, mock_project_model): + """Test get project with www-project- prefix.""" + mock_request = MagicMock() + mock_project = MagicMock() + mock_project_model.active_projects.filter.return_value.first.return_value = mock_project + + result = get_project(mock_request, "www-project-Nest") + + mock_project_model.active_projects.filter.assert_called_with( + key__iexact="www-project-Nest" + ) + assert result == mock_project + + @patch("apps.api.rest.v0.project.ProjectModel") + def test_get_project_not_found(self, mock_project_model): + """Test get project when not found.""" + mock_request = MagicMock() + mock_project_model.active_projects.filter.return_value.first.return_value = None + + result = get_project(mock_request, "NonExistent") + + assert result.status_code == HTTPStatus.NOT_FOUND diff --git a/backend/tests/apps/api/rest/v0/repository_test.py b/backend/tests/apps/api/rest/v0/repository_test.py index a4e3b44586..926e2879e4 100644 --- a/backend/tests/apps/api/rest/v0/repository_test.py +++ b/backend/tests/apps/api/rest/v0/repository_test.py @@ -1,8 +1,10 @@ from datetime import datetime +from http import HTTPStatus +from unittest.mock import MagicMock, patch import pytest -from apps.api.rest.v0.repository import RepositoryDetail +from apps.api.rest.v0.repository import RepositoryDetail, get_repository, list_repository class TestRepositorySchema: @@ -45,3 +47,71 @@ def test_repository_schema(self, repository_data): assert repository.open_issues_count == repository_data["open_issues_count"] assert repository.stars_count == repository_data["stars_count"] assert repository.updated_at == datetime.fromisoformat(repository_data["updated_at"]) + + +class TestListRepository: + """Tests for list_repository endpoint.""" + + @patch("apps.api.rest.v0.repository.RepositoryModel") + def test_list_repository_without_filter(self, mock_repo_model): + """Test list repositories without organization filter.""" + mock_request = MagicMock() + mock_filters = MagicMock() + mock_filters.organization_id = None + + mock_queryset = MagicMock() + mock_repo_model.objects.select_related.return_value = mock_queryset + mock_queryset.order_by.return_value = mock_queryset + + result = list_repository(mock_request, mock_filters, ordering=None) + + mock_queryset.order_by.assert_called_with("-created_at", "-updated_at") + assert result == mock_queryset + + @patch("apps.api.rest.v0.repository.RepositoryModel") + def test_list_repository_with_organization_filter(self, mock_repo_model): + """Test list repositories with organization filter.""" + mock_request = MagicMock() + mock_filters = MagicMock() + mock_filters.organization_id = "OWASP" + + mock_queryset = MagicMock() + mock_filtered_queryset = MagicMock() + mock_repo_model.objects.select_related.return_value = mock_queryset + mock_queryset.filter.return_value = mock_filtered_queryset + mock_filtered_queryset.order_by.return_value = mock_filtered_queryset + + result = list_repository(mock_request, mock_filters, ordering="created_at") + + mock_queryset.filter.assert_called_with(organization__login__iexact="OWASP") + mock_filtered_queryset.order_by.assert_called_with("created_at", "-updated_at") + assert result == mock_filtered_queryset + + +class TestGetRepository: + """Tests for get_repository endpoint.""" + + @patch("apps.api.rest.v0.repository.RepositoryModel") + def test_get_repository_success(self, mock_repo_model): + """Test get repository when found.""" + mock_request = MagicMock() + mock_repo = MagicMock() + mock_repo_model.objects.select_related.return_value.get.return_value = mock_repo + + result = get_repository(mock_request, "OWASP", "Nest") + + mock_repo_model.objects.select_related.assert_called_with("organization") + assert result == mock_repo + + @patch("apps.api.rest.v0.repository.RepositoryModel") + def test_get_repository_not_found(self, mock_repo_model): + """Test get repository when not found.""" + mock_request = MagicMock() + mock_repo_model.DoesNotExist = Exception + mock_repo_model.objects.select_related.return_value.get.side_effect = ( + mock_repo_model.DoesNotExist + ) + + result = get_repository(mock_request, "OWASP", "NonExistent") + + assert result.status_code == HTTPStatus.NOT_FOUND diff --git a/backend/tests/apps/api/rest/v0/snapshot_test.py b/backend/tests/apps/api/rest/v0/snapshot_test.py index 2485772251..0a69c31d7a 100644 --- a/backend/tests/apps/api/rest/v0/snapshot_test.py +++ b/backend/tests/apps/api/rest/v0/snapshot_test.py @@ -13,6 +13,7 @@ list_snapshot_members, list_snapshot_projects, list_snapshot_releases, + list_snapshots, ) from apps.github.models.issue import Issue from apps.github.models.release import Release @@ -261,3 +262,34 @@ def test_snapshot_release_resolver_no_organization(self): assert SnapshotRelease.resolve_organization_login(release) is None assert SnapshotRelease.resolve_repository_name(release) == "test-repo" + + +class TestListSnapshots: + """Tests for list_snapshots endpoint.""" + + @patch("apps.api.rest.v0.snapshot.SnapshotModel") + def test_list_snapshots_default_ordering(self, mock_snapshot_model): + """Test listing snapshots with default ordering.""" + mock_request = MagicMock() + mock_queryset = MagicMock() + mock_snapshot_model.objects.filter.return_value.order_by.return_value = mock_queryset + + result = list_snapshots(mock_request, ordering=None) + + mock_snapshot_model.objects.filter.assert_called_with( + status=mock_snapshot_model.Status.COMPLETED + ) + mock_snapshot_model.objects.filter.return_value.order_by.assert_called_with("-created_at") + assert result == mock_queryset + + @patch("apps.api.rest.v0.snapshot.SnapshotModel") + def test_list_snapshots_with_ordering(self, mock_snapshot_model): + """Test listing snapshots with custom ordering.""" + mock_request = MagicMock() + mock_queryset = MagicMock() + mock_snapshot_model.objects.filter.return_value.order_by.return_value = mock_queryset + + result = list_snapshots(mock_request, ordering="start_at") + + mock_snapshot_model.objects.filter.return_value.order_by.assert_called_with("start_at") + assert result == mock_queryset diff --git a/backend/tests/apps/api/rest/v0/sponsor_test.py b/backend/tests/apps/api/rest/v0/sponsor_test.py index 24a7ebadf4..5e89a5b30d 100644 --- a/backend/tests/apps/api/rest/v0/sponsor_test.py +++ b/backend/tests/apps/api/rest/v0/sponsor_test.py @@ -1,6 +1,9 @@ +from http import HTTPStatus +from unittest.mock import MagicMock, patch + import pytest -from apps.api.rest.v0.sponsor import SponsorDetail +from apps.api.rest.v0.sponsor import SponsorDetail, get_sponsor, list_sponsors class TestSponsorSchema: @@ -63,3 +66,61 @@ def test_sponsor_schema_with_minimal_data(self): assert sponsor.job_url == "" assert sponsor.key == "test-sponsor" assert sponsor.name == "Test Sponsor" + + +class TestListSponsors: + """Tests for list_sponsors endpoint.""" + + @patch("apps.api.rest.v0.sponsor.SponsorModel") + def test_list_sponsors_no_ordering(self, mock_sponsor_model): + """Test listing sponsors without ordering.""" + mock_request = MagicMock() + mock_filters = MagicMock() + mock_queryset = MagicMock() + mock_sponsor_model.objects.order_by.return_value = mock_queryset + mock_filters.filter.return_value = mock_queryset + + result = list_sponsors(mock_request, mock_filters, ordering=None) + + mock_sponsor_model.objects.order_by.assert_called_with("name") + assert result == mock_queryset + + @patch("apps.api.rest.v0.sponsor.SponsorModel") + def test_list_sponsors_with_ordering(self, mock_sponsor_model): + """Test listing sponsors with custom ordering.""" + mock_request = MagicMock() + mock_filters = MagicMock() + mock_queryset = MagicMock() + mock_sponsor_model.objects.order_by.return_value = mock_queryset + mock_filters.filter.return_value = mock_queryset + + result = list_sponsors(mock_request, mock_filters, ordering="-name") + + mock_sponsor_model.objects.order_by.assert_called_with("-name") + assert result == mock_queryset + + +class TestGetSponsor: + """Tests for get_sponsor endpoint.""" + + @patch("apps.api.rest.v0.sponsor.SponsorModel") + def test_get_sponsor_success(self, mock_sponsor_model): + """Test getting a sponsor when found.""" + mock_request = MagicMock() + mock_sponsor = MagicMock() + mock_sponsor_model.objects.filter.return_value.first.return_value = mock_sponsor + + result = get_sponsor(mock_request, "adobe") + + mock_sponsor_model.objects.filter.assert_called_with(key__iexact="adobe") + assert result == mock_sponsor + + @patch("apps.api.rest.v0.sponsor.SponsorModel") + def test_get_sponsor_not_found(self, mock_sponsor_model): + """Test getting a sponsor when not found.""" + mock_request = MagicMock() + mock_sponsor_model.objects.filter.return_value.first.return_value = None + + result = get_sponsor(mock_request, "nonexistent") + + assert result.status_code == HTTPStatus.NOT_FOUND diff --git a/backend/tests/apps/api/rest/v0/structured_search_test.py b/backend/tests/apps/api/rest/v0/structured_search_test.py index 89e5f91546..dda65d726a 100644 --- a/backend/tests/apps/api/rest/v0/structured_search_test.py +++ b/backend/tests/apps/api/rest/v0/structured_search_test.py @@ -57,3 +57,147 @@ def test_unknown_field_is_ignored(): args, _ = qs.filter.call_args assert "invalid_field" not in str(args[0]) + + +def test_empty_query_returns_original_queryset(): + """Test that empty query returns original queryset.""" + qs = make_queryset() + result = apply_structured_search(qs, "", FIELD_SCHEMA) + + assert result == qs + qs.filter.assert_not_called() + + +def test_none_query_returns_original_queryset(): + """Test that None query returns original queryset.""" + qs = make_queryset() + result = apply_structured_search(qs, None, FIELD_SCHEMA) + + assert result == qs + qs.filter.assert_not_called() + + +def test_invalid_number_value_is_skipped(): + """Test that invalid number value is skipped.""" + qs = make_queryset() + apply_structured_search(qs, "stars:not_a_number", FIELD_SCHEMA) + + qs.filter.assert_called_once() + args, _ = qs.filter.call_args + assert "stars_count" not in str(args[0]) + + +def test_less_than_operator(): + """Test less than operator for numeric fields.""" + qs = make_queryset() + apply_structured_search(qs, "stars:<5", FIELD_SCHEMA) + + args, _ = qs.filter.call_args + assert "stars_count__lt" in str(args[0]) + + +def test_string_field_with_exact_lookup(): + """Test string field with exact lookup.""" + schema_with_exact = { + "name": {"type": "string", "lookup": "exact"}, + } + qs = make_queryset() + apply_structured_search(qs, "name:test", schema_with_exact) + + qs.filter.assert_called_once() + args, _ = qs.filter.call_args + assert "name__icontains" not in str(args[0]) + + +def test_less_than_equal_operator(): + """Test less than or equal operator for numeric fields.""" + qs = make_queryset() + apply_structured_search(qs, "stars:<=100", FIELD_SCHEMA) + + args, _ = qs.filter.call_args + assert "stars_count__lte" in str(args[0]) + + +def test_equal_operator_numeric(): + """Test equals operator for numeric fields.""" + qs = make_queryset() + apply_structured_search(qs, "stars:=42", FIELD_SCHEMA) + + args, _ = qs.filter.call_args + assert "stars_count" in str(args[0]) + + +def test_multiple_conditions(): + """Test query with multiple field conditions.""" + qs = make_queryset() + apply_structured_search(qs, "name:nest stars:>10", FIELD_SCHEMA) + + qs.filter.assert_called_once() + args, _ = qs.filter.call_args + filter_str = str(args[0]) + assert "name__icontains" in filter_str + assert "stars_count__gt" in filter_str + + +def test_query_parser_error_returns_original_queryset(): + """Test that QueryParserError returns original queryset.""" + from unittest.mock import patch + + from apps.api.rest.v0.structured_search import QueryParserError + + qs = make_queryset() + with patch("apps.api.rest.v0.structured_search.QueryParser") as mock_parser_class: + mock_parser_class.side_effect = QueryParserError("Test error") + result = apply_structured_search(qs, "name:test", FIELD_SCHEMA) + + assert result == qs + qs.filter.assert_not_called() + + +def test_condition_field_not_in_schema_is_skipped(): + """Test that condition with field not in field_schema is skipped.""" + from unittest.mock import patch + + qs = make_queryset() + with patch("apps.api.rest.v0.structured_search.QueryParser") as mock_parser_class: + mock_parser = MagicMock() + mock_parser_class.return_value = mock_parser + mock_parser.parse.return_value = [{"field": "query", "type": "string", "value": "test"}] + apply_structured_search(qs, "test", FIELD_SCHEMA) + + qs.filter.assert_called_once() + + +def test_boolean_field_uses_no_lookup_suffix(): + """Test boolean field uses empty lookup suffix.""" + from unittest.mock import patch + + schema_with_boolean = { + "active": {"type": "boolean", "field": "is_active"}, + } + qs = make_queryset() + with patch("apps.api.rest.v0.structured_search.QueryParser") as mock_parser_class: + mock_parser = MagicMock() + mock_parser_class.return_value = mock_parser + mock_parser.parse.return_value = [{"field": "active", "type": "boolean", "value": True}] + apply_structured_search(qs, "active:true", schema_with_boolean) + + qs.filter.assert_called_once() + args, _ = qs.filter.call_args + assert "is_active" in str(args[0]) + + +def test_number_field_with_none_value_is_skipped(): + """Test that number field with None value is skipped.""" + from unittest.mock import patch + + qs = make_queryset() + with patch("apps.api.rest.v0.structured_search.QueryParser") as mock_parser_class: + mock_parser = MagicMock() + mock_parser_class.return_value = mock_parser + mock_parser.parse.return_value = [{"field": "stars", "type": "number", "value": None}] + apply_structured_search(qs, "stars:", FIELD_SCHEMA) + + qs.filter.assert_called_once() + args, _ = qs.filter.call_args + assert "stars" not in str(args[0]) diff --git a/backend/tests/apps/common/eleven_labs_test.py b/backend/tests/apps/common/eleven_labs_test.py index 3113debfc1..ef84b5bed8 100644 --- a/backend/tests/apps/common/eleven_labs_test.py +++ b/backend/tests/apps/common/eleven_labs_test.py @@ -8,12 +8,12 @@ DEFAULT_MODEL_ID = "eleven_multilingual_v2" DEFAULT_OUTPUT_FORMAT = "mp3_44100_128" DEFAULT_SIMILARITY_BOOST = 0.75 -DEFAULT_SPEED = 1.0 +DEFAULT_SPEED = 0.85 DEFAULT_STABILITY = 0.5 DEFAULT_STYLE = 0.0 DEFAULT_TIMEOUT = 30 DEFAULT_USE_SPEAKER_BOOST = True -DEFAULT_VOICE_ID = "1SM7GgM6IMuvQlz2BwM3" # cspell:disable-line +DEFAULT_VOICE_ID = "TX3LPaxmHKxFdv7VOQHJ" # cspell:disable-line class TestElevenLabs: diff --git a/backend/tests/apps/common/open_ai_test.py b/backend/tests/apps/common/open_ai_test.py index c4fface6c7..419baf444b 100644 --- a/backend/tests/apps/common/open_ai_test.py +++ b/backend/tests/apps/common/open_ai_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -53,9 +53,12 @@ def test_set_prompt(self, openai_instance, prompt_content, expected_prompt): @patch("apps.common.open_ai.logger") @patch("openai.OpenAI") - def test_complete_general_exception(self, mock_openai, mock_logger, openai_instance): - mock_openai.return_value.chat.completions.create.side_effect = Exception() - + def test_complete_general_exception(self, mock_openai, mock_logger): + """Test that general exceptions are caught and logged.""" + mock_client = MagicMock() + mock_client.chat.completions.create.side_effect = Exception("API error") + mock_openai.return_value = mock_client + openai_instance = OpenAi() openai_instance.set_prompt("Test prompt").set_input("Test input") response = openai_instance.complete() diff --git a/backend/tests/apps/common/search/query_parser_test.py b/backend/tests/apps/common/search/query_parser_test.py index 168ad8304f..1ad2ea400c 100644 --- a/backend/tests/apps/common/search/query_parser_test.py +++ b/backend/tests/apps/common/search/query_parser_test.py @@ -208,3 +208,16 @@ def test_overflow_numerical_value(self): self.strict_parser.parse(f"stars:{overflow_number}") assert e.value.error_type == "NUMBER_VALUE_ERROR" + + def test_quoted_multi_word_values(self): + query = 'project:"OWASP Nest" author:"John Doe"' + results = self.parser.parse(query) + + assert len(results) == 2 + assert results[0]["value"] == '"owasp nest"' + assert results[1]["value"] == '"john doe"' + + def test_case_sensitivity_toggle(self): + query = "Author:OWASP" + cs_result = self.case_sensitive_parser.parse(query) + assert cs_result[0]["value"] == "OWASP" diff --git a/backend/tests/apps/core/api/internal/algolia_test.py b/backend/tests/apps/core/api/internal/algolia_test.py index 0477d9a696..4e569c1bcd 100644 --- a/backend/tests/apps/core/api/internal/algolia_test.py +++ b/backend/tests/apps/core/api/internal/algolia_test.py @@ -27,6 +27,22 @@ def mock_redis_cache(): class TestAlgoliaSearch: + def _build_request(self, *, facet_filters, hits_per_page, index_name, page, query): + """Build mock requests with consistent structure.""" + mock_request = Mock() + mock_request.META = {"HTTP_X_FORWARDED_FOR": CLIENT_IP_ADDRESS} + mock_request.method = "POST" + mock_request.body = json.dumps( + { + "facetFilters": facet_filters, + "hitsPerPage": hits_per_page, + "indexName": index_name, + "page": page, + "query": query, + } + ) + return mock_request + @pytest.mark.parametrize( ("index_name", "query", "page", "hits_per_page", "facet_filters", "expected_result"), [ @@ -160,3 +176,77 @@ def test_algolia_search_invalid_request( assert response.status_code == HTTPStatus.BAD_REQUEST assert response_data["error"] == error_message + + def test_algolia_search_different_facet_filters_return_different_results(self): + """Test that same query with different facet filters returns different results.""" + result_active = { + "hits": [{"id": 1, "name": "Active Item"}], + "nbPages": 1, + } + result_inactive = { + "hits": [{"id": 2, "name": "Inactive Item"}], + "nbPages": 1, + } + base_params = { + "index_name": "projects", + "query": "security", + "page": 1, + "hits_per_page": 10, + } + + with patch( + "apps.core.api.internal.algolia.get_search_results", + side_effect=[result_active, result_inactive], + ): + mock_request_1 = self._build_request( + facet_filters=["idx_is_active:true"], + **base_params, + ) + mock_request_2 = self._build_request( + facet_filters=["idx_is_active:false"], + **base_params, + ) + + response_1 = algolia_search(mock_request_1) + response_2 = algolia_search(mock_request_2) + + response_data_1 = json.loads(response_1.content) + response_data_2 = json.loads(response_2.content) + + assert response_1.status_code == HTTPStatus.OK + assert response_2.status_code == HTTPStatus.OK + assert response_data_1 == result_active + assert response_data_2 == result_inactive + assert response_data_1 != response_data_2 + + def test_algolia_search_same_query_same_keys_return_same_results(self, mock_redis_cache): + """Test that cache hit returns same results without re-querying backend.""" + expected_result = MOCKED_SEARCH_RESULTS + facet_filters = ["idx_is_active:true"] + base_params = { + "facet_filters": facet_filters, + "index_name": "projects", + "query": "security", + "page": 1, + "hits_per_page": 10, + } + + mock_redis_cache.get.side_effect = [None, expected_result] + + with patch( + "apps.core.api.internal.algolia.get_search_results", return_value=expected_result + ) as mock_get_search_results: + mock_request = self._build_request(**base_params) + + response_1 = algolia_search(mock_request) + response_2 = algolia_search(mock_request) + + response_data_1 = json.loads(response_1.content) + response_data_2 = json.loads(response_2.content) + + assert response_1.status_code == HTTPStatus.OK + assert response_2.status_code == HTTPStatus.OK + assert response_data_1 == expected_result + assert response_data_2 == expected_result + # backend only called once = caching worked + mock_get_search_results.assert_called_once() diff --git a/backend/tests/apps/core/utils/index_test.py b/backend/tests/apps/core/utils/index_test.py index 1a3eb43957..ff0de925bc 100644 --- a/backend/tests/apps/core/utils/index_test.py +++ b/backend/tests/apps/core/utils/index_test.py @@ -2,7 +2,12 @@ from unittest.mock import MagicMock, patch -from apps.core.utils.index import DisableIndexing +from apps.core.utils.index import ( + DisableIndexing, + clear_index_cache, + deep_camelize, + get_params_for_index, +) class TestDisableIndexing: @@ -36,3 +41,160 @@ def test_disable_indexing_with_custom_app_names( mock_register.assert_not_called() mock_register.assert_called_once() + + +class TestDeepCamelize: + """Test the deep_camelize function.""" + + def test_deep_camelize_empty_input(self): + """Test that empty/None input returns as-is.""" + assert deep_camelize(None) is None + assert deep_camelize({}) == {} + assert deep_camelize([]) == [] + + def test_deep_camelize_dict(self): + """Test dictionary key conversion.""" + input_dict = {"snake_case": "value", "idx_another_key": "another"} + result = deep_camelize(input_dict) + + assert "snakeCase" in result + assert "anotherKey" in result + assert result["snakeCase"] == "value" + + def test_deep_camelize_nested_dict(self): + """Test nested dictionary conversion.""" + input_dict = { + "outer_key": { + "inner_key": "value", + } + } + result = deep_camelize(input_dict) + + assert "outerKey" in result + assert "innerKey" in result["outerKey"] + + def test_deep_camelize_list(self): + """Test list of dictionaries conversion.""" + input_list = [ + {"first_item": 1}, + {"second_item": 2}, + ] + result = deep_camelize(input_list) + + assert len(result) == 2 + assert "firstItem" in result[0] + assert "secondItem" in result[1] + + def test_deep_camelize_non_dict_non_list(self): + """Test that non-dict/list values are returned as-is.""" + assert deep_camelize("string") == "string" + assert deep_camelize(123) == 123 + + +class TestGetParamsForIndex: + """Test the get_params_for_index function.""" + + def test_get_params_for_issues(self): + """Test parameters for issues index.""" + params = get_params_for_index("issues") + + assert "attributesToRetrieve" in params + assert "idx_comments_count" in params["attributesToRetrieve"] + assert "idx_title" in params["attributesToRetrieve"] + assert params["distinct"] == 1 + + def test_get_params_for_chapters(self): + """Test parameters for chapters index.""" + params = get_params_for_index("chapters") + + assert "attributesToRetrieve" in params + assert "_geoloc" in params["attributesToRetrieve"] + assert "idx_region" in params["attributesToRetrieve"] + assert params["aroundLatLngViaIP"] + + def test_get_params_for_programs(self): + """Test parameters for programs index.""" + params = get_params_for_index("programs") + + assert "attributesToRetrieve" in params + assert "idx_name" in params["attributesToRetrieve"] + assert "idx_status" in params["attributesToRetrieve"] + + def test_get_params_for_projects(self): + """Test parameters for projects index.""" + params = get_params_for_index("projects") + + assert "attributesToRetrieve" in params + assert "idx_level" in params["attributesToRetrieve"] + assert "idx_languages" in params["attributesToRetrieve"] + + def test_get_params_for_committees(self): + """Test parameters for committees index.""" + params = get_params_for_index("committees") + + assert "attributesToRetrieve" in params + assert "idx_leaders" in params["attributesToRetrieve"] + + def test_get_params_for_users(self): + """Test parameters for users index.""" + params = get_params_for_index("users") + + assert "attributesToRetrieve" in params + assert "idx_login" in params["attributesToRetrieve"] + assert "idx_avatar_url" in params["attributesToRetrieve"] + + def test_get_params_for_organizations(self): + """Test parameters for organizations index.""" + params = get_params_for_index("organizations") + + assert "attributesToRetrieve" in params + assert "idx_location" in params["attributesToRetrieve"] + + def test_get_params_for_unknown_index(self): + """Test parameters for unknown index returns empty list.""" + params = get_params_for_index("unknown_index") + + assert params["attributesToRetrieve"] == [] + + def test_common_params_always_present(self): + """Test that common params are always present.""" + for index in ["issues", "chapters", "projects", "unknown"]: + params = get_params_for_index(index) + assert "attributesToHighlight" in params + assert "removeWordsIfNoResults" in params + assert "minProximity" in params + assert "typoTolerance" in params + + +class TestClearIndexCache: + """Test the clear_index_cache function.""" + + @patch("apps.core.utils.index.cache") + @patch("apps.core.utils.index.logger") + def test_clear_index_cache_no_index_name(self, mock_logger, mock_cache): + """Test that empty index name does nothing.""" + clear_index_cache("") + + mock_logger.info.assert_called_with("No index name provided, skipping cache clear.") + mock_cache.iter_keys.assert_not_called() + + @patch("apps.core.utils.index.cache") + @patch("apps.core.utils.index.logger") + def test_clear_index_cache_no_matching_keys(self, mock_logger, mock_cache): + """Test when no matching keys are found.""" + mock_cache.iter_keys.return_value = iter([]) + + clear_index_cache("projects") + + mock_logger.info.assert_called() + mock_cache.delete.assert_not_called() + + @patch("apps.core.utils.index.cache") + @patch("apps.core.utils.index.logger") + def test_clear_index_cache_with_matching_keys(self, mock_logger, mock_cache): + """Test that matching keys are deleted.""" + mock_cache.iter_keys.return_value = iter(["algolia:projects:1", "algolia:projects:2"]) + + clear_index_cache("projects") + + assert mock_cache.delete.call_count == 2 diff --git a/backend/tests/apps/github/management/commands/github_enrich_issues_test.py b/backend/tests/apps/github/management/commands/github_enrich_issues_test.py index 573a81c0d6..45fa48efad 100644 --- a/backend/tests/apps/github/management/commands/github_enrich_issues_test.py +++ b/backend/tests/apps/github/management/commands/github_enrich_issues_test.py @@ -162,11 +162,10 @@ def test_handle_with_offset(mock_issue_class, mock_open_ai_class): @patch("apps.github.management.commands.github_enrich_issues.OpenAi") @patch("apps.github.management.commands.github_enrich_issues.Issue") def test_handle_with_chunked_save(mock_issue_class, mock_open_ai_class): - """Tests that the command correctly saves issues in chunks of 1000.""" + """Tests that the command correctly processes multiple issues.""" mock_open_ai = MagicMock() mock_open_ai_class.return_value = mock_open_ai - - mock_issues = [MagicMock(title=f"Test Issue {i}") for i in range(1001)] + mock_issues = [MagicMock(title=f"Test Issue {i}") for i in range(50)] for issue in mock_issues: issue.generate_hint = MagicMock() issue.generate_summary = MagicMock() @@ -193,7 +192,7 @@ def test_handle_with_chunked_save(mock_issue_class, mock_open_ai_class): assert mock_issue_class.bulk_save.call_count == 1 args, kwargs = mock_issue_class.bulk_save.call_args_list[0] - assert len(args[0]) == 1001 + assert len(args[0]) == 50 assert kwargs["fields"] == ["hint", "summary"] diff --git a/backend/tests/apps/owasp/admin/mixins_test.py b/backend/tests/apps/owasp/admin/mixins_test.py index 1984219262..d9efdf6c1d 100644 --- a/backend/tests/apps/owasp/admin/mixins_test.py +++ b/backend/tests/apps/owasp/admin/mixins_test.py @@ -1,7 +1,10 @@ +from unittest.mock import patch + from django.contrib.admin import ModelAdmin from apps.owasp.admin.mixins import ( BaseOwaspAdminMixin, + EntityChannelInline, GenericEntityAdminMixin, StandardOwaspAdminMixin, ) @@ -90,6 +93,102 @@ class MockObj: urls = admin.custom_field_github_urls(obj) assert "href='https://github.com/owasp/main-repo'" in urls + def test_custom_field_github_urls_no_repository(self, mocker): + """Test custom_field_github_urls returns empty when no repositories or owasp_repository.""" + admin = self.MockGenericAdmin(Project, mocker.Mock()) + + class MockObj: + pass + + obj = MockObj() + obj.owasp_repository = None + + urls = admin.custom_field_github_urls(obj) + assert urls == "" + + def test_get_queryset_prefetches_repositories(self, mocker): + """Test get_queryset prefetches repositories.""" + admin = self.MockGenericAdmin(Project, mocker.Mock()) + + mock_queryset = mocker.Mock() + mock_queryset.prefetch_related.return_value = mock_queryset + + mocker.patch.object(ModelAdmin, "get_queryset", return_value=mock_queryset) + + result = admin.get_queryset(mocker.Mock()) + + mock_queryset.prefetch_related.assert_called_once_with("repositories") + assert result == mock_queryset + + def test_format_github_link_no_repository(self, mocker): + """Test _format_github_link returns empty string when repository is None.""" + admin = self.MockGenericAdmin(Project, mocker.Mock()) + assert admin._format_github_link(None) == "" + + def test_format_github_link_no_owner(self, mocker): + """Test _format_github_link returns empty string when owner is None.""" + admin = self.MockGenericAdmin(Project, mocker.Mock()) + mock_repo = mocker.Mock() + mock_repo.owner = None + assert admin._format_github_link(mock_repo) == "" + + def test_format_github_link_no_owner_login(self, mocker): + """Test _format_github_link returns empty string when owner.login is None.""" + admin = self.MockGenericAdmin(Project, mocker.Mock()) + mock_repo = mocker.Mock() + mock_repo.owner.login = None + assert admin._format_github_link(mock_repo) == "" + + def test_format_github_link_no_key(self, mocker): + """Test _format_github_link returns empty string when repository key is None.""" + admin = self.MockGenericAdmin(Project, mocker.Mock()) + mock_repo = mocker.Mock() + mock_repo.owner.login = "owasp" + mock_repo.key = None + assert admin._format_github_link(mock_repo) == "" + + +class TestEntityChannelInline: + """Tests for EntityChannelInline.""" + + def test_formfield_for_dbfield_channel_id(self, mocker): + """Test that channel_id field gets ChannelIdWidget.""" + from apps.owasp.admin.widgets import ChannelIdWidget + + inline = EntityChannelInline(Project, mocker.Mock()) + mock_db_field = mocker.Mock() + mock_db_field.name = "channel_id" + mock_request = mocker.Mock() + with patch.object( + EntityChannelInline.__bases__[0], "formfield_for_dbfield" + ) as mock_parent: + mock_parent.return_value = mocker.Mock() + inline.formfield_for_dbfield(mock_db_field, mock_request) + call_kwargs = mock_parent.call_args[1] + assert isinstance(call_kwargs.get("widget"), ChannelIdWidget) + + @patch("apps.owasp.admin.mixins.ContentType") + def test_formfield_for_dbfield_channel_type(self, mock_content_type, mocker): + """Test that channel_type field gets limited queryset.""" + inline = EntityChannelInline(Project, mocker.Mock()) + mock_db_field = mocker.Mock() + mock_db_field.name = "channel_type" + mock_request = mocker.Mock() + + mock_conversation_ct = mocker.Mock() + mock_conversation_ct.id = 1 + mock_content_type.objects.get_for_model.return_value = mock_conversation_ct + mock_content_type.objects.filter.return_value = mocker.Mock() + with patch.object( + EntityChannelInline.__bases__[0], "formfield_for_dbfield" + ) as mock_parent: + mock_parent.return_value = mocker.Mock() + inline.formfield_for_dbfield(mock_db_field, mock_request) + mock_content_type.objects.filter.assert_called_once_with(id=mock_conversation_ct.id) + call_kwargs = mock_parent.call_args[1] + assert "queryset" in call_kwargs + assert "initial" in call_kwargs + class TestStandardOwaspAdminMixin: """Tests for StandardOwaspAdminMixin.""" diff --git a/backend/tests/apps/owasp/api/internal/nodes/chapter_test.py b/backend/tests/apps/owasp/api/internal/nodes/chapter_test.py index 3b7cb91027..c82606073c 100644 --- a/backend/tests/apps/owasp/api/internal/nodes/chapter_test.py +++ b/backend/tests/apps/owasp/api/internal/nodes/chapter_test.py @@ -1,6 +1,9 @@ """Test cases for ChapterNode.""" -from apps.owasp.api.internal.nodes.chapter import ChapterNode +import math +from unittest.mock import Mock + +from apps.owasp.api.internal.nodes.chapter import ChapterNode, GeoLocationType from tests.apps.common.graphql_node_base_test import GraphQLNodeBaseTest @@ -84,3 +87,69 @@ def test_contribution_stats_transforms_snake_case_to_camel_case(self): assert result["releases"] == 5 assert result["total"] == 125 assert "pull_requests" not in result + + def test_created_at_resolver(self): + """Test created_at resolver uses idx_created_at.""" + mock_chapter = Mock() + mock_chapter.idx_created_at = 1672531200 + + field = self._get_field_by_name("created_at", ChapterNode) + result = field.base_resolver.wrapped_func(None, mock_chapter) + + assert result == 1672531200 + + def test_geo_location_resolver_with_coordinates(self): + """Test geo_location resolver with valid coordinates.""" + mock_chapter = Mock() + mock_chapter.latitude = 40.7128 + mock_chapter.longitude = -74.0060 + + field = self._get_field_by_name("geo_location", ChapterNode) + result = field.base_resolver.wrapped_func(None, mock_chapter) + + assert isinstance(result, GeoLocationType) + assert math.isclose(result.lat, 40.7128) + assert math.isclose(result.lng, -74.0060) + + def test_geo_location_resolver_without_coordinates(self): + """Test geo_location resolver returns None without coordinates.""" + mock_chapter = Mock() + mock_chapter.latitude = None + mock_chapter.longitude = None + + field = self._get_field_by_name("geo_location", ChapterNode) + result = field.base_resolver.wrapped_func(None, mock_chapter) + + assert result is None + + def test_key_resolver(self): + """Test key resolver uses idx_key.""" + mock_chapter = Mock() + mock_chapter.idx_key = "www-chapter-test" + + field = self._get_field_by_name("key", ChapterNode) + result = field.base_resolver.wrapped_func(None, mock_chapter) + + assert result == "www-chapter-test" + + def test_suggested_location_resolver(self): + """Test suggested_location resolver uses idx_suggested_location.""" + mock_chapter = Mock() + mock_chapter.idx_suggested_location = "New York, USA" + + field = self._get_field_by_name("suggested_location", ChapterNode) + result = field.base_resolver.wrapped_func(None, mock_chapter) + + assert result == "New York, USA" + + def test_suggested_location_resolver_none(self): + """Test suggested_location resolver when None.""" + from unittest.mock import Mock + + mock_chapter = Mock() + mock_chapter.idx_suggested_location = None + + field = self._get_field_by_name("suggested_location", ChapterNode) + result = field.base_resolver.wrapped_func(None, mock_chapter) + + assert result is None diff --git a/backend/tests/apps/owasp/api/internal/nodes/project_health_metrics_test.py b/backend/tests/apps/owasp/api/internal/nodes/project_health_metrics_test.py index 4f282b4c18..0f9a944724 100644 --- a/backend/tests/apps/owasp/api/internal/nodes/project_health_metrics_test.py +++ b/backend/tests/apps/owasp/api/internal/nodes/project_health_metrics_test.py @@ -1,6 +1,7 @@ """Test cases for ProjectHealthMetricsNode.""" -from datetime import datetime +from datetime import UTC, datetime +from unittest.mock import MagicMock import pytest @@ -74,3 +75,144 @@ def test_field_types(self, field_name, expected_type): field = self._get_field_by_name(field_name, ProjectHealthMetricsNode) assert field is not None assert field.type is expected_type + + +class TestProjectHealthMetricsNodeResolvers: + """Test ProjectHealthMetricsNode resolver execution.""" + + def _get_resolver(self, field_name): + """Get the resolver function for a field.""" + for field in ProjectHealthMetricsNode.__strawberry_definition__.fields: + if field.name == field_name: + return field.base_resolver.wrapped_func if field.base_resolver else None + return None + + def test_age_days_resolver(self): + """Test age_days resolver returns root.age_days.""" + resolver = self._get_resolver("age_days") + mock_metrics = MagicMock() + mock_metrics.age_days = 365 + + result = resolver(None, mock_metrics) + + assert result == 365 + + def test_age_days_requirement_resolver(self): + """Test age_days_requirement resolver.""" + resolver = self._get_resolver("age_days_requirement") + mock_metrics = MagicMock() + mock_metrics.age_days_requirement = 180 + + result = resolver(None, mock_metrics) + + assert result == 180 + + def test_created_at_resolver(self): + """Test created_at resolver returns nest_created_at.""" + resolver = self._get_resolver("created_at") + mock_metrics = MagicMock() + mock_metrics.nest_created_at = datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC) + + result = resolver(None, mock_metrics) + + assert result == datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC) + + def test_last_commit_days_resolver(self): + """Test last_commit_days resolver.""" + resolver = self._get_resolver("last_commit_days") + mock_metrics = MagicMock() + mock_metrics.last_commit_days = 30 + + result = resolver(None, mock_metrics) + + assert result == 30 + + def test_last_commit_days_requirement_resolver(self): + """Test last_commit_days_requirement resolver.""" + resolver = self._get_resolver("last_commit_days_requirement") + mock_metrics = MagicMock() + mock_metrics.last_commit_days_requirement = 60 + + result = resolver(None, mock_metrics) + + assert result == 60 + + def test_last_pull_request_days_resolver(self): + """Test last_pull_request_days resolver.""" + resolver = self._get_resolver("last_pull_request_days") + mock_metrics = MagicMock() + mock_metrics.last_pull_request_days = 15 + + result = resolver(None, mock_metrics) + + assert result == 15 + + def test_last_pull_request_days_requirement_resolver(self): + """Test last_pull_request_days_requirement resolver.""" + resolver = self._get_resolver("last_pull_request_days_requirement") + mock_metrics = MagicMock() + mock_metrics.last_pull_request_days_requirement = 45 + + result = resolver(None, mock_metrics) + + assert result == 45 + + def test_last_release_days_resolver(self): + """Test last_release_days resolver.""" + resolver = self._get_resolver("last_release_days") + mock_metrics = MagicMock() + mock_metrics.last_release_days = 90 + + result = resolver(None, mock_metrics) + + assert result == 90 + + def test_last_release_days_requirement_resolver(self): + """Test last_release_days_requirement resolver.""" + resolver = self._get_resolver("last_release_days_requirement") + mock_metrics = MagicMock() + mock_metrics.last_release_days_requirement = 180 + + result = resolver(None, mock_metrics) + + assert result == 180 + + def test_project_key_resolver(self): + """Test project_key resolver returns project.nest_key.""" + resolver = self._get_resolver("project_key") + mock_metrics = MagicMock() + mock_metrics.project.nest_key = "www-project-test" + + result = resolver(None, mock_metrics) + + assert result == "www-project-test" + + def test_project_name_resolver(self): + """Test project_name resolver returns project.name.""" + resolver = self._get_resolver("project_name") + mock_metrics = MagicMock() + mock_metrics.project.name = "Test Project" + + result = resolver(None, mock_metrics) + + assert result == "Test Project" + + def test_owasp_page_last_update_days_resolver(self): + """Test owasp_page_last_update_days resolver.""" + resolver = self._get_resolver("owasp_page_last_update_days") + mock_metrics = MagicMock() + mock_metrics.owasp_page_last_update_days = 45 + + result = resolver(None, mock_metrics) + + assert result == 45 + + def test_owasp_page_last_update_days_requirement_resolver(self): + """Test owasp_page_last_update_days_requirement resolver.""" + resolver = self._get_resolver("owasp_page_last_update_days_requirement") + mock_metrics = MagicMock() + mock_metrics.owasp_page_last_update_days_requirement = 90 + + result = resolver(None, mock_metrics) + + assert result == 90 diff --git a/backend/tests/apps/owasp/api/internal/nodes/project_test.py b/backend/tests/apps/owasp/api/internal/nodes/project_test.py index 4a57206e31..4c05be4f3f 100644 --- a/backend/tests/apps/owasp/api/internal/nodes/project_test.py +++ b/backend/tests/apps/owasp/api/internal/nodes/project_test.py @@ -1,5 +1,7 @@ """Test cases for ProjectNode.""" +from unittest.mock import MagicMock + from apps.github.api.internal.nodes.issue import IssueNode from apps.github.api.internal.nodes.milestone import MilestoneNode from apps.github.api.internal.nodes.pull_request import PullRequestNode @@ -138,3 +140,119 @@ def test_contribution_stats_transforms_snake_case_to_camel_case(self): assert result["releases"] == 10 assert result["total"] == 185 assert "pull_requests" not in result + + +class TestProjectNodeResolvers: + """Test ProjectNode resolver execution.""" + + def _get_resolver(self, field_name): + """Get the resolver function for a field.""" + for field in ProjectNode.__strawberry_definition__.fields: + if field.name == field_name: + return field.base_resolver.wrapped_func if field.base_resolver else None + return None + + def test_health_metrics_list_with_invalid_limit(self): + """Test health_metrics_list returns empty list for invalid limit.""" + resolver = self._get_resolver("health_metrics_list") + mock_project = MagicMock() + + result = resolver(None, mock_project, limit=0) + assert result == [] + + result = resolver(None, mock_project, limit=-5) + assert result == [] + + def test_health_metrics_list_with_valid_limit(self): + """Test health_metrics_list returns metrics with valid limit.""" + resolver = self._get_resolver("health_metrics_list") + mock_project = MagicMock() + mock_metrics = [MagicMock(), MagicMock()] + mock_sliced = MagicMock() + mock_sliced.__reversed__ = lambda _: iter(mock_metrics) + mock_project.health_metrics.order_by.return_value.__getitem__.return_value = mock_sliced + + result = resolver(None, mock_project, limit=10) + + mock_project.health_metrics.order_by.assert_called_once_with("-nest_created_at") + assert result == mock_metrics + + def test_health_metrics_latest(self): + """Test health_metrics_latest returns latest metric.""" + resolver = self._get_resolver("health_metrics_latest") + mock_project = MagicMock() + mock_latest = MagicMock() + mock_project.health_metrics.order_by.return_value.first.return_value = mock_latest + + result = resolver(None, mock_project) + + mock_project.health_metrics.order_by.assert_called_once_with("-nest_created_at") + assert result == mock_latest + + def test_health_metrics_latest_none(self): + """Test health_metrics_latest returns None when no metrics.""" + resolver = self._get_resolver("health_metrics_latest") + mock_project = MagicMock() + mock_project.health_metrics.order_by.return_value.first.return_value = None + + result = resolver(None, mock_project) + + assert result is None + + def test_recent_milestones_with_invalid_limit(self): + """Test recent_milestones returns empty list for invalid limit.""" + resolver = self._get_resolver("recent_milestones") + mock_project = MagicMock() + + result = resolver(None, mock_project, limit=0) + assert result == [] + + def test_issues_count(self): + """Test issues_count resolver returns idx_issues_count.""" + resolver = self._get_resolver("issues_count") + mock_project = MagicMock() + mock_project.idx_issues_count = 42 + + result = resolver(None, mock_project) + + assert result == 42 + + def test_key(self): + """Test key resolver returns idx_key.""" + resolver = self._get_resolver("key") + mock_project = MagicMock() + mock_project.idx_key = "test-project" + + result = resolver(None, mock_project) + + assert result == "test-project" + + def test_languages(self): + """Test languages resolver returns idx_languages.""" + resolver = self._get_resolver("languages") + mock_project = MagicMock() + mock_project.idx_languages = ["Python", "JavaScript"] + + result = resolver(None, mock_project) + + assert result == ["Python", "JavaScript"] + + def test_repositories_count(self): + """Test repositories_count resolver returns idx_repositories_count.""" + resolver = self._get_resolver("repositories_count") + mock_project = MagicMock() + mock_project.idx_repositories_count = 5 + + result = resolver(None, mock_project) + + assert result == 5 + + def test_topics(self): + """Test topics resolver returns idx_topics.""" + resolver = self._get_resolver("topics") + mock_project = MagicMock() + mock_project.idx_topics = ["security", "owasp"] + + result = resolver(None, mock_project) + + assert result == ["security", "owasp"] diff --git a/backend/tests/apps/owasp/api/internal/nodes/snapshot_test.py b/backend/tests/apps/owasp/api/internal/nodes/snapshot_test.py new file mode 100644 index 0000000000..bbec20d6f0 --- /dev/null +++ b/backend/tests/apps/owasp/api/internal/nodes/snapshot_test.py @@ -0,0 +1,100 @@ +"""Test cases for SnapshotNode.""" + +from unittest.mock import MagicMock + +from apps.owasp.api.internal.nodes.snapshot import SnapshotNode +from tests.apps.common.graphql_node_base_test import GraphQLNodeBaseTest + + +class TestSnapshotNode(GraphQLNodeBaseTest): + """Test cases for SnapshotNode.""" + + def test_snapshot_node_inheritance(self): + """Test SnapshotNode has strawberry definition.""" + assert hasattr(SnapshotNode, "__strawberry_definition__") + + def test_meta_configuration(self): + """Test expected fields are present.""" + field_names = {field.name for field in SnapshotNode.__strawberry_definition__.fields} + expected_field_names = { + "created_at", + "end_at", + "key", + "new_chapters", + "new_issues", + "new_projects", + "new_releases", + "new_users", + "start_at", + "title", + } + assert expected_field_names.issubset(field_names) + + +class TestSnapshotNodeResolvers: + """Test SnapshotNode resolver execution.""" + + def _get_resolver(self, field_name): + """Get the resolver function for a field.""" + for field in SnapshotNode.__strawberry_definition__.fields: + if field.name == field_name: + return field.base_resolver.wrapped_func if field.base_resolver else None + return None + + def test_key_resolver(self): + """Test key resolver returns snapshot key.""" + resolver = self._get_resolver("key") + mock_snapshot = MagicMock() + mock_snapshot.key = "2025-02" + + result = resolver(None, mock_snapshot) + + assert result == "2025-02" + + def test_new_issues_resolver(self): + """Test new_issues resolver returns ordered issues.""" + resolver = self._get_resolver("new_issues") + mock_snapshot = MagicMock() + mock_issues = [MagicMock(), MagicMock()] + mock_snapshot.new_issues.order_by.return_value.__getitem__.return_value = mock_issues + + result = resolver(None, mock_snapshot) + + mock_snapshot.new_issues.order_by.assert_called_once_with("-created_at") + assert result == mock_issues + + def test_new_projects_resolver(self): + """Test new_projects resolver returns ordered projects.""" + resolver = self._get_resolver("new_projects") + mock_snapshot = MagicMock() + mock_projects = [MagicMock(), MagicMock()] + mock_snapshot.new_projects.order_by.return_value = mock_projects + + result = resolver(None, mock_snapshot) + + mock_snapshot.new_projects.order_by.assert_called_once_with("-created_at") + assert result == mock_projects + + def test_new_releases_resolver(self): + """Test new_releases resolver returns ordered releases.""" + resolver = self._get_resolver("new_releases") + mock_snapshot = MagicMock() + mock_releases = [MagicMock(), MagicMock()] + mock_snapshot.new_releases.order_by.return_value = mock_releases + + result = resolver(None, mock_snapshot) + + mock_snapshot.new_releases.order_by.assert_called_once_with("-published_at") + assert result == mock_releases + + def test_new_users_resolver(self): + """Test new_users resolver returns ordered users.""" + resolver = self._get_resolver("new_users") + mock_snapshot = MagicMock() + mock_users = [MagicMock(), MagicMock()] + mock_snapshot.new_users.order_by.return_value = mock_users + + result = resolver(None, mock_snapshot) + + mock_snapshot.new_users.order_by.assert_called_once_with("-created_at") + assert result == mock_users diff --git a/backend/tests/apps/owasp/api/internal/queries/chapter_test.py b/backend/tests/apps/owasp/api/internal/queries/chapter_test.py index e0d389ec6a..c0966266fc 100644 --- a/backend/tests/apps/owasp/api/internal/queries/chapter_test.py +++ b/backend/tests/apps/owasp/api/internal/queries/chapter_test.py @@ -60,3 +60,11 @@ def test_recent_chapters_query(self, mock_info): with patch.object(query, "recent_chapters", return_value=mock_chapters): result = query.recent_chapters(limit=2) assert result == mock_chapters + + def test_recent_chapters_with_invalid_limit(self, mock_info): + """Test recent_chapters with invalid limit returns empty list.""" + query = ChapterQuery() + result = query.recent_chapters(limit=0) + assert result == [] + result = query.recent_chapters(limit=-1) + assert result == [] diff --git a/backend/tests/apps/owasp/api/internal/queries/committee_test.py b/backend/tests/apps/owasp/api/internal/queries/committee_test.py index 4a07593290..ff093688f8 100644 --- a/backend/tests/apps/owasp/api/internal/queries/committee_test.py +++ b/backend/tests/apps/owasp/api/internal/queries/committee_test.py @@ -1,5 +1,8 @@ +from unittest.mock import Mock, patch + from apps.owasp.api.internal.nodes.committee import CommitteeNode from apps.owasp.api.internal.queries.committee import CommitteeQuery +from apps.owasp.models.committee import Committee class TestCommitteeQuery: @@ -27,3 +30,29 @@ def test_committee_field_configuration(self): key_arg = next(arg for arg in committee_field.arguments if arg.python_name == "key") assert key_arg.type_annotation.annotation is str + + +class TestCommitteeResolution: + """Test cases for committee resolution methods.""" + + def test_committee_found(self): + """Test if a committee is returned when found.""" + mock_committee = Mock(spec=Committee) + + with patch("apps.owasp.models.committee.Committee.objects.get") as mock_get: + mock_get.return_value = mock_committee + + result = CommitteeQuery().committee(key="test-committee") + + assert result == mock_committee + mock_get.assert_called_once_with(key="www-committee-test-committee") + + def test_committee_not_found(self): + """Test if None is returned when the committee is not found.""" + with patch("apps.owasp.models.committee.Committee.objects.get") as mock_get: + mock_get.side_effect = Committee.DoesNotExist + + result = CommitteeQuery().committee(key="non-existent") + + assert result is None + mock_get.assert_called_once_with(key="www-committee-non-existent") diff --git a/backend/tests/apps/owasp/api/internal/queries/event_test.py b/backend/tests/apps/owasp/api/internal/queries/event_test.py new file mode 100644 index 0000000000..b2c16b3c55 --- /dev/null +++ b/backend/tests/apps/owasp/api/internal/queries/event_test.py @@ -0,0 +1,36 @@ +"""Test cases for EventQuery.""" + +from unittest.mock import Mock, patch + +from apps.owasp.api.internal.queries.event import EventQuery + + +class TestEventQuery: + """Test cases for EventQuery class.""" + + def test_event_query_has_strawberry_definition(self): + """Test if EventQuery is a valid Strawberry type.""" + assert hasattr(EventQuery, "__strawberry_definition__") + + field_names = [field.name for field in EventQuery.__strawberry_definition__.fields] + assert "upcoming_events" in field_names + + def test_upcoming_events_valid_limit(self): + """Test upcoming_events with valid limit.""" + mock_events = [Mock(), Mock()] + + with patch("apps.owasp.models.event.Event.upcoming_events") as mock_upcoming: + mock_upcoming.return_value.__getitem__ = Mock(return_value=mock_events) + + query = EventQuery() + query.upcoming_events(limit=5) + + assert mock_upcoming.called + + def test_upcoming_events_invalid_limit(self): + """Test upcoming_events with invalid limit returns empty list.""" + query = EventQuery() + result = query.upcoming_events(limit=0) + assert result == [] + result = query.upcoming_events(limit=-1) + assert result == [] diff --git a/backend/tests/apps/owasp/api/internal/queries/post_test.py b/backend/tests/apps/owasp/api/internal/queries/post_test.py new file mode 100644 index 0000000000..d0181f0ce8 --- /dev/null +++ b/backend/tests/apps/owasp/api/internal/queries/post_test.py @@ -0,0 +1,38 @@ +"""Test cases for PostQuery.""" + +from unittest.mock import Mock, patch + +from apps.owasp.api.internal.queries.post import PostQuery + + +class TestPostQuery: + """Test cases for PostQuery class.""" + + def test_post_query_has_strawberry_definition(self): + """Test if PostQuery is a valid Strawberry type.""" + assert hasattr(PostQuery, "__strawberry_definition__") + + field_names = [field.name for field in PostQuery.__strawberry_definition__.fields] + assert "recent_posts" in field_names + + def test_recent_posts_valid_limit(self): + """Test recent_posts with valid limit.""" + mock_posts = [Mock(), Mock()] + + with patch("apps.owasp.models.post.Post.recent_posts") as mock_recent: + mock_recent.return_value.__getitem__ = Mock(return_value=mock_posts) + + query = PostQuery() + result = query.recent_posts(limit=5) + + assert mock_recent.called + mock_recent.return_value.__getitem__.assert_called_once_with(slice(None, 5)) + assert result == mock_posts + + def test_recent_posts_invalid_limit(self): + """Test recent_posts with invalid limit returns empty list.""" + query = PostQuery() + result = query.recent_posts(limit=0) + assert result == [] + result = query.recent_posts(limit=-1) + assert result == [] diff --git a/backend/tests/apps/owasp/management/commands/common/entity_metadata_test.py b/backend/tests/apps/owasp/management/commands/common/entity_metadata_test.py index 436e5ed570..20c929d57a 100644 --- a/backend/tests/apps/owasp/management/commands/common/entity_metadata_test.py +++ b/backend/tests/apps/owasp/management/commands/common/entity_metadata_test.py @@ -78,3 +78,21 @@ def test_handle_entity_not_found(self): model_class.objects.get.assert_called_once_with(key="unknown-key") assert "TestModel with key 'unknown-key' not found" in stderr.getvalue() + + @patch("apps.owasp.management.commands.common.entity_metadata.validate_data") + @patch("apps.owasp.management.commands.common.entity_metadata.get_schema") + def test_handle_validation_failure(self, mock_get_schema, mock_validate): + """Test handle method when validation fails.""" + mock_validate.return_value = "Validation error: missing required field" + + command_instance = ConcreteTestCommand() + entity_instance = MagicMock() + command_instance.model.objects.get.return_value = entity_instance + command_instance.get_metadata.return_value = {"invalid": "data"} + + stderr = io.StringIO() + call_command(command_instance, "test-key", stderr=stderr) + + error_output = stderr.getvalue() + assert "Validation FAILED" in error_output + assert "Validation error: missing required field" in error_output diff --git a/backend/tests/apps/owasp/management/commands/owasp_aggregate_entity_contributions_test.py b/backend/tests/apps/owasp/management/commands/owasp_aggregate_entity_contributions_test.py index b4c4999bf8..a46db8b373 100644 --- a/backend/tests/apps/owasp/management/commands/owasp_aggregate_entity_contributions_test.py +++ b/backend/tests/apps/owasp/management/commands/owasp_aggregate_entity_contributions_test.py @@ -442,3 +442,85 @@ def test_handle_custom_days( # Allow 1 second tolerance for test execution time. assert abs((expected_start - start_date).total_seconds()) < 1 + + def test_calculate_contribution_stats_no_repositories(self, command, mock_chapter): + """Test calculate_contribution_stats when entity has no repositories.""" + mock_chapter.owasp_repository = None + start_date = datetime.now(tz=UTC) - timedelta(days=365) + + result = command.calculate_contribution_stats(mock_chapter, start_date) + assert result == { + "commits": 0, + "issues": 0, + "pull_requests": 0, + "releases": 0, + "total": 0, + } + + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Project") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Release") + def test_handle_project_with_key_filter( + self, + mock_release, + mock_pr, + mock_issue, + mock_commit, + mock_project_model, + command, + mock_project, + ): + """Test projects with key filter.""" + mock_project_model.objects.filter.return_value = MockQuerySet([mock_project]) + mock_project_model.bulk_save = mock.Mock() + + mock_commit.objects.filter.return_value.count.return_value = 0 + mock_issue.objects.filter.return_value.count.return_value = 0 + mock_pr.objects.filter.return_value.count.return_value = 0 + mock_release.objects.filter.return_value.count.return_value = 0 + + with mock.patch.object( + command, + "aggregate_contributions", + return_value={}, + ): + command.handle(entity_type="project", key="www-project-test", days=365, offset=0) + + mock_project_model.objects.filter.assert_called() + mock_project_model.bulk_save.assert_called() + + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Project") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Release") + def test_handle_project_with_offset( + self, + mock_release, + mock_pr, + mock_issue, + mock_commit, + mock_project_model, + command, + mock_project, + ): + """Test projects with offset.""" + projects = [mock_project, mock_project, mock_project] + mock_project_model.objects.filter.return_value = MockQuerySet(projects) + mock_project_model.bulk_save = mock.Mock() + + mock_commit.objects.filter.return_value.count.return_value = 0 + mock_issue.objects.filter.return_value.count.return_value = 0 + mock_pr.objects.filter.return_value.count.return_value = 0 + mock_release.objects.filter.return_value.count.return_value = 0 + + with mock.patch.object( + command, + "aggregate_contributions", + return_value={}, + ) as mock_aggregate: + command.handle(entity_type="project", offset=2, days=365) + assert mock_aggregate.call_count == 1 + mock_project_model.bulk_save.assert_called_once() diff --git a/backend/tests/apps/owasp/management/commands/owasp_aggregate_projects_test.py b/backend/tests/apps/owasp/management/commands/owasp_aggregate_projects_test.py index bc12eb4c6f..74cf49a5f7 100644 --- a/backend/tests/apps/owasp/management/commands/owasp_aggregate_projects_test.py +++ b/backend/tests/apps/owasp/management/commands/owasp_aggregate_projects_test.py @@ -5,6 +5,13 @@ from apps.owasp.management.commands.owasp_aggregate_projects import Command, Project +class MockQuerySet(list): + """Helper class to simulate a QuerySet with exists() method.""" + + def exists(self): + return bool(self) + + class TestOwaspAggregateProjects: @pytest.fixture def command(self): @@ -56,12 +63,7 @@ def test_handle(self, mock_bulk_save, command, mock_project, offset, projects): mock_repository.topics = ["security", "owasp"] mock_project.repositories.all.return_value = [mock_repository] - - class QS(list): - def exists(self): - return bool(self) - - mock_project.repositories.filter.return_value = QS([mock_repository]) + mock_project.repositories.filter.return_value = MockQuerySet([mock_repository]) mock_projects_list = [mock_project] * projects mock_active_projects = mock.MagicMock() mock_active_projects.__iter__.return_value = iter(mock_projects_list) @@ -85,3 +87,91 @@ def exists(self): for call in mock_print.call_args_list: args, _ = call assert "https://owasp.org/www-project-test" in args[0] + + @mock.patch.dict("os.environ", {"GITHUB_TOKEN": "test-token"}) + @mock.patch.object(Project, "bulk_save", autospec=True) + def test_handle_deactivates_archived_project(self, mock_bulk_save, command, mock_project): + """Test that project with archived repository is deactivated.""" + mock_project.owasp_repository.is_archived = True + + mock_organization = mock.Mock() + mock_repository = mock.Mock() + mock_repository.organization = mock_organization + mock_repository.owner = mock.Mock() + mock_repository.is_archived = False + mock_repository.pushed_at = "2024-12-28T00:00:00Z" + mock_repository.latest_release = None + mock_repository.commits_count = 10 + mock_repository.contributors_count = 5 + mock_repository.forks_count = 2 + mock_repository.open_issues_count = 4 + mock_repository.releases.count.return_value = 0 + mock_repository.stars_count = 50 + mock_repository.subscribers_count = 3 + mock_repository.watchers_count = 7 + mock_repository.top_languages = [] + mock_repository.license = None + mock_repository.topics = None + + mock_project.repositories.all.return_value = [mock_repository] + mock_project.repositories.filter.return_value = MockQuerySet([mock_repository]) + mock_projects_list = [mock_project] + mock_active_projects = mock.MagicMock() + mock_active_projects.__iter__.return_value = iter(mock_projects_list) + mock_active_projects.count.return_value = 1 + mock_active_projects.__getitem__.side_effect = lambda idx: ( + mock_projects_list[idx.start : idx.stop] + if isinstance(idx, slice) + else mock_projects_list[idx] + ) + mock_active_projects.order_by.return_value = mock_active_projects + + with ( + mock.patch.object(Project, "active_projects", mock_active_projects), + mock.patch("builtins.print"), + ): + command.handle(offset=0) + + assert not mock_project.is_active + assert mock_bulk_save.called + + @mock.patch.dict("os.environ", {"GITHUB_TOKEN": "test-token"}) + @mock.patch.object(Project, "bulk_save", autospec=True) + def test_handle_no_release_or_license(self, mock_bulk_save, command, mock_project): + """Test handle when repository has no latest_release or license.""" + mock_repository = mock.Mock() + mock_repository.organization = None + mock_repository.owner = mock.Mock() + mock_repository.is_archived = False + mock_repository.pushed_at = "2024-12-28T00:00:00Z" + mock_repository.latest_release = None + mock_repository.commits_count = 10 + mock_repository.contributors_count = 5 + mock_repository.forks_count = 2 + mock_repository.open_issues_count = 4 + mock_repository.releases.count.return_value = 0 + mock_repository.stars_count = 50 + mock_repository.subscribers_count = 3 + mock_repository.watchers_count = 7 + mock_repository.top_languages = [] + mock_repository.license = None + mock_repository.topics = None + mock_project.repositories.all.return_value = [mock_repository] + mock_project.repositories.filter.return_value = MockQuerySet([mock_repository]) + mock_projects_list = [mock_project] + mock_active_projects = mock.MagicMock() + mock_active_projects.__iter__.return_value = iter(mock_projects_list) + mock_active_projects.count.return_value = 1 + mock_active_projects.__getitem__.side_effect = lambda idx: ( + mock_projects_list[idx.start : idx.stop] + if isinstance(idx, slice) + else mock_projects_list[idx] + ) + mock_active_projects.order_by.return_value = mock_active_projects + + with ( + mock.patch.object(Project, "active_projects", mock_active_projects), + mock.patch("builtins.print"), + ): + command.handle(offset=0) + assert mock_bulk_save.called diff --git a/backend/tests/apps/owasp/management/commands/owasp_create_member_snapshot_test.py b/backend/tests/apps/owasp/management/commands/owasp_create_member_snapshot_test.py index 281cc5c8c9..6bd04dfffa 100644 --- a/backend/tests/apps/owasp/management/commands/owasp_create_member_snapshot_test.py +++ b/backend/tests/apps/owasp/management/commands/owasp_create_member_snapshot_test.py @@ -500,3 +500,575 @@ def test_generate_repository_contributions_empty_commits(self, command): result = command.generate_repository_contributions([], start_at, end_at) assert result == {} + + +class TestHandleMethod: + """Tests for the handle() method of owasp_create_member_snapshot command.""" + + target_module = "apps.owasp.management.commands.owasp_create_member_snapshot" + + @pytest.fixture + def command(self): + cmd = Command() + cmd.stdout = mock.MagicMock() + cmd.stderr = mock.MagicMock() + cmd.style = mock.MagicMock() + cmd.style.ERROR = lambda x: x + cmd.style.WARNING = lambda x: x + cmd.style.SUCCESS = lambda x: x + return cmd + + def test_handle_user_not_found(self, command, mocker): + """Test handle when user is not found.""" + mock_user = mocker.patch(f"{self.target_module}.User") + mock_user.DoesNotExist = Exception + mock_user.objects.get.side_effect = mock_user.DoesNotExist + + command.handle(username="nonexistent", start_at=None, end_at=None) + + command.stderr.write.assert_called() + + def test_handle_creates_new_snapshot(self, command, mocker): + """Test handle creates a new snapshot when none exists.""" + mock_user = mocker.patch(f"{self.target_module}.User") + mock_user_instance = mock.MagicMock() + mock_user.objects.get.return_value = mock_user_instance + + mock_snapshot_model = mocker.patch(f"{self.target_module}.MemberSnapshot") + mock_snapshot_model.objects.filter.return_value.first.return_value = None + + mock_snapshot = mock.MagicMock() + mock_snapshot.id = 1 + mock_snapshot.total_contributions = 0 + mock_snapshot.commits_count = 0 + mock_snapshot.pull_requests_count = 0 + mock_snapshot.issues_count = 0 + mock_snapshot.messages_count = 0 + mock_snapshot_model.objects.create.return_value = mock_snapshot + + mock_commit = mocker.patch(f"{self.target_module}.Commit") + mock_commit.objects.filter.return_value.count.return_value = 0 + + mock_pr = mocker.patch(f"{self.target_module}.PullRequest") + mock_pr.objects.filter.return_value.count.return_value = 0 + + mock_issue = mocker.patch(f"{self.target_module}.Issue") + mock_issue.objects.filter.return_value.count.return_value = 0 + + mock_profile = mocker.patch(f"{self.target_module}.MemberProfile") + mock_profile.DoesNotExist = Exception + mock_profile.objects.get.side_effect = mock_profile.DoesNotExist + + mocker.patch.object(command, "generate_heatmap_data", return_value={}) + mocker.patch.object(command, "generate_entity_contributions", return_value={}) + mocker.patch.object(command, "generate_repository_contributions", return_value={}) + mocker.patch.object(command, "generate_communication_heatmap_data", return_value={}) + + command.handle(username="testuser", start_at=None, end_at=None) + + mock_snapshot_model.objects.create.assert_called_once() + + def test_handle_updates_existing_snapshot(self, command, mocker): + """Test handle updates an existing snapshot.""" + mock_user = mocker.patch(f"{self.target_module}.User") + mock_user_instance = mock.MagicMock() + mock_user.objects.get.return_value = mock_user_instance + + mock_snapshot_model = mocker.patch(f"{self.target_module}.MemberSnapshot") + mock_existing_snapshot = mock.MagicMock() + mock_existing_snapshot.id = 99 + mock_existing_snapshot.total_contributions = 0 + mock_existing_snapshot.commits_count = 0 + mock_existing_snapshot.pull_requests_count = 0 + mock_existing_snapshot.issues_count = 0 + mock_existing_snapshot.messages_count = 0 + mock_snapshot_model.objects.filter.return_value.first.return_value = mock_existing_snapshot + + mock_commit = mocker.patch(f"{self.target_module}.Commit") + mock_commit.objects.filter.return_value.count.return_value = 0 + + mock_pr = mocker.patch(f"{self.target_module}.PullRequest") + mock_pr.objects.filter.return_value.count.return_value = 0 + + mock_issue = mocker.patch(f"{self.target_module}.Issue") + mock_issue.objects.filter.return_value.count.return_value = 0 + + mock_profile = mocker.patch(f"{self.target_module}.MemberProfile") + mock_profile.DoesNotExist = Exception + mock_profile.objects.get.side_effect = mock_profile.DoesNotExist + + mocker.patch.object(command, "generate_heatmap_data", return_value={}) + mocker.patch.object(command, "generate_entity_contributions", return_value={}) + mocker.patch.object(command, "generate_repository_contributions", return_value={}) + mocker.patch.object(command, "generate_communication_heatmap_data", return_value={}) + + command.handle(username="testuser", start_at=None, end_at=None) + + mock_existing_snapshot.commits.clear.assert_called_once() + mock_existing_snapshot.pull_requests.clear.assert_called_once() + mock_existing_snapshot.issues.clear.assert_called_once() + mock_existing_snapshot.messages.clear.assert_called_once() + + def test_handle_with_contributions(self, command, mocker): + """Test handle with actual contributions linked.""" + mock_user = mocker.patch(f"{self.target_module}.User") + mock_user_instance = mock.MagicMock() + mock_user.objects.get.return_value = mock_user_instance + + mock_snapshot_model = mocker.patch(f"{self.target_module}.MemberSnapshot") + mock_snapshot_model.objects.filter.return_value.first.return_value = None + + mock_snapshot = mock.MagicMock() + mock_snapshot.id = 1 + mock_snapshot.total_contributions = 10 + mock_snapshot.commits_count = 5 + mock_snapshot.pull_requests_count = 3 + mock_snapshot.issues_count = 2 + mock_snapshot.messages_count = 0 + mock_snapshot_model.objects.create.return_value = mock_snapshot + + mock_commit = mocker.patch(f"{self.target_module}.Commit") + mock_commit_qs = mock.MagicMock() + mock_commit_qs.count.return_value = 5 + mock_commit_qs.__iter__ = lambda _: iter([]) + mock_commit.objects.filter.return_value = mock_commit_qs + + mock_pr = mocker.patch(f"{self.target_module}.PullRequest") + mock_pr_qs = mock.MagicMock() + mock_pr_qs.count.return_value = 3 + mock_pr_qs.__iter__ = lambda _: iter([]) + mock_pr.objects.filter.return_value = mock_pr_qs + + mock_issue = mocker.patch(f"{self.target_module}.Issue") + mock_issue_qs = mock.MagicMock() + mock_issue_qs.count.return_value = 2 + mock_issue_qs.__iter__ = lambda _: iter([]) + mock_issue.objects.filter.return_value = mock_issue_qs + + mock_profile = mocker.patch(f"{self.target_module}.MemberProfile") + mock_profile.DoesNotExist = Exception + mock_profile.objects.get.side_effect = mock_profile.DoesNotExist + + mocker.patch.object(command, "generate_heatmap_data", return_value={"2025-01-01": 1}) + mocker.patch.object(command, "generate_entity_contributions", return_value={"project": 1}) + mocker.patch.object(command, "generate_repository_contributions", return_value={"repo": 1}) + mocker.patch.object(command, "generate_communication_heatmap_data", return_value={}) + + command.handle(username="testuser", start_at=None, end_at=None) + + mock_snapshot.commits.add.assert_called() + mock_snapshot.pull_requests.add.assert_called() + mock_snapshot.issues.add.assert_called() + + def test_handle_with_member_profile_no_slack_id(self, command, mocker): + """Test handle when user has profile but no Slack ID.""" + mock_user = mocker.patch(f"{self.target_module}.User") + mock_user_instance = mock.MagicMock() + mock_user.objects.get.return_value = mock_user_instance + + mock_snapshot_model = mocker.patch(f"{self.target_module}.MemberSnapshot") + mock_snapshot_model.objects.filter.return_value.first.return_value = None + + mock_snapshot = mock.MagicMock() + mock_snapshot.id = 1 + mock_snapshot.total_contributions = 0 + mock_snapshot.commits_count = 0 + mock_snapshot.pull_requests_count = 0 + mock_snapshot.issues_count = 0 + mock_snapshot.messages_count = 0 + mock_snapshot_model.objects.create.return_value = mock_snapshot + + mock_commit = mocker.patch(f"{self.target_module}.Commit") + mock_commit.objects.filter.return_value.count.return_value = 0 + + mock_pr = mocker.patch(f"{self.target_module}.PullRequest") + mock_pr.objects.filter.return_value.count.return_value = 0 + + mock_issue = mocker.patch(f"{self.target_module}.Issue") + mock_issue.objects.filter.return_value.count.return_value = 0 + + mock_profile = mocker.patch(f"{self.target_module}.MemberProfile") + mock_profile_instance = mock.MagicMock() + mock_profile_instance.owasp_slack_id = None + mock_profile.objects.get.return_value = mock_profile_instance + + mocker.patch.object(command, "generate_heatmap_data", return_value={}) + mocker.patch.object(command, "generate_entity_contributions", return_value={}) + mocker.patch.object(command, "generate_repository_contributions", return_value={}) + mocker.patch.object(command, "generate_communication_heatmap_data", return_value={}) + + command.handle(username="testuser", start_at=None, end_at=None) + + stdout_calls = [str(call) for call in command.stdout.write.call_args_list] + assert any("No Slack ID" in str(call) for call in stdout_calls) + + def test_handle_with_slack_messages(self, command, mocker): + """Test handle when user has Slack messages.""" + mock_user = mocker.patch(f"{self.target_module}.User") + mock_user_instance = mock.MagicMock() + mock_user.objects.get.return_value = mock_user_instance + + mock_snapshot_model = mocker.patch(f"{self.target_module}.MemberSnapshot") + mock_snapshot_model.objects.filter.return_value.first.return_value = None + + mock_snapshot = mock.MagicMock() + mock_snapshot.id = 1 + mock_snapshot.total_contributions = 5 + mock_snapshot.commits_count = 5 + mock_snapshot.pull_requests_count = 0 + mock_snapshot.issues_count = 0 + mock_snapshot.messages_count = 10 + mock_snapshot_model.objects.create.return_value = mock_snapshot + + mock_commit = mocker.patch(f"{self.target_module}.Commit") + mock_commit_qs = mock.MagicMock() + mock_commit_qs.count.return_value = 5 + mock_commit_qs.__iter__ = lambda _: iter([]) + mock_commit.objects.filter.return_value = mock_commit_qs + + mock_pr = mocker.patch(f"{self.target_module}.PullRequest") + mock_pr.objects.filter.return_value.count.return_value = 0 + + mock_issue = mocker.patch(f"{self.target_module}.Issue") + mock_issue.objects.filter.return_value.count.return_value = 0 + + mock_profile = mocker.patch(f"{self.target_module}.MemberProfile") + mock_profile_instance = mock.MagicMock() + mock_profile_instance.owasp_slack_id = "U12345" + mock_profile.objects.get.return_value = mock_profile_instance + + mock_message = mocker.patch(f"{self.target_module}.Message") + mock_msg_qs = mock.MagicMock() + mock_msg_qs.select_related.return_value = mock_msg_qs + mock_msg_qs.count.return_value = 10 + mock_msg_qs.__iter__ = lambda _: iter([]) + mock_message.objects.filter.return_value = mock_msg_qs + mock_message.objects.none.return_value = mock_msg_qs + + mocker.patch.object(command, "generate_heatmap_data", return_value={"2025-01-01": 1}) + mocker.patch.object(command, "generate_entity_contributions", return_value={}) + mocker.patch.object(command, "generate_repository_contributions", return_value={}) + mocker.patch.object( + command, "generate_communication_heatmap_data", return_value={"2025-01-01": 5} + ) + mocker.patch.object( + command, "generate_channel_communications", return_value={"general": 5} + ) + + command.handle(username="testuser", start_at=None, end_at=None) + mock_snapshot.messages.add.assert_called() + + def test_handle_with_custom_dates(self, command, mocker): + """Test handle with custom start and end dates.""" + mock_user = mocker.patch(f"{self.target_module}.User") + mock_user_instance = mock.MagicMock() + mock_user.objects.get.return_value = mock_user_instance + + mock_snapshot_model = mocker.patch(f"{self.target_module}.MemberSnapshot") + mock_snapshot_model.objects.filter.return_value.first.return_value = None + + mock_snapshot = mock.MagicMock() + mock_snapshot.id = 1 + mock_snapshot.total_contributions = 0 + mock_snapshot.commits_count = 0 + mock_snapshot.pull_requests_count = 0 + mock_snapshot.issues_count = 0 + mock_snapshot.messages_count = 0 + mock_snapshot_model.objects.create.return_value = mock_snapshot + + mock_commit = mocker.patch(f"{self.target_module}.Commit") + mock_commit.objects.filter.return_value.count.return_value = 0 + + mock_pr = mocker.patch(f"{self.target_module}.PullRequest") + mock_pr.objects.filter.return_value.count.return_value = 0 + + mock_issue = mocker.patch(f"{self.target_module}.Issue") + mock_issue.objects.filter.return_value.count.return_value = 0 + + mock_profile = mocker.patch(f"{self.target_module}.MemberProfile") + mock_profile.DoesNotExist = Exception + mock_profile.objects.get.side_effect = mock_profile.DoesNotExist + + mocker.patch.object(command, "generate_heatmap_data", return_value={}) + mocker.patch.object(command, "generate_entity_contributions", return_value={}) + mocker.patch.object(command, "generate_repository_contributions", return_value={}) + mocker.patch.object(command, "generate_communication_heatmap_data", return_value={}) + + command.handle(username="testuser", start_at="2024-06-01", end_at="2024-12-31") + mock_snapshot_model.objects.create.assert_called_once() + + def test_handle_no_contributions_warning(self, command, mocker): + """Test handle shows warning when no contributions found.""" + mock_user = mocker.patch(f"{self.target_module}.User") + mock_user_instance = mock.MagicMock() + mock_user.objects.get.return_value = mock_user_instance + + mock_snapshot_model = mocker.patch(f"{self.target_module}.MemberSnapshot") + mock_snapshot_model.objects.filter.return_value.first.return_value = None + + mock_snapshot = mock.MagicMock() + mock_snapshot.id = 1 + mock_snapshot.total_contributions = 0 + mock_snapshot.commits_count = 0 + mock_snapshot.pull_requests_count = 0 + mock_snapshot.issues_count = 0 + mock_snapshot.messages_count = 0 + mock_snapshot_model.objects.create.return_value = mock_snapshot + + mock_commit = mocker.patch(f"{self.target_module}.Commit") + mock_commit.objects.filter.return_value.count.return_value = 0 + + mock_pr = mocker.patch(f"{self.target_module}.PullRequest") + mock_pr.objects.filter.return_value.count.return_value = 0 + + mock_issue = mocker.patch(f"{self.target_module}.Issue") + mock_issue.objects.filter.return_value.count.return_value = 0 + + mock_profile = mocker.patch(f"{self.target_module}.MemberProfile") + mock_profile.DoesNotExist = Exception + mock_profile.objects.get.side_effect = mock_profile.DoesNotExist + + mocker.patch.object(command, "generate_heatmap_data", return_value={}) + mocker.patch.object(command, "generate_entity_contributions", return_value={}) + mocker.patch.object(command, "generate_repository_contributions", return_value={}) + mocker.patch.object(command, "generate_communication_heatmap_data", return_value={}) + + command.handle(username="testuser", start_at=None, end_at=None) + stdout_calls = [str(call) for call in command.stdout.write.call_args_list] + assert any("no contributions" in str(call).lower() for call in stdout_calls) + + +class TestGenerateEntityContributionsChapter: + """Tests for chapter entity contributions.""" + + def test_generate_entity_contributions_chapter(self): + """Test chapter entity contributions.""" + command = Command() + start_at = datetime(2025, 1, 1, tzinfo=UTC) + end_at = datetime(2025, 10, 1, tzinfo=UTC) + + mock_user = mock.Mock() + mock_user.id = 1 + + mock_repo = mock.Mock() + mock_repo.id = 100 + + mock_commit = mock.Mock() + mock_commit.created_at = datetime(2025, 1, 15, tzinfo=UTC) + mock_commit.repository_id = 100 + + mock_pr = mock.Mock() + mock_pr.created_at = datetime(2025, 2, 1, tzinfo=UTC) + mock_pr.repository_id = 100 + + mock_issue = mock.Mock() + mock_issue.created_at = datetime(2025, 3, 1, tzinfo=UTC) + mock_issue.repository_id = 100 + + commit_list = [mock_commit] + pr_list = [mock_pr] + issue_list = [mock_issue] + + mock_commits = mock.Mock() + mock_commits.select_related.return_value = iter(commit_list) + mock_commits.__iter__ = lambda _: iter(commit_list) + + mock_prs = mock.Mock() + mock_prs.select_related.return_value = iter(pr_list) + mock_prs.__iter__ = lambda _: iter(pr_list) + + mock_issues = mock.Mock() + mock_issues.select_related.return_value = iter(issue_list) + mock_issues.__iter__ = lambda _: iter(issue_list) + + target_module = "apps.owasp.management.commands.owasp_create_member_snapshot" + + with ( + mock.patch(f"{target_module}.Chapter") as mock_chapter_model, + mock.patch(f"{target_module}.ContentType") as mock_content_type, + mock.patch(f"{target_module}.EntityMember") as mock_entity_member, + ): + mock_content_type.objects.get_for_model.return_value = mock.Mock(id=2) + mock_entity_member.objects.filter.return_value.values_list.return_value = [1] + + mock_chapter = mock.Mock() + mock_chapter.nest_key = "test-chapter" + mock_chapter.owasp_repository_id = 100 + + mock_filter = mock.Mock() + mock_filter.select_related.return_value = [mock_chapter] + mock_filter.__iter__ = lambda _: iter([mock_chapter]) + mock_chapter_model.objects.filter.return_value = mock_filter + + result = command.generate_entity_contributions( + mock_user, + mock_commits, + mock_prs, + mock_issues, + "chapter", + start_at, + end_at, + ) + + assert result == {"test-chapter": 3} + + def test_generate_entity_contributions_chapter_no_repository(self): + """Test chapter with no repository.""" + command = Command() + start_at = datetime(2025, 1, 1, tzinfo=UTC) + end_at = datetime(2025, 10, 1, tzinfo=UTC) + + mock_user = mock.Mock() + mock_user.id = 1 + + mock_commits = mock.Mock() + mock_commits.select_related.return_value = iter([]) + mock_commits.__iter__ = lambda _: iter([]) + + mock_prs = mock.Mock() + mock_prs.select_related.return_value = iter([]) + mock_prs.__iter__ = lambda _: iter([]) + + mock_issues = mock.Mock() + mock_issues.select_related.return_value = iter([]) + mock_issues.__iter__ = lambda _: iter([]) + + target_module = "apps.owasp.management.commands.owasp_create_member_snapshot" + + with ( + mock.patch(f"{target_module}.Chapter") as mock_chapter_model, + mock.patch(f"{target_module}.ContentType") as mock_content_type, + mock.patch(f"{target_module}.EntityMember") as mock_entity_member, + ): + mock_content_type.objects.get_for_model.return_value = mock.Mock(id=2) + mock_entity_member.objects.filter.return_value.values_list.return_value = [1] + + mock_chapter = mock.Mock() + mock_chapter.nest_key = "test-chapter" + mock_chapter.owasp_repository_id = None + + mock_filter = mock.Mock() + mock_filter.select_related.return_value = [mock_chapter] + mock_filter.__iter__ = lambda _: iter([mock_chapter]) + mock_chapter_model.objects.filter.return_value = mock_filter + + result = command.generate_entity_contributions( + mock_user, + mock_commits, + mock_prs, + mock_issues, + "chapter", + start_at, + end_at, + ) + assert result == {"test-chapter": 0} + + def test_generate_entity_contributions_pr_outside_date_range(self): + """Test PR contribution with created_at = None or outside range.""" + command = Command() + start_at = datetime(2025, 1, 1, tzinfo=UTC) + end_at = datetime(2025, 10, 1, tzinfo=UTC) + + mock_user = mock.Mock() + mock_user.id = 1 + + mock_pr = mock.Mock() + mock_pr.created_at = None + mock_pr.repository_id = 100 + + mock_commits = mock.Mock() + mock_commits.select_related.return_value = iter([]) + mock_commits.__iter__ = lambda _: iter([]) + + mock_prs = mock.Mock() + mock_prs.select_related.return_value = iter([mock_pr]) + mock_prs.__iter__ = lambda _: iter([mock_pr]) + + mock_issues = mock.Mock() + mock_issues.select_related.return_value = iter([]) + mock_issues.__iter__ = lambda _: iter([]) + + target_module = "apps.owasp.management.commands.owasp_create_member_snapshot" + + with ( + mock.patch(f"{target_module}.Chapter") as mock_chapter_model, + mock.patch(f"{target_module}.ContentType") as mock_content_type, + mock.patch(f"{target_module}.EntityMember") as mock_entity_member, + ): + mock_content_type.objects.get_for_model.return_value = mock.Mock(id=2) + mock_entity_member.objects.filter.return_value.values_list.return_value = [1] + + mock_chapter = mock.Mock() + mock_chapter.nest_key = "test-chapter" + mock_chapter.owasp_repository_id = 100 + + mock_filter = mock.Mock() + mock_filter.select_related.return_value = [mock_chapter] + mock_filter.__iter__ = lambda _: iter([mock_chapter]) + mock_chapter_model.objects.filter.return_value = mock_filter + + result = command.generate_entity_contributions( + mock_user, + mock_commits, + mock_prs, + mock_issues, + "chapter", + start_at, + end_at, + ) + assert result == {"test-chapter": 0} + + def test_generate_entity_contributions_issue_outside_date_range(self): + """Test issue contribution with created_at = None or outside range.""" + command = Command() + start_at = datetime(2025, 1, 1, tzinfo=UTC) + end_at = datetime(2025, 10, 1, tzinfo=UTC) + + mock_user = mock.Mock() + mock_user.id = 1 + + mock_issue = mock.Mock() + mock_issue.created_at = None + mock_issue.repository_id = 100 + + mock_commits = mock.Mock() + mock_commits.select_related.return_value = iter([]) + mock_commits.__iter__ = lambda _: iter([]) + + mock_prs = mock.Mock() + mock_prs.select_related.return_value = iter([]) + mock_prs.__iter__ = lambda _: iter([]) + + mock_issues = mock.Mock() + mock_issues.select_related.return_value = iter([mock_issue]) + mock_issues.__iter__ = lambda _: iter([mock_issue]) + + target_module = "apps.owasp.management.commands.owasp_create_member_snapshot" + + with ( + mock.patch(f"{target_module}.Chapter") as mock_chapter_model, + mock.patch(f"{target_module}.ContentType") as mock_content_type, + mock.patch(f"{target_module}.EntityMember") as mock_entity_member, + ): + mock_content_type.objects.get_for_model.return_value = mock.Mock(id=2) + mock_entity_member.objects.filter.return_value.values_list.return_value = [1] + + mock_chapter = mock.Mock() + mock_chapter.nest_key = "test-chapter" + mock_chapter.owasp_repository_id = 100 + + mock_filter = mock.Mock() + mock_filter.select_related.return_value = [mock_chapter] + mock_filter.__iter__ = lambda _: iter([mock_chapter]) + mock_chapter_model.objects.filter.return_value = mock_filter + + result = command.generate_entity_contributions( + mock_user, + mock_commits, + mock_prs, + mock_issues, + "chapter", + start_at, + end_at, + ) + assert result == {"test-chapter": 0} diff --git a/backend/tests/apps/owasp/management/commands/owasp_update_leaders_test.py b/backend/tests/apps/owasp/management/commands/owasp_update_leaders_test.py index 801fe803a9..e73dc397f4 100644 --- a/backend/tests/apps/owasp/management/commands/owasp_update_leaders_test.py +++ b/backend/tests/apps/owasp/management/commands/owasp_update_leaders_test.py @@ -4,6 +4,9 @@ import pytest from django.contrib.contenttypes.models import ContentType from django.core.management import call_command +from django.core.management.base import CommandError + +from apps.owasp.management.commands.owasp_update_leaders import Command COMMAND_PATH = "apps.owasp.management.commands.owasp_update_leaders" @@ -69,7 +72,7 @@ def test_command_updates_members_with_correct_matches( filter_call = mock_em.objects.filter.call_args assert filter_call[1]["entity_type"] == mock_ct.objects.get_for_model.return_value assert filter_call[1]["role"] == mock_em.Role.LEADER - assert filter_call[1]["member__isnull"] is True + assert filter_call[1]["member__isnull"] # Verify all members were matched and saved assert mock_members[0].save.called @@ -171,3 +174,73 @@ def test_exact_match_is_preferred_over_fuzzy( assert mock_members[0].save.called assert mock_members[0].member_id == 1 # john.doe assert "Matched 1 out of 1 Chapter leaders" in out.getvalue() + + @patch(f"{COMMAND_PATH}.EntityMember") + @patch(f"{COMMAND_PATH}.ContentType") + @patch(f"{COMMAND_PATH}.Chapter") + def test_invalid_model_name(self, mock_chapter, mock_ct, mock_em): + """Test that an invalid model name raises a CommandError.""" + with pytest.raises( + CommandError, match="Error: argument model_name: invalid choice: 'invalid_model'" + ): + call_command("owasp_update_leaders", "invalid_model") + + assert not mock_em.objects.filter.called + + def test_find_best_user_match_empty_name(self): + """Test find_best_user_match returns None for empty member name.""" + cmd = TestOwaspUpdateLeaders._create_command() + assert cmd.find_best_user_match(None, "email", [], 75) is None + assert cmd.find_best_user_match("", "email", [], 75) is None + + def test_find_best_user_match_exact_variations(self): + """Test exact matches by name and email.""" + cmd = TestOwaspUpdateLeaders._create_command() + users = [ + {"id": 1, "login": "login_match", "name": "Wrong Name", "email": "wrong@email.com"}, + {"id": 2, "login": "wrong_login", "name": "Name Match", "email": "wrong@email.com"}, + {"id": 3, "login": "wrong_login2", "name": "Wrong Name", "email": "email@match.com"}, + ] + + match = cmd.find_best_user_match("Name Match", "other@email.com", users, 75) + assert match == users[1] + + match = cmd.find_best_user_match("No Name Match", "email@match.com", users, 75) + assert match == users[2] + + def test_find_best_user_match_fuzzy_variations(self): + """Test fuzzy matches by name and email, and priority handling.""" + cmd = TestOwaspUpdateLeaders._create_command() + users = [ + {"id": 1, "login": "fuzzy_login", "name": "Wrong", "email": "wrong@email.com"}, + {"id": 2, "login": "wrong", "name": "Fuzzy Name", "email": "wrong@email.com"}, + {"id": 3, "login": "wrong", "name": "Wrong", "email": "fuzzy@email.com"}, + ] + + match = cmd.find_best_user_match("fuzzy_login", None, users, 75) + assert match == users[0] + + match = cmd.find_best_user_match("Fuzzy Name Delta", None, users, 75) + assert match == users[1] + + match = cmd.find_best_user_match("Unrelated", "fuzzy_email@email.com", users, 75) + assert match == users[2] + + def test_find_best_user_match_priority(self): + """Test that login > name > email for equal scores.""" + cmd = TestOwaspUpdateLeaders._create_command() + users = [ + {"id": 1, "login": "target", "name": "A", "email": "a@a.com"}, + {"id": 2, "login": "b", "name": "target", "email": "b@b.com"}, + {"id": 3, "login": "c", "name": "C", "email": "target"}, + ] + + match = cmd.find_best_user_match("target", "target", users, 0) + assert match == users[0] + + match = cmd.find_best_user_match("target", "target", users[1:], 0) + assert match == users[1] + + @staticmethod + def _create_command(): + return Command() diff --git a/backend/tests/apps/owasp/models/chapter_test.py b/backend/tests/apps/owasp/models/chapter_test.py index 0c09445a7e..56fdad1bdf 100644 --- a/backend/tests/apps/owasp/models/chapter_test.py +++ b/backend/tests/apps/owasp/models/chapter_test.py @@ -94,6 +94,18 @@ def test_generate_suggested_location( mock_open_ai.set_prompt.assert_called_once_with("Tell me the location") mock_open_ai.complete.assert_called_once() + def test_generate_suggested_location_no_prompt(self): + """Test generate_suggested_location returns early when no prompt is available.""" + mock_open_ai = MagicMock() + chapter = Chapter(is_active=True, suggested_location="") + chapter.get_geo_string = MagicMock(return_value="Test Geo") + + with patch.object(Prompt, "get_owasp_chapter_suggested_location", return_value=None): + chapter.generate_suggested_location(open_ai=mock_open_ai) + + mock_open_ai.set_input.assert_not_called() + assert chapter.suggested_location == "" + @pytest.mark.parametrize( ("name", "key", "expected_str"), [ @@ -106,6 +118,28 @@ def test_str_representation(self, name, key, expected_str): chapter = Chapter(name=name, key=key) assert str(chapter) == expected_str + @pytest.mark.parametrize( + ("key", "expected_nest_key"), + [ + ("www-chapter-test", "test"), + ("www-chapter-new-york", "new-york"), + ("www-chapter-", ""), + ], + ) + def test_nest_key_property(self, key, expected_nest_key): + """Test nest_key property strips www-chapter- prefix.""" + chapter = Chapter(key=key) + assert chapter.nest_key == expected_nest_key + + def test_nest_url_property(self): + """Test nest_url property returns correct URL.""" + chapter = Chapter(key="www-chapter-new-york") + with patch("apps.owasp.models.chapter.get_absolute_url") as mock_get_url: + mock_get_url.return_value = "https://nest.owasp.org/chapters/new-york" + url = chapter.nest_url + mock_get_url.assert_called_once_with("chapters/new-york") + assert url == "https://nest.owasp.org/chapters/new-york" + @pytest.mark.parametrize( ("value"), [ @@ -200,3 +234,63 @@ def test_from_github(self): assert chapter.created_at == owasp_repository.created_at assert chapter.name == owasp_repository.title assert chapter.updated_at == owasp_repository.updated_at + + def test_update_data_new_chapter(self): + """Test update_data creates a new chapter when one doesn't exist.""" + mock_gh_repository = Mock() + mock_gh_repository.name = "www-chapter-test" + + mock_repository = Mock() + + with ( + patch.object(Chapter, "objects") as mock_objects, + patch.object(Chapter, "from_github") as mock_from_github, + patch.object(Chapter, "save") as mock_save, + ): + mock_objects.get.side_effect = Chapter.DoesNotExist() + + result = Chapter.update_data(mock_gh_repository, mock_repository, save=True) + + assert result.key == "www-chapter-test" + mock_from_github.assert_called_once_with(mock_repository) + mock_save.assert_called_once() + + def test_update_data_existing_chapter(self): + """Test update_data updates an existing chapter.""" + mock_gh_repository = Mock() + mock_gh_repository.name = "www-chapter-test" + + mock_repository = Mock() + mock_existing_chapter = Mock(spec=Chapter) + + with ( + patch.object(Chapter, "objects") as mock_objects, + patch.object(Chapter, "from_github") as _mock_from_github, + ): + mock_objects.get.return_value = mock_existing_chapter + + result = Chapter.update_data(mock_gh_repository, mock_repository, save=True) + + assert result == mock_existing_chapter + mock_existing_chapter.from_github.assert_called_once_with(mock_repository) + mock_existing_chapter.save.assert_called_once() + + def test_update_data_no_save(self): + """Test update_data with save=False doesn't save.""" + mock_gh_repository = Mock() + mock_gh_repository.name = "www-chapter-new" + + mock_repository = Mock() + + with ( + patch.object(Chapter, "objects") as mock_objects, + patch.object(Chapter, "from_github") as mock_from_github, + patch.object(Chapter, "save") as mock_save, + ): + mock_objects.get.side_effect = Chapter.DoesNotExist() + + result = Chapter.update_data(mock_gh_repository, mock_repository, save=False) + + assert result.key == "www-chapter-new" + mock_from_github.assert_called_once_with(mock_repository) + mock_save.assert_not_called() diff --git a/backend/tests/apps/owasp/models/common_test.py b/backend/tests/apps/owasp/models/common_test.py index b790a40980..a60f30efa1 100644 --- a/backend/tests/apps/owasp/models/common_test.py +++ b/backend/tests/apps/owasp/models/common_test.py @@ -436,3 +436,141 @@ def test_sync_leaders_mixed_scenario( leaders_to_save = call_args[0][1] assert len(leaders_to_save) == 2 # Updated existing + new leader + + @pytest.mark.parametrize( + ("name", "expected_owasp_name"), + [ + ("Test Project", "OWASP Test Project"), + ("OWASP Already Prefixed", "OWASP Already Prefixed"), + ("", "OWASP "), + ], + ) + def test_owasp_name_property(self, name, expected_owasp_name): + """Test owasp_name property prefixes OWASP when needed.""" + model = EntityModel() + model.name = name + assert model.owasp_name == expected_owasp_name + + def test_generate_summary_empty_prompt(self): + """Test generate_summary returns early when prompt is empty.""" + model = EntityModel() + model.summary = "existing" + mock_open_ai = MagicMock() + + model.generate_summary(prompt="", open_ai=mock_open_ai) + + mock_open_ai.set_input.assert_not_called() + assert model.summary == "existing" + + def test_generate_summary_none_prompt(self): + """Test generate_summary returns early when prompt is None.""" + model = EntityModel() + model.summary = "existing" + mock_open_ai = MagicMock() + + model.generate_summary(prompt=None, open_ai=mock_open_ai) + + mock_open_ai.set_input.assert_not_called() + assert model.summary == "existing" + + def test_from_github_sets_values(self): + """Test from_github properly sets field values.""" + model = EntityModel() + model.name = "" + model.leaders_raw = [] + model.is_leaders_policy_compliant = True + model.tags = [] + + repository = Repository() + repository.name = "www-project-example" + model.owasp_repository = repository + + mock_metadata = { + "title": "Test Project", + "leader": "", + "tags": ["tag1", "tag2"], + } + + with ( + patch.object(model, "get_metadata", return_value=mock_metadata), + patch.object(model, "get_leaders", return_value=["Leader1", "Leader2"]), + ): + model.from_github({"name": "title"}) + + assert model.name == "Test Project" + assert model.leaders_raw == ["Leader1", "Leader2"] + assert model.is_leaders_policy_compliant + + @pytest.mark.parametrize( + ("url", "exclude_domains", "include_domains", "expected_result"), + [ + ("https://excluded.com/path", ("excluded.com",), (), None), + ("https://other.com/path", (), ("included.com",), None), + ("https://included.com/path", (), ("included.com",), "https://included.com/path"), + ("/cdn-cgi/l/email-protection#abc123", (), (), None), + ("", (), (), None), + (None, (), (), None), + ], + ) + def test_get_related_url_edge_cases( + self, url, exclude_domains, include_domains, expected_result + ): + """Test get_related_url with edge cases.""" + model = EntityModel() + result = model.get_related_url( + url, exclude_domains=exclude_domains, include_domains=include_domains + ) + assert result == expected_result + + def test_get_metadata_yaml_scanner_error(self): + """Test get_metadata handles YAML scanner errors gracefully.""" + model = EntityModel() + repository = Repository() + repository.name = "test-repo" + repository.key = "test-repo" + model.owasp_repository = repository + + invalid_yaml = """--- + invalid: yaml: content: [broken + ---""" + + with patch( + "apps.owasp.models.common.get_repository_file_content", return_value=invalid_yaml + ): + result = model.get_metadata() + + assert result == {} + + def test_get_urls_with_domain_value_error(self): + """Test get_urls handles ValueError during domain filtering.""" + from urllib.parse import urlparse as original_urlparse + + model = EntityModel() + repository = Repository() + repository.name = "www-project-example" + model.owasp_repository = repository + content = """* [Link](https://example.com) +* [Other](https://other.com)""" + + def side_effect_urlparse(url): + if "other.com" in url: + msg = "forced error" + raise ValueError(msg) + return original_urlparse(url) + + with ( + patch("apps.owasp.models.common.get_repository_file_content", return_value=content), + patch("apps.owasp.models.common.urlparse", side_effect=side_effect_urlparse), + ): + urls = model.get_urls(domain="example.com") + + assert "https://example.com" in urls + + def test_github_file_urls_no_repository(self): + """Test file URLs return None when no repository is linked.""" + model = EntityModel() + model.owasp_repository = None + + assert model.index_md_url is None + assert model.info_md_url is None + assert model.leaders_md_url is None diff --git a/backend/tests/apps/owasp/models/managers/__init__.py b/backend/tests/apps/owasp/models/managers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/apps/owasp/models/managers/managers_test.py b/backend/tests/apps/owasp/models/managers/managers_test.py new file mode 100644 index 0000000000..90a1eaff60 --- /dev/null +++ b/backend/tests/apps/owasp/models/managers/managers_test.py @@ -0,0 +1,96 @@ +from unittest.mock import MagicMock + +from django.db.models import Q + +from apps.owasp.models.managers.chapter import ActiveChapterManager +from apps.owasp.models.managers.committee import ActiveCommitteeManager +from apps.owasp.models.managers.project import ActiveProjectManager + + +class TestActiveChapterManager: + """Test ActiveChapterManager.""" + + def test_get_queryset(self, mocker): + """Test get_queryset filters for active chapters with non-empty repos.""" + mock_qs = MagicMock() + mocker.patch("django.db.models.Manager.get_queryset", return_value=mock_qs) + manager = ActiveChapterManager() + manager.model = MagicMock() + result = manager.get_queryset() + mock_qs.select_related.assert_called_with("owasp_repository") + mock_qs.select_related.return_value.filter.assert_called_with( + is_active=True, + owasp_repository__is_empty=False, + ) + assert result == mock_qs.select_related.return_value.filter.return_value + + def test_without_geo_data(self, mocker): + """Test without_geo_data property.""" + manager = ActiveChapterManager() + mock_qs = MagicMock() + manager.get_queryset = MagicMock(return_value=mock_qs) + + result = manager.without_geo_data + manager.get_queryset.assert_called_once() + mock_qs.filter.assert_called_once() + args, _ = mock_qs.filter.call_args + assert len(args) == 1 + q_obj = args[0] + assert isinstance(q_obj, Q) + assert result == mock_qs.filter.return_value + + +class TestActiveCommitteeManager: + """Test ActiveCommitteeManager.""" + + def test_get_queryset(self, mocker): + """Test get_queryset logic.""" + mock_qs = MagicMock() + mocker.patch("django.db.models.Manager.get_queryset", return_value=mock_qs) + + manager = ActiveCommitteeManager() + + result = manager.get_queryset() + + mock_qs.filter.assert_called_with(is_active=True) + assert result == mock_qs.filter.return_value + + def test_without_summary(self): + """Test without_summary property.""" + manager = ActiveCommitteeManager() + mock_qs = MagicMock() + manager.get_queryset = MagicMock(return_value=mock_qs) + + result = manager.without_summary + + manager.get_queryset.assert_called_once() + mock_qs.filter.assert_called_with(summary="") + assert result == mock_qs.filter.return_value + + +class TestActiveProjectManager: + """Test ActiveProjectManager.""" + + def test_get_queryset(self, mocker): + """Test get_queryset logic.""" + mock_qs = MagicMock() + mocker.patch("django.db.models.Manager.get_queryset", return_value=mock_qs) + + manager = ActiveProjectManager() + + result = manager.get_queryset() + + mock_qs.filter.assert_called_with(is_active=True) + assert result == mock_qs.filter.return_value + + def test_without_summary(self): + """Test without_summary property.""" + manager = ActiveProjectManager() + mock_qs = MagicMock() + manager.get_queryset = MagicMock(return_value=mock_qs) + + result = manager.without_summary + + manager.get_queryset.assert_called_once() + mock_qs.filter.assert_called_with(summary="") + assert result == mock_qs.filter.return_value diff --git a/backend/tests/apps/owasp/models/mixins/chapter_test.py b/backend/tests/apps/owasp/models/mixins/chapter_test.py new file mode 100644 index 0000000000..50bcd7724b --- /dev/null +++ b/backend/tests/apps/owasp/models/mixins/chapter_test.py @@ -0,0 +1,201 @@ +"""Tests for ChapterIndexMixin.""" + +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +from apps.owasp.models.mixins.chapter import ChapterIndexMixin + + +class TestChapterIndexMixin: + """Test cases for ChapterIndexMixin.""" + + def create_mock_chapter(self, **kwargs): + """Create a mock chapter with ChapterIndexMixin methods.""" + mock_chapter = MagicMock(spec=ChapterIndexMixin) + mock_chapter.key = kwargs.get("key", "www-chapter-test") + mock_chapter.name = kwargs.get("name", "Test Chapter") + mock_chapter.country = kwargs.get("country", "United States") + mock_chapter.region = kwargs.get("region", "California") + mock_chapter.latitude = kwargs.get("latitude", 37.7749) + mock_chapter.longitude = kwargs.get("longitude", -122.4194) + mock_chapter.postal_code = kwargs.get("postal_code", "94102") + mock_chapter.meetup_group = kwargs.get("meetup_group", "owasp-sf") + mock_chapter.related_urls = kwargs.get("related_urls", ["https://example.com"]) + mock_chapter.suggested_location = kwargs.get("suggested_location", "San Francisco, CA") + mock_chapter.is_active = kwargs.get("is_active", True) + mock_chapter.created_at = kwargs.get( + "created_at", datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC) + ) + mock_chapter.updated_at = kwargs.get( + "updated_at", datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) + ) + mock_repository = MagicMock() + mock_repository.is_empty = kwargs.get("is_empty", False) + mock_repository.created_at = datetime(2023, 1, 1, 0, 0, 0, tzinfo=UTC) + mock_repository.updated_at = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) + mock_chapter.owasp_repository = mock_repository + + return mock_chapter + + def test_is_indexable_true(self): + """Test is_indexable returns True when conditions are met.""" + mock_chapter = self.create_mock_chapter( + latitude=37.7749, longitude=-122.4194, is_empty=False + ) + + result = ChapterIndexMixin.is_indexable.fget(mock_chapter) + + assert result + + def test_is_indexable_false_no_latitude(self): + """Test is_indexable returns False when latitude is None.""" + mock_chapter = self.create_mock_chapter(latitude=None) + + result = ChapterIndexMixin.is_indexable.fget(mock_chapter) + + assert not result + + def test_is_indexable_false_no_longitude(self): + """Test is_indexable returns False when longitude is None.""" + mock_chapter = self.create_mock_chapter(longitude=None) + + result = ChapterIndexMixin.is_indexable.fget(mock_chapter) + + assert not result + + def test_is_indexable_false_empty_repository(self): + """Test is_indexable returns False when repository is empty.""" + mock_chapter = self.create_mock_chapter(is_empty=True) + + result = ChapterIndexMixin.is_indexable.fget(mock_chapter) + + assert not result + + def test_idx_country(self): + """Test idx_country returns country.""" + mock_chapter = self.create_mock_chapter(country="Germany") + + result = ChapterIndexMixin.idx_country.fget(mock_chapter) + + assert result == "Germany" + + def test_idx_created_at_with_chapter_created_at(self): + """Test idx_created_at returns chapter created_at timestamp.""" + test_datetime = datetime(2024, 3, 15, 10, 30, 0, tzinfo=UTC) + mock_chapter = self.create_mock_chapter(created_at=test_datetime) + + result = ChapterIndexMixin.idx_created_at.fget(mock_chapter) + + assert result == test_datetime.timestamp() + + def test_idx_created_at_fallback_to_repository(self): + """Test idx_created_at falls back to repository created_at.""" + mock_chapter = self.create_mock_chapter(created_at=None) + + result = ChapterIndexMixin.idx_created_at.fget(mock_chapter) + + assert result == mock_chapter.owasp_repository.created_at.timestamp() + + def test_idx_geo_location(self): + """Test idx_geo_location returns latitude, longitude tuple.""" + mock_chapter = self.create_mock_chapter(latitude=51.5074, longitude=-0.1278) + + result = ChapterIndexMixin.idx_geo_location.fget(mock_chapter) + + assert result == (51.5074, -0.1278) + + def test_idx_is_active(self): + """Test idx_is_active returns is_active status.""" + mock_chapter = self.create_mock_chapter(is_active=True) + + result = ChapterIndexMixin.idx_is_active.fget(mock_chapter) + + assert result + + def test_idx_key(self): + """Test idx_key strips www-chapter- prefix.""" + mock_chapter = self.create_mock_chapter(key="www-chapter-london") + + result = ChapterIndexMixin.idx_key.fget(mock_chapter) + + assert result == "london" + + def test_idx_meetup_group(self): + """Test idx_meetup_group returns meetup group.""" + mock_chapter = self.create_mock_chapter(meetup_group="owasp-london") + + result = ChapterIndexMixin.idx_meetup_group.fget(mock_chapter) + + assert result == "owasp-london" + + def test_idx_postal_code(self): + """Test idx_postal_code returns postal code.""" + mock_chapter = self.create_mock_chapter(postal_code="SW1A 1AA") + + result = ChapterIndexMixin.idx_postal_code.fget(mock_chapter) + + assert result == "SW1A 1AA" + + def test_idx_region(self): + """Test idx_region returns region.""" + mock_chapter = self.create_mock_chapter(region="Greater London") + + result = ChapterIndexMixin.idx_region.fget(mock_chapter) + + assert result == "Greater London" + + def test_idx_related_urls(self): + """Test idx_related_urls returns related URLs list.""" + urls = ["https://owasp.org", "https://meetup.com"] + mock_chapter = self.create_mock_chapter(related_urls=urls) + + result = ChapterIndexMixin.idx_related_urls.fget(mock_chapter) + + assert result == urls + + def test_idx_suggested_location(self): + """Test idx_suggested_location returns suggested location.""" + mock_chapter = self.create_mock_chapter(suggested_location="London, UK") + + result = ChapterIndexMixin.idx_suggested_location.fget(mock_chapter) + + assert result == "London, UK" + + def test_idx_suggested_location_none_string(self): + """Test idx_suggested_location returns empty string for 'None'.""" + mock_chapter = self.create_mock_chapter(suggested_location="None") + + result = ChapterIndexMixin.idx_suggested_location.fget(mock_chapter) + + assert result == "" + + def test_idx_top_contributors(self): + """Test idx_top_contributors calls RepositoryContributor.""" + mock_chapter = self.create_mock_chapter(key="www-chapter-test") + + with patch( + "apps.owasp.models.mixins.chapter.RepositoryContributor.get_top_contributors" + ) as mock_get: + mock_get.return_value = [{"login": "user1"}, {"login": "user2"}] + + result = ChapterIndexMixin.idx_top_contributors.fget(mock_chapter) + + mock_get.assert_called_once_with(chapter="www-chapter-test") + assert len(result) == 2 + + def test_idx_updated_at_with_chapter_updated_at(self): + """Test idx_updated_at returns chapter updated_at timestamp.""" + test_datetime = datetime(2024, 8, 20, 14, 0, 0, tzinfo=UTC) + mock_chapter = self.create_mock_chapter(updated_at=test_datetime) + + result = ChapterIndexMixin.idx_updated_at.fget(mock_chapter) + + assert result == test_datetime.timestamp() + + def test_idx_updated_at_fallback_to_repository(self): + """Test idx_updated_at falls back to repository updated_at.""" + mock_chapter = self.create_mock_chapter(updated_at=None) + + result = ChapterIndexMixin.idx_updated_at.fget(mock_chapter) + + assert result == mock_chapter.owasp_repository.updated_at.timestamp() diff --git a/backend/tests/apps/owasp/models/mixins/committee_test.py b/backend/tests/apps/owasp/models/mixins/committee_test.py new file mode 100644 index 0000000000..804b6e9fcc --- /dev/null +++ b/backend/tests/apps/owasp/models/mixins/committee_test.py @@ -0,0 +1,90 @@ +"""Tests for CommitteeIndexMixin.""" + +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +from apps.owasp.models.mixins.committee import CommitteeIndexMixin + + +class TestCommitteeIndexMixin: + """Test cases for CommitteeIndexMixin.""" + + def create_mock_committee(self, **kwargs): + """Create a mock committee with CommitteeIndexMixin methods.""" + mock_committee = MagicMock(spec=CommitteeIndexMixin) + mock_committee.key = kwargs.get("key", "www-committee-test") + mock_committee.name = kwargs.get("name", "Test Committee") + mock_committee.related_urls = kwargs.get("related_urls", ["https://example.com"]) + mock_committee.created_at = kwargs.get( + "created_at", datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC) + ) + mock_committee.updated_at = kwargs.get( + "updated_at", datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) + ) + + return mock_committee + + def test_idx_created_at_with_value(self): + """Test idx_created_at returns timestamp when created_at exists.""" + test_datetime = datetime(2024, 3, 15, 10, 30, 0, tzinfo=UTC) + mock_committee = self.create_mock_committee(created_at=test_datetime) + + result = CommitteeIndexMixin.idx_created_at.fget(mock_committee) + + assert result == test_datetime.timestamp() + + def test_idx_created_at_none(self): + """Test idx_created_at returns None when created_at is None.""" + mock_committee = self.create_mock_committee(created_at=None) + + result = CommitteeIndexMixin.idx_created_at.fget(mock_committee) + + assert result is None + + def test_idx_key(self): + """Test idx_key strips www-committee- prefix.""" + mock_committee = self.create_mock_committee(key="www-committee-education") + + result = CommitteeIndexMixin.idx_key.fget(mock_committee) + + assert result == "education" + + def test_idx_related_urls(self): + """Test idx_related_urls returns related URLs list.""" + urls = ["https://owasp.org", "https://wiki.owasp.org"] + mock_committee = self.create_mock_committee(related_urls=urls) + + result = CommitteeIndexMixin.idx_related_urls.fget(mock_committee) + + assert result == urls + + def test_idx_top_contributors(self): + """Test idx_top_contributors calls RepositoryContributor.""" + mock_committee = self.create_mock_committee(key="www-committee-test") + + with patch( + "apps.owasp.models.mixins.committee.RepositoryContributor.get_top_contributors" + ) as mock_get: + mock_get.return_value = [{"login": "user1"}, {"login": "user2"}] + + result = CommitteeIndexMixin.idx_top_contributors.fget(mock_committee) + + mock_get.assert_called_once_with(committee="www-committee-test") + assert len(result) == 2 + + def test_idx_updated_at_with_value(self): + """Test idx_updated_at returns timestamp when updated_at exists.""" + test_datetime = datetime(2024, 8, 20, 14, 0, 0, tzinfo=UTC) + mock_committee = self.create_mock_committee(updated_at=test_datetime) + + result = CommitteeIndexMixin.idx_updated_at.fget(mock_committee) + + assert result == test_datetime.timestamp() + + def test_idx_updated_at_none(self): + """Test idx_updated_at returns None when updated_at is None.""" + mock_committee = self.create_mock_committee(updated_at=None) + + result = CommitteeIndexMixin.idx_updated_at.fget(mock_committee) + + assert result is None diff --git a/backend/tests/apps/owasp/models/mixins/common_test.py b/backend/tests/apps/owasp/models/mixins/common_test.py index 6a3da11767..0efb0a68aa 100644 --- a/backend/tests/apps/owasp/models/mixins/common_test.py +++ b/backend/tests/apps/owasp/models/mixins/common_test.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock + import pytest from apps.owasp.models.common import RepositoryBasedEntityModel @@ -21,3 +23,48 @@ class TestRepositoryBasedEntityModelMixin: def test_is_indexable(self, has_active_repositories, expected_indexable): instance = EntityModelMixin(has_active_repositories=has_active_repositories) assert instance.is_indexable == expected_indexable + + def test_idx_description(self): + """Test idx_description returns description.""" + instance = EntityModelMixin(has_active_repositories=True, description="Test description") + assert instance.idx_description == "Test description" + + def test_idx_leaders(self): + """Test idx_leaders returns leaders without @ prefixed entries.""" + instance = EntityModelMixin(has_active_repositories=True) + instance.leaders_raw = ["John Doe", "@hidden_user", "Jane Smith"] + assert instance.idx_leaders == ["John Doe", "Jane Smith"] + + def test_idx_leaders_empty(self): + """Test idx_leaders returns empty list when no leaders.""" + instance = EntityModelMixin(has_active_repositories=True) + instance.leaders_raw = [] + assert instance.idx_leaders == [] + + def test_idx_name(self): + """Test idx_name returns name.""" + instance = EntityModelMixin(has_active_repositories=True, name="Test Entity") + assert instance.idx_name == "Test Entity" + + def test_idx_summary(self): + """Test idx_summary returns summary.""" + instance = EntityModelMixin(has_active_repositories=True, summary="Test summary") + assert instance.idx_summary == "Test summary" + + def test_idx_tags(self): + """Test idx_tags returns tags.""" + instance = EntityModelMixin(has_active_repositories=True, tags=["security", "owasp"]) + assert instance.idx_tags == ["security", "owasp"] + + def test_idx_topics(self): + """Test idx_topics returns topics.""" + instance = EntityModelMixin(has_active_repositories=True, topics=["web", "mobile"]) + assert instance.idx_topics == ["web", "mobile"] + + def test_idx_url(self): + """Test idx_url returns owasp_url.""" + mock_instance = MagicMock() + mock_instance.owasp_url = "https://owasp.org/test" + + result = RepositoryBasedEntityModelMixin.idx_url.fget(mock_instance) + assert result == "https://owasp.org/test" diff --git a/backend/tests/apps/owasp/models/project_health_metrics_test.py b/backend/tests/apps/owasp/models/project_health_metrics_test.py index 16a83481bc..9e53f9b917 100644 --- a/backend/tests/apps/owasp/models/project_health_metrics_test.py +++ b/backend/tests/apps/owasp/models/project_health_metrics_test.py @@ -1,3 +1,6 @@ +import math +from unittest.mock import MagicMock, patch + import pytest from django.core.exceptions import ValidationError from django.utils import timezone @@ -116,3 +119,247 @@ def test_handle_days_calculation(self, field_name, expected_days): metrics.pull_request_last_created_at = self.FIXED_DATE assert getattr(metrics, field_name) == expected_days + + +class TestProjectHealthMetricsRequirements: + """Tests for requirement properties.""" + + def test_age_days_requirement_with_requirements(self): + """Test age_days_requirement returns value when requirements exist.""" + mock_requirements = MagicMock() + mock_requirements.age_days = 30 + + metrics = ProjectHealthMetrics() + with patch.object( + ProjectHealthMetrics, + "project_requirements", + new_callable=lambda: property(lambda _: mock_requirements), + ): + assert metrics.age_days_requirement == 30 + + def test_age_days_requirement_without_requirements(self): + """Test age_days_requirement returns 0 when no requirements.""" + metrics = ProjectHealthMetrics() + with patch.object( + ProjectHealthMetrics, + "project_requirements", + new_callable=lambda: property(lambda _: None), + ): + assert metrics.age_days_requirement == 0 + + def test_last_commit_days_requirement_with_requirements(self): + """Test last_commit_days_requirement returns value when requirements exist.""" + mock_requirements = MagicMock() + mock_requirements.last_commit_days = 14 + + metrics = ProjectHealthMetrics() + with patch.object( + ProjectHealthMetrics, + "project_requirements", + new_callable=lambda: property(lambda _: mock_requirements), + ): + assert metrics.last_commit_days_requirement == 14 + + def test_last_commit_days_requirement_without_requirements(self): + """Test last_commit_days_requirement returns 0 when no requirements.""" + metrics = ProjectHealthMetrics() + with patch.object( + ProjectHealthMetrics, + "project_requirements", + new_callable=lambda: property(lambda _: None), + ): + assert metrics.last_commit_days_requirement == 0 + + def test_last_pull_request_days_requirement_with_requirements(self): + """Test last_pull_request_days_requirement returns value when requirements exist.""" + mock_requirements = MagicMock() + mock_requirements.last_pull_request_days = 21 + + metrics = ProjectHealthMetrics() + with patch.object( + ProjectHealthMetrics, + "project_requirements", + new_callable=lambda: property(lambda _: mock_requirements), + ): + assert metrics.last_pull_request_days_requirement == 21 + + def test_last_pull_request_days_requirement_without_requirements(self): + """Test last_pull_request_days_requirement returns 0 when no requirements.""" + metrics = ProjectHealthMetrics() + with patch.object( + ProjectHealthMetrics, + "project_requirements", + new_callable=lambda: property(lambda _: None), + ): + assert metrics.last_pull_request_days_requirement == 0 + + def test_last_release_days_requirement_with_requirements(self): + """Test last_release_days_requirement returns value when requirements exist.""" + mock_requirements = MagicMock() + mock_requirements.last_release_days = 90 + + metrics = ProjectHealthMetrics() + with patch.object( + ProjectHealthMetrics, + "project_requirements", + new_callable=lambda: property(lambda _: mock_requirements), + ): + assert metrics.last_release_days_requirement == 90 + + def test_last_release_days_requirement_without_requirements(self): + """Test last_release_days_requirement returns 0 when no requirements.""" + metrics = ProjectHealthMetrics() + with patch.object( + ProjectHealthMetrics, + "project_requirements", + new_callable=lambda: property(lambda _: None), + ): + assert metrics.last_release_days_requirement == 0 + + def test_owasp_page_last_update_days_requirement_with_requirements(self): + """Test owasp_page_last_update_days_requirement returns value when requirements exist.""" + mock_requirements = MagicMock() + mock_requirements.owasp_page_last_update_days = 60 + + metrics = ProjectHealthMetrics() + with patch.object( + ProjectHealthMetrics, + "project_requirements", + new_callable=lambda: property(lambda _: mock_requirements), + ): + assert metrics.owasp_page_last_update_days_requirement == 60 + + def test_owasp_page_last_update_days_requirement_without_requirements(self): + """Test owasp_page_last_update_days_requirement returns 0 when no requirements.""" + metrics = ProjectHealthMetrics() + with patch.object( + ProjectHealthMetrics, + "project_requirements", + new_callable=lambda: property(lambda _: None), + ): + assert metrics.owasp_page_last_update_days_requirement == 0 + + +class TestProjectHealthMetricsStaticMethods: + """Tests for static methods.""" + + @patch("apps.owasp.models.project_health_metrics.BulkSaveModel") + def test_bulk_save(self, mock_bulk_save_model): + """Test bulk_save calls BulkSaveModel correctly.""" + mock_metrics = [MagicMock(), MagicMock()] + mock_fields = ["score", "contributors_count"] + + ProjectHealthMetrics.bulk_save(mock_metrics, fields=mock_fields) + + mock_bulk_save_model.bulk_save.assert_called_once_with( + ProjectHealthMetrics, mock_metrics, fields=mock_fields + ) + + @patch.object(ProjectHealthMetrics.objects, "filter") + def test_get_latest_health_metrics(self, mock_filter): + """Test get_latest_health_metrics returns filtered queryset.""" + mock_queryset = MagicMock() + mock_filter.return_value = mock_queryset + + result = ProjectHealthMetrics.get_latest_health_metrics() + assert mock_filter.call_count == 2 + assert result == mock_queryset + + +class TestProjectHealthMetricsProperties: + """Tests for property methods.""" + + @patch("apps.owasp.models.project_health_metrics.ProjectHealthRequirements") + def test_project_requirements_returns_requirements(self, mock_requirements_model): + """Test project_requirements returns requirements for project level.""" + mock_requirements = MagicMock() + mock_requirements_model.objects.filter.return_value.first.return_value = mock_requirements + + project = Project(level="FLAGSHIP") + + metrics = ProjectHealthMetrics() + metrics.project = project + + result = metrics.project_requirements + + mock_requirements_model.objects.filter.assert_called_once_with(level="FLAGSHIP") + assert result == mock_requirements + + +def _mock_monthly_queryset_chain(result: list) -> MagicMock: + """Build a mock for annotate().filter().order_by().values().distinct().annotate(). + + The production code chains these QuerySet methods; this helper returns a mock + that supports that chain and yields `result` from the final .annotate(). + """ + mock = MagicMock() + chain = MagicMock() + mock.filter.return_value = chain + chain.order_by.return_value = chain + chain.values.return_value = chain + chain.distinct.return_value = chain + chain.annotate.return_value = result + return mock + + +class TestProjectHealthMetricsGetStats: + """Tests for get_stats method.""" + + @patch.object(ProjectHealthMetrics, "get_latest_health_metrics") + @patch.object(ProjectHealthMetrics.objects, "annotate") + def test_get_stats(self, mock_annotate, mock_get_latest): + """Test get_stats returns ProjectHealthStatsNode with all data.""" + mock_queryset = MagicMock() + mock_queryset.aggregate.return_value = { + "projects_count_healthy": 10, + "projects_count_need_attention": 5, + "projects_count_unhealthy": 3, + "projects_count_total": 18, + "average_score": 75.5, + "total_contributors": 100, + "total_forks": 50, + "total_stars": 200, + } + mock_get_latest.return_value = mock_queryset + mock_annotate.return_value = _mock_monthly_queryset_chain( + [ + {"month": 1, "score": 70.0}, + {"month": 2, "score": 72.5}, + {"month": 3, "score": 75.0}, + ] + ) + + result = ProjectHealthMetrics.get_stats() + assert math.isclose(result.average_score, 75.5) + assert result.projects_count_healthy == 10 + assert result.projects_count_need_attention == 5 + assert result.projects_count_unhealthy == 3 + assert all( + math.isclose(a, b) + for a, b in zip(result.monthly_overall_scores, [70.0, 72.5, 75.0], strict=True) + ) + assert result.monthly_overall_scores_months == [1, 2, 3] + + @patch.object(ProjectHealthMetrics, "get_latest_health_metrics") + @patch.object(ProjectHealthMetrics.objects, "annotate") + def test_get_stats_avoids_division_by_zero(self, mock_annotate, mock_get_latest): + """Test get_stats handles zero projects gracefully.""" + mock_queryset = MagicMock() + mock_queryset.aggregate.return_value = { + "projects_count_healthy": 0, + "projects_count_need_attention": 0, + "projects_count_unhealthy": 0, + "projects_count_total": 0, + "average_score": 0.0, + "total_contributors": 0, + "total_forks": 0, + "total_stars": 0, + } + mock_get_latest.return_value = mock_queryset + mock_annotate.return_value = _mock_monthly_queryset_chain([]) + + result = ProjectHealthMetrics.get_stats() + + assert result.projects_percentage_healthy == 0 + assert result.projects_percentage_need_attention == 0 + assert result.projects_percentage_unhealthy == 0 diff --git a/backend/tests/apps/owasp/models/snapshot_test.py b/backend/tests/apps/owasp/models/snapshot_test.py index 5e89f480b9..3a38f83d45 100644 --- a/backend/tests/apps/owasp/models/snapshot_test.py +++ b/backend/tests/apps/owasp/models/snapshot_test.py @@ -1,5 +1,6 @@ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch +from django.db import models from django.test import SimpleTestCase from apps.owasp.models.snapshot import Snapshot @@ -34,3 +35,72 @@ def test_snapshot_attributes(self): """Test that title and key are correctly assigned.""" assert self.snapshot.title == "Mock Snapshot Title" assert self.snapshot.key == "2025-02" + + +class SnapshotModelPropertyTest(SimpleTestCase): + """Test Snapshot model properties.""" + + def test_str_representation(self): + """Test __str__ returns the snapshot title.""" + snapshot = Snapshot.__new__(Snapshot) + snapshot.title = "January 2025 Snapshot" + assert str(snapshot) == "January 2025 Snapshot" + + def test_new_chapters_count(self): + """Test new_chapters_count property.""" + mock_snapshot = MagicMock(spec=Snapshot) + mock_snapshot.new_chapters.count.return_value = 5 + result = Snapshot.new_chapters_count.fget(mock_snapshot) + assert result == 5 + + def test_new_issues_count(self): + """Test new_issues_count property.""" + mock_snapshot = MagicMock(spec=Snapshot) + mock_snapshot.new_issues.count.return_value = 10 + result = Snapshot.new_issues_count.fget(mock_snapshot) + assert result == 10 + + def test_new_projects_count(self): + """Test new_projects_count property.""" + mock_snapshot = MagicMock(spec=Snapshot) + mock_snapshot.new_projects.count.return_value = 3 + result = Snapshot.new_projects_count.fget(mock_snapshot) + assert result == 3 + + def test_new_releases_count(self): + """Test new_releases_count property.""" + mock_snapshot = MagicMock(spec=Snapshot) + mock_snapshot.new_releases.count.return_value = 7 + result = Snapshot.new_releases_count.fget(mock_snapshot) + assert result == 7 + + def test_new_users_count(self): + """Test new_users_count property.""" + mock_snapshot = MagicMock(spec=Snapshot) + mock_snapshot.new_users.count.return_value = 15 + result = Snapshot.new_users_count.fget(mock_snapshot) + assert result == 15 + + def test_save_generates_key_when_empty(self): + """Test save method auto-generates key from current date.""" + snapshot = Snapshot.__new__(Snapshot) + snapshot.key = "" + + with ( + patch("apps.owasp.models.snapshot.now") as mock_now, + patch.object(models.Model, "save"), + ): + mock_now.return_value.strftime.return_value = "2025-02" + snapshot.save() + + assert snapshot.key == "2025-02" + + def test_save_preserves_existing_key(self): + """Test save method preserves existing key.""" + snapshot = Snapshot.__new__(Snapshot) + snapshot.key = "2024-12" + + with patch.object(models.Model, "save"): + snapshot.save() + + assert snapshot.key == "2024-12" diff --git a/backend/tests/apps/owasp/scraper_test.py b/backend/tests/apps/owasp/scraper_test.py index 0b90aeb50a..56441138a5 100644 --- a/backend/tests/apps/owasp/scraper_test.py +++ b/backend/tests/apps/owasp/scraper_test.py @@ -212,3 +212,124 @@ def test_initialization_timeout(self, mock_session): scraper = OwaspScraper("https://test.org") assert scraper.page_tree is None + + def test_get_audience_with_none_page_tree(self, mock_session): + """Test get_audience returns empty list when page_tree is None.""" + mock_session.get.side_effect = requests.exceptions.RequestException + scraper = OwaspScraper("https://test.org") + + assert scraper.page_tree is None + assert scraper.get_audience() == [] + + def test_get_audience_with_text_content(self, mock_session): + """Test get_audience extracts audience from text content in sidebar.""" + html_with_audience = ( + b'' + ) + mock_response = Mock() + mock_response.status_code = HTTPStatus.OK + mock_response.content = html_with_audience + mock_session.get.return_value = mock_response + + scraper = OwaspScraper("https://test.org") + audience = scraper.get_audience() + + assert "builder" in audience + assert "defender" in audience + + def test_get_audience_with_paragraph_content(self, mock_session): + """Test get_audience extracts audience from paragraph text.""" + html_with_audience = b'' + mock_response = Mock() + mock_response.status_code = HTTPStatus.OK + mock_response.content = html_with_audience + mock_session.get.return_value = mock_response + + scraper = OwaspScraper("https://test.org") + audience = scraper.get_audience() + + assert "breaker" in audience + + def test_get_audience_with_image_alt_text(self, mock_session): + """Test get_audience extracts audience from image alt text.""" + html_with_audience = ( + b'' + ) + mock_response = Mock() + mock_response.status_code = HTTPStatus.OK + mock_response.content = html_with_audience + mock_session.get.return_value = mock_response + + scraper = OwaspScraper("https://test.org") + audience = scraper.get_audience() + + assert "builder" in audience + + def test_get_audience_with_complementary_role(self, mock_session): + """Test get_audience works with complementary role containers.""" + html_with_audience = b'

For Defenders

' + mock_response = Mock() + mock_response.status_code = HTTPStatus.OK + mock_response.content = html_with_audience + mock_session.get.return_value = mock_response + + scraper = OwaspScraper("https://test.org") + audience = scraper.get_audience() + + assert "defender" in audience + + def test_get_audience_with_empty_text_content(self, mock_session): + """Test get_audience handles empty text content.""" + html_with_empty = b'' + mock_response = Mock() + mock_response.status_code = HTTPStatus.OK + mock_response.content = html_with_empty + mock_session.get.return_value = mock_response + + scraper = OwaspScraper("https://test.org") + audience = scraper.get_audience() + + assert audience == [] + + def test_get_audience_with_empty_alt_text(self, mock_session): + """Test get_audience handles images with no alt text.""" + html_with_no_alt = b'' + mock_response = Mock() + mock_response.status_code = HTTPStatus.OK + mock_response.content = html_with_no_alt + mock_session.get.return_value = mock_response + + scraper = OwaspScraper("https://test.org") + audience = scraper.get_audience() + + assert audience == [] + + def test_get_audience_returns_sorted_list(self, mock_session): + """Test get_audience returns sorted unique audience list.""" + html_with_all = ( + b'" + ) + mock_response = Mock() + mock_response.status_code = HTTPStatus.OK + mock_response.content = html_with_all + mock_session.get.return_value = mock_response + + scraper = OwaspScraper("https://test.org") + audience = scraper.get_audience() + + assert audience == ["breaker", "builder", "defender"] + + def test_verify_url_request_exception(self, mock_session, mock_response, caplog): + """Test verify_url handles request exceptions during verification.""" + mock_session.get.return_value = mock_response + scraper = OwaspScraper("https://test.org") + mock_session.get.reset_mock() + + mock_session.get.side_effect = requests.exceptions.RequestException("Connection error") + + with caplog.at_level(logging.ERROR): + result = scraper.verify_url("https://example.org") + + assert result is None + assert "Request failed" in caplog.text diff --git a/backend/tests/apps/owasp/video_test.py b/backend/tests/apps/owasp/video_test.py index 004a67b983..1662ec63b2 100644 --- a/backend/tests/apps/owasp/video_test.py +++ b/backend/tests/apps/owasp/video_test.py @@ -1,5 +1,6 @@ from unittest.mock import MagicMock, Mock, patch +import ffmpeg import pytest from apps.owasp.video import ( @@ -106,19 +107,48 @@ def test_generate_and_save_video_success(self, mock_ffmpeg, slide): @patch("apps.owasp.video.ffmpeg") def test_generate_and_save_video_exception(self, mock_ffmpeg, mock_logger, slide): """Test generate_and_save_video logs exception on error.""" - import ffmpeg as real_ffmpeg - - mock_ffmpeg.input.return_value = Mock() - mock_ffmpeg.output.return_value.run.side_effect = real_ffmpeg.Error( + mock_ffmpeg.output.return_value.run.side_effect = ffmpeg.Error( "ffmpeg", "stdout", "stderr" ) - mock_ffmpeg.Error = real_ffmpeg.Error + mock_ffmpeg.Error = ffmpeg.Error - with pytest.raises(real_ffmpeg.Error): + with pytest.raises(ffmpeg.Error): slide.generate_and_save_video() mock_logger.exception.assert_called_once() + @patch("apps.owasp.video.pdfium.PdfDocument") + @patch("apps.owasp.video.HTML") + @patch("apps.owasp.video.video_env") + def test_render_and_save_image_render_error( + self, mock_video_env, mock_html, mock_pdfium, slide + ): + """Test render_and_save_image handles render error.""" + mock_template = Mock() + mock_video_env.get_template.return_value = mock_template + mock_html.return_value.write_pdf.side_effect = Exception("Render Error") + + with pytest.raises(Exception, match="Render Error"): + slide.render_and_save_image() + + @patch("apps.owasp.video.pdfium.PdfDocument") + @patch("apps.owasp.video.HTML") + @patch("apps.owasp.video.video_env") + def test_render_and_save_image_page_error_closes_pdf( + self, mock_video_env, mock_html, mock_pdfium, slide + ): + """Test render_and_save_image ensures PDF is closed on page error.""" + mock_template = Mock() + mock_video_env.get_template.return_value = mock_template + mock_pdf = MagicMock() + mock_pdfium.return_value = mock_pdf + mock_pdf.__getitem__.side_effect = Exception("Page Error") + + with pytest.raises(Exception, match="Page Error"): + slide.render_and_save_image() + + mock_pdf.close.assert_called_once() + class TestSlideBuilder: @pytest.fixture @@ -313,6 +343,26 @@ def test_add_thank_you_slide(self, slide_builder): assert slide.name == "thank_you" assert slide.template_name == "slides/thank_you.jinja" + @patch("apps.owasp.video.Project.objects") + @patch("apps.owasp.video.Release.objects") + def test_add_releases_slide_edge_cases( + self, mock_release_objects, mock_project_objects, slide_builder + ): + """Test add_releases_slide edge cases (no repo, no project).""" + release_no_repo = Mock() + release_no_repo.repository = None + release_no_project = Mock() + release_no_project.repository = Mock() + + mock_release_qs = MagicMock() + mock_release_qs.exists.return_value = True + mock_release_qs.count.return_value = 2 + mock_release_qs.__iter__ = Mock(return_value=iter([release_no_repo, release_no_project])) + mock_release_objects.filter.return_value.distinct.return_value = mock_release_qs + mock_project_objects.filter.return_value.first.return_value = None + result = slide_builder.add_releases_slide() + assert result is None + class TestVideoGenerator: @pytest.fixture @@ -372,8 +422,6 @@ def test_merge_videos(self, mock_ffmpeg, generator, tmp_path): @patch("apps.owasp.video.ffmpeg") def test_merge_videos_exception(self, mock_ffmpeg, mock_logger, generator, tmp_path): """Test merge_videos logs exception on error.""" - import ffmpeg as real_ffmpeg - slide = Mock() slide.video_path = tmp_path / "slide.mp4" generator.slides = [slide] @@ -384,11 +432,28 @@ def test_merge_videos_exception(self, mock_ffmpeg, mock_logger, generator, tmp_p mock_ffmpeg.input.return_value = mock_input mock_ffmpeg.concat.return_value = Mock() mock_ffmpeg.output.return_value.overwrite_output.return_value.run.side_effect = ( - real_ffmpeg.Error("ffmpeg", "stdout", "stderr") + ffmpeg.Error("ffmpeg", "stdout", "stderr") ) - mock_ffmpeg.Error = real_ffmpeg.Error + mock_ffmpeg.Error = ffmpeg.Error - with pytest.raises(real_ffmpeg.Error): + with pytest.raises(ffmpeg.Error): generator.merge_videos(tmp_path / "output.mp4") mock_logger.exception.assert_called_once() + + def test_cleanup(self, generator, tmp_path): + """Test cleanup removes temporary files.""" + slide = Mock() + slide.audio_path = Mock() + slide.image_path = Mock() + slide.video_path = Mock() + slide.audio_path.exists.return_value = True + slide.image_path.exists.return_value = True + slide.video_path.exists.return_value = False + + generator.slides = [slide] + generator.cleanup() + + slide.audio_path.unlink.assert_called_once() + slide.image_path.unlink.assert_called_once() + slide.video_path.unlink.assert_not_called() diff --git a/backend/tests/apps/slack/events/event_test.py b/backend/tests/apps/slack/events/event_test.py index b9ced55985..1945987913 100644 --- a/backend/tests/apps/slack/events/event_test.py +++ b/backend/tests/apps/slack/events/event_test.py @@ -4,6 +4,7 @@ import pytest from slack_sdk.errors import SlackApiError +from apps.slack.blocks import DIVIDER from apps.slack.events.event import EventBase @@ -168,3 +169,91 @@ def test_register(self, mocker, event_instance): mock_app.event.assert_called_once_with( event_instance.event_type, matchers=event_instance.matchers ) + + def test_configure_events_when_app_is_none(self, mocker): + """Tests that configure_events returns early when app is None.""" + mock_slack_config = mocker.patch("apps.slack.events.event.SlackConfig") + mock_slack_config.app = None + mock_logger = mocker.patch("apps.slack.events.event.logger") + + EventBase.configure_events() + + mock_logger.warning.assert_called_once_with( + "SlackConfig.app is None. Event handlers are not registered." + ) + + def test_get_events_yields_subclasses(self): + """Tests that get_events yields all EventBase subclasses.""" + subclasses = list(EventBase.get_events()) + assert MockEvent in subclasses + assert EventBase not in subclasses + + def test_get_direct_message(self, mocker, event_instance, mock_event_payload): + """Tests that get_direct_message returns blocks.""" + mock_template = MagicMock() + mock_template.render.return_value = "Direct message content" + mocker.patch.object( + MockEvent, + "direct_message_template", + new_callable=mocker.PropertyMock, + return_value=mock_template, + ) + + result = event_instance.get_direct_message(mock_event_payload) + + assert isinstance(result, list) + + def test_get_ephemeral_message(self, mocker, event_instance, mock_event_payload): + """Tests that get_ephemeral_message returns blocks.""" + mock_template = MagicMock() + mock_template.render.return_value = "Ephemeral message content" + mocker.patch.object( + MockEvent, + "ephemeral_message_template", + new_callable=mocker.PropertyMock, + return_value=mock_template, + ) + + result = event_instance.get_ephemeral_message(mock_event_payload) + + assert isinstance(result, list) + + def test_open_conversation_catches_cannot_dm_bot_error(self, event_instance, mock_client): + """Tests that open_conversation returns None for cannot_dm_bot error.""" + mock_slack_error = SlackApiError( + message="Cannot DM bot", response={"ok": False, "error": "cannot_dm_bot"} + ) + mock_client.conversations_open.side_effect = mock_slack_error + + result = event_instance.open_conversation(client=mock_client, user_id="U123ABC") + + assert result is None + + def test_open_conversation_raises_other_errors(self, event_instance, mock_client): + """Tests that open_conversation re-raises other SlackApiErrors.""" + mock_slack_error = SlackApiError( + message="Other error", response={"ok": False, "error": "channel_not_found"} + ) + mock_client.conversations_open.side_effect = mock_slack_error + + with pytest.raises(SlackApiError): + event_instance.open_conversation(client=mock_client, user_id="U123ABC") + + def test_render_blocks_with_divider(self, mocker, event_instance): + """Tests that render_blocks handles divider sections.""" + mock_template = MagicMock() + mock_template.render.return_value = f"{DIVIDER}" + + result = event_instance.render_blocks(template=mock_template, context={}) + + assert any(block.get("type") == "divider" for block in result) + + def test_render_blocks_with_text_section(self, mocker, event_instance): + """Tests that render_blocks handles text sections.""" + mock_template = MagicMock() + mock_template.render.return_value = "This is some text content" + + result = event_instance.render_blocks(template=mock_template, context={}) + + assert len(result) > 0 + assert result[0]["type"] == "section" diff --git a/backend/tests/apps/slack/events/member_joined_channel/catch_all_test.py b/backend/tests/apps/slack/events/member_joined_channel/catch_all_test.py index 446b13d4ec..a9f993c374 100644 --- a/backend/tests/apps/slack/events/member_joined_channel/catch_all_test.py +++ b/backend/tests/apps/slack/events/member_joined_channel/catch_all_test.py @@ -1,3 +1,5 @@ +import importlib +import sys from unittest.mock import MagicMock from apps.slack.events.member_joined_channel.catch_all import catch_all_handler @@ -11,3 +13,22 @@ def test_catch_all_handler_acknowledges_event(self): ack = MagicMock() catch_all_handler(event={}, client=MagicMock(), ack=ack) ack.assert_called_once() + + +class TestCatchAllModuleRegistration: + """Tests for the module-level event registration in catch_all.py.""" + + def test_module_registers_event_handler_when_app_exists(self, mocker): + """Tests that the event handler is registered when SlackConfig.app exists.""" + module_name = "apps.slack.events.member_joined_channel.catch_all" + if module_name in sys.modules: + del sys.modules[module_name] + + mock_app = MagicMock() + mocker.patch("apps.slack.apps.SlackConfig.app", mock_app) + importlib.import_module(module_name) + mock_app.event.assert_called_once() + call_args = mock_app.event.call_args + + assert call_args[0][0] == "member_joined_channel" + assert "matchers" in call_args[1] diff --git a/backend/tests/apps/slack/management/commands/owasp_match_channels_test.py b/backend/tests/apps/slack/management/commands/owasp_match_channels_test.py index 9e854b802d..240ce0e50e 100644 --- a/backend/tests/apps/slack/management/commands/owasp_match_channels_test.py +++ b/backend/tests/apps/slack/management/commands/owasp_match_channels_test.py @@ -1,4 +1,5 @@ from apps.owasp.models.chapter import Chapter +from apps.owasp.models.committee import Committee from apps.owasp.models.project import Project from apps.slack.management.commands.owasp_match_channels import Command from apps.slack.models import Conversation @@ -105,3 +106,107 @@ def test_strip_owasp_prefix(self): assert cmd.strip_owasp_prefix("Current OWASP Project") == "Current OWASP Project" assert cmd.strip_owasp_prefix("OWASP - Project") == "Project" assert cmd.strip_owasp_prefix("Simple Name") == "Simple Name" + + def test_strip_owasp_prefix_empty_name(self): + """Test strip_owasp_prefix with empty/None name.""" + cmd = Command() + assert cmd.strip_owasp_prefix("") == "" + assert cmd.strip_owasp_prefix(None) is None + + def test_find_fuzzy_matches_skips_empty_conversation_name(self): + """Test that conversations with empty name are skipped.""" + cmd = Command() + mock_conv_with_name = type("Conversation", (), {"name": "project-test"})() + mock_conv_without_name = type("Conversation", (), {"name": None})() + mock_conv_empty_name = type("Conversation", (), {"name": ""})() + + conversations = [mock_conv_without_name, mock_conv_empty_name, mock_conv_with_name] + matches = cmd.find_fuzzy_matches("Test Project", conversations, threshold=50) + assert len(matches) == 1 + assert matches[0][0].name == "project-test" + + def test_handle_skips_entity_without_name(self, mocker): + """Test that entities with empty name are skipped.""" + mock_committee = mocker.Mock(spec=Committee, id=1) + mock_committee.name = None + + mock_conv = mocker.Mock(spec=Conversation, id=10) + mock_conv.name = "committee-test" + + mock_conv_qs = mocker.Mock() + mock_conv_qs.only.return_value.iterator.return_value = [mock_conv] + mocker.patch( + "apps.slack.management.commands.owasp_match_channels.Conversation.objects", + mock_conv_qs, + ) + + mock_chapter_qs = mocker.Mock() + mock_chapter_qs.filter.return_value.only.return_value.iterator.return_value = [] + mocker.patch("apps.owasp.models.chapter.Chapter.objects", mock_chapter_qs) + + mock_committee_qs = mocker.Mock() + mock_committee_qs.filter.return_value.only.return_value.iterator.return_value = [ + mock_committee + ] + mocker.patch("apps.owasp.models.committee.Committee.objects", mock_committee_qs) + + mock_project_qs = mocker.Mock() + mock_project_qs.filter.return_value.only.return_value.iterator.return_value = [] + mocker.patch("apps.owasp.models.project.Project.objects", mock_project_qs) + + mock_ec_qs = mocker.Mock() + mocker.patch("apps.owasp.models.entity_channel.EntityChannel.objects", mock_ec_qs) + + mock_ct = mocker.Mock() + mocker.patch( + "apps.slack.management.commands.owasp_match_channels.ContentType.objects.get_for_model", + return_value=mock_ct, + ) + + command = Command() + command.stdout = mocker.Mock() + command.handle(dry_run=False, threshold=80) + mock_ec_qs.get_or_create.assert_not_called() + + def test_handle_committee_matches_all_conversations(self, mocker): + """Test that Committee model uses all_conversations for matching.""" + mock_committee = mocker.Mock(spec=Committee, id=1) + mock_committee.name = "OWASP Education Committee" + mock_conv = mocker.Mock(spec=Conversation, id=10) + mock_conv.name = "education-committee" + + mock_conv_qs = mocker.Mock() + mock_conv_qs.only.return_value.iterator.return_value = [mock_conv] + mocker.patch( + "apps.slack.management.commands.owasp_match_channels.Conversation.objects", + mock_conv_qs, + ) + + mock_chapter_qs = mocker.Mock() + mock_chapter_qs.filter.return_value.only.return_value.iterator.return_value = [] + mocker.patch("apps.owasp.models.chapter.Chapter.objects", mock_chapter_qs) + + mock_committee_qs = mocker.Mock() + mock_committee_qs.filter.return_value.only.return_value.iterator.return_value = [ + mock_committee + ] + mocker.patch("apps.owasp.models.committee.Committee.objects", mock_committee_qs) + + mock_project_qs = mocker.Mock() + mock_project_qs.filter.return_value.only.return_value.iterator.return_value = [] + mocker.patch("apps.owasp.models.project.Project.objects", mock_project_qs) + + mock_ec_qs = mocker.Mock() + mock_ec_qs.get_or_create.return_value = (mocker.Mock(), True) + mocker.patch("apps.owasp.models.entity_channel.EntityChannel.objects", mock_ec_qs) + + mock_ct = mocker.Mock() + mocker.patch( + "apps.slack.management.commands.owasp_match_channels.ContentType.objects.get_for_model", + return_value=mock_ct, + ) + + command = Command() + command.stdout = mocker.Mock() + command.handle(dry_run=False, threshold=50) + assert mock_ec_qs.get_or_create.call_count >= 1 diff --git a/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py b/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py index 09926a26b6..ecb70e0cf9 100644 --- a/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py +++ b/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py @@ -584,3 +584,166 @@ def test_github_user_id_resolution_fails(self, mocker): mock_resolve.assert_called_once() mock_sync.assert_not_called() + + +class TestFetchMessages: + """Tests for _fetch_messages method.""" + + target_module = "apps.slack.management.commands.slack_sync_messages" + + def test_rate_limit_handling(self, mocker): + """Test rate limiting is handled in _fetch_messages.""" + mocker.patch(f"{self.target_module}.time.sleep") + mocker.patch(f"{self.target_module}.Message") + mock_conversation = MagicMock() + mock_conversation.slack_channel_id = "C123" + mock_conversation.latest_message = None + + mock_client = MagicMock() + rate_limit_error = SlackApiError( + response={"ok": False, "error": "ratelimited", "headers": {"Retry-After": "1"}}, + message="Rate limited", + ) + rate_limit_error.response = MagicMock() + rate_limit_error.response.__getitem__ = lambda _self, key: ( + "ratelimited" if key == "error" else None + ) + rate_limit_error.response.get = lambda key, default=None: ( + "ratelimited" if key == "error" else default + ) + rate_limit_error.response.headers = {"Retry-After": "1"} + + mock_client.conversations_history.side_effect = [ + rate_limit_error, + {"ok": True, "messages": []}, + ] + + command = Command() + command.stdout = MagicMock() + + command._fetch_messages(100, mock_client, mock_conversation, 0.1, 3) + + assert mock_client.conversations_history.call_count == 2 + command.stdout.write.assert_called() + + def test_max_retries_exceeded(self, mocker): + """Test max retries exceeded in _fetch_messages.""" + mocker.patch(f"{self.target_module}.time.sleep") + mock_conversation = MagicMock() + mock_client = MagicMock() + + rate_limit_error = SlackApiError( + response={"ok": False, "error": "ratelimited"}, + message="Rate limited", + ) + rate_limit_error.response = MagicMock() + rate_limit_error.response.__getitem__ = lambda _self, key: ( + "ratelimited" if key == "error" else None + ) + rate_limit_error.response.get = lambda key, default=None: ( + "ratelimited" if key == "error" else default + ) + rate_limit_error.response.headers = {"Retry-After": "1"} + + mock_client.conversations_history.side_effect = rate_limit_error + + command = Command() + command.stdout = MagicMock() + + command._fetch_messages(100, mock_client, mock_conversation, 0.1, 1) + + assert mock_client.conversations_history.call_count == 2 + + def test_generic_api_error(self, mocker): + """Test generic API error in _fetch_messages.""" + mock_conversation = MagicMock() + mock_client = MagicMock() + + error = SlackApiError( + response={"ok": False, "error": "fatal_error"}, + message="Fatal error", + ) + error.response = {"error": "fatal_error", "ok": False} + + mock_client.conversations_history.side_effect = error + + command = Command() + command.stdout = MagicMock() + + command._fetch_messages(100, mock_client, mock_conversation, 0.1, 3) + + command.stdout.write.assert_called() + + +class TestSyncUserMessagesAdvanced: + """Advanced tests for _sync_user_messages including date filtering.""" + + target_module = "apps.slack.management.commands.slack_sync_messages" + + def test_date_filtering_and_pagination(self, mocker): + """Test date filtering logic: skip future, process current, break on past.""" + mocker.patch(f"{self.target_module}.os.environ.get", return_value="token") + mocker.patch(f"{self.target_module}.Workspace") + mock_conversation_cls = mocker.patch(f"{self.target_module}.Conversation") + mock_conversation_cls.objects.get_or_create.return_value = (MagicMock(), True) + mock_msg_model = mocker.patch(f"{self.target_module}.Message") + mock_queryset = MagicMock() + mock_queryset.exists.return_value = True + mock_queryset.__iter__.return_value = [MagicMock()] + + mock_workspace = MagicMock() + mock_workspace.objects.all.return_value = mock_queryset + mocker.patch(f"{self.target_module}.Workspace", mock_workspace) + + mock_client = MagicMock() + mocker.patch(f"{self.target_module}.WebClient", return_value=mock_client) + ts_future = "1704196800" + ts_current = "1704110400" + ts_past = "1704024000" + + mock_response = { + "ok": True, + "messages": { + "matches": [ + {"ts": ts_future, "channel": {"id": "C1"}}, + {"ts": ts_current, "channel": {"id": "C1"}}, + {"ts": ts_past, "channel": {"id": "C1"}}, + ] + }, + } + mock_client.search_messages.return_value = mock_response + + command = Command() + command.stdout = MagicMock() + mocker.patch.object(command, "_create_message", side_effect=lambda **_kwargs: MagicMock()) + + command._sync_user_messages( + "U1", start_at="2024-01-01", end_at="2024-01-02", delay=0, max_retries=1 + ) + mock_msg_model.bulk_save.assert_called() + saved_list = mock_msg_model.bulk_save.call_args[0][0] + assert len(saved_list) == 1 + + def test_create_message_bot_failure(self, mocker): + """Test _create_message handles bot info failure.""" + command = Command() + command.stdout = MagicMock() + + mock_client = MagicMock() + mock_client.bots_info.side_effect = SlackApiError( + message="Error", response={"ok": False, "error": "fatal"} + ) + + mock_member = mocker.patch(f"{self.target_module}.Member") + mock_member.DoesNotExist = Exception + mock_member.objects.get.side_effect = Exception + + msg_data = {"bot_id": "B1"} + + mock_msg_model = mocker.patch(f"{self.target_module}.Message") + + command._create_message(mock_client, msg_data, MagicMock(), 0, 1) + command.stdout.write.assert_called() + mock_msg_model.update_data.assert_called_with( + data=msg_data, conversation=mocker.ANY, author=None, parent_message=None, save=False + ) diff --git a/backend/tests/apps/slack/models/message_test.py b/backend/tests/apps/slack/models/message_test.py index 851e1d6a0e..280d77d6d6 100644 --- a/backend/tests/apps/slack/models/message_test.py +++ b/backend/tests/apps/slack/models/message_test.py @@ -1,4 +1,4 @@ -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch from apps.slack.models.conversation import Conversation from apps.slack.models.member import Member @@ -180,3 +180,103 @@ def test_update_data_with_thread_parent(self, mocker): def test_str_method(self): message = Message(raw_data={"text": "Short message"}) assert str(message) == "Short message" + + def test_str_method_huddle_thread(self): + """Test __str__ with huddle_thread subtype.""" + message = Message( + raw_data={"text": "Ignored", "subtype": "huddle_thread", "channel": "C123"} + ) + assert str(message) == "C123 huddle" + + def test_cleaned_text_empty(self): + """Test cleaned_text returns empty string when text is empty.""" + message = Message(raw_data={"text": ""}) + assert message.cleaned_text == "" + + def test_cleaned_text_removes_emojis(self): + """Test cleaned_text removes emojis.""" + message = Message(raw_data={"text": "Hello 👋 World"}) + result = message.cleaned_text + assert "👋" not in result + assert "Hello" in result + assert "World" in result + + def test_cleaned_text_removes_user_mentions(self): + """Test cleaned_text removes user mentions.""" + message = Message(raw_data={"text": "Hey <@U12345678> check this"}) + result = message.cleaned_text + assert "<@U12345678>" not in result + assert "Hey" in result + assert "check this" in result + + def test_cleaned_text_removes_links(self): + """Test cleaned_text removes links.""" + message = Message(raw_data={"text": "Check "}) + result = message.cleaned_text + assert "https://example.com" not in result + + def test_cleaned_text_removes_emoji_aliases(self): + """Test cleaned_text removes emoji aliases.""" + message = Message(raw_data={"text": "Great :smile: work"}) + result = message.cleaned_text + assert ":smile:" not in result + + def test_cleaned_text_normalizes_whitespace(self): + """Test cleaned_text normalizes multiple whitespaces.""" + message = Message(raw_data={"text": "Hello World"}) + result = message.cleaned_text + assert " " not in result + + def test_subtype_property(self): + """Test subtype property returns subtype from raw_data.""" + message = Message(raw_data={"text": "test", "subtype": "bot_message"}) + assert message.subtype == "bot_message" + + def test_subtype_property_none(self): + """Test subtype property returns None when not present.""" + message = Message(raw_data={"text": "test"}) + assert message.subtype is None + + def test_text_property(self): + """Test text property returns text from raw_data.""" + message = Message(raw_data={"text": "Hello world"}) + assert message.text == "Hello world" + + def test_text_property_default(self): + """Test text property returns empty string when no text.""" + message = Message(raw_data={}) + assert message.text == "" + + def test_ts_property(self): + """Test ts property returns timestamp from raw_data.""" + message = Message(raw_data={"ts": "1234567890.123456", "text": ""}) + assert message.ts == "1234567890.123456" + + def test_url_property(self): + """Test url property returns correct Slack message URL.""" + mock_message = MagicMock(spec=Message) + mock_message.conversation.workspace.name = "TestWorkspace" + mock_message.conversation.slack_channel_id = "C12345" + mock_message.slack_message_id = "1234567890.123456" + result = Message.url.fget(mock_message) + + expected_url = "https://testworkspace.slack.com/archives/C12345/p1234567890123456" + assert result == expected_url + + def test_latest_reply_property(self, mocker): + """Test latest_reply property returns most recent reply.""" + mock_conversation = create_model_mock(Conversation) + + message = Message(raw_data={"text": "Parent"}) + message.conversation = mock_conversation + + mock_reply = create_model_mock(Message) + mock_reply.raw_data = {"text": "Latest reply"} + + mock_filter = mocker.patch.object(Message.objects, "filter") + mock_filter.return_value.order_by.return_value.first.return_value = mock_reply + + result = message.latest_reply + + assert result == mock_reply + mock_filter.assert_called_once() diff --git a/backend/tests/apps/slack/utils_test.py b/backend/tests/apps/slack/utils_test.py index ee1c6e71e1..897b519816 100644 --- a/backend/tests/apps/slack/utils_test.py +++ b/backend/tests/apps/slack/utils_test.py @@ -1,13 +1,17 @@ -from unittest.mock import Mock +from unittest.mock import Mock, patch from urllib.parse import urljoin import pytest +from requests.exceptions import RequestException from apps.common.constants import OWASP_NEWS_URL from apps.slack.utils import ( escape, + format_links_for_slack, get_gsoc_projects, get_news_data, + get_posts_data, + get_sponsors_data, get_staff_data, get_text, strip_markdown, @@ -80,6 +84,19 @@ def test_process_mrkdwn(input_text, expected_output): assert strip_markdown(input_text) == expected_output +@pytest.mark.parametrize( + ("input_text", "expected_output"), + [ + ("Check [link](https://example.com)", "Check "), + ("", ""), + (None, None), + ], +) +def test_format_links_for_slack(input_text, expected_output): + """Test format_links_for_slack with various inputs including empty text.""" + assert format_links_for_slack(input_text) == expected_output + + @pytest.mark.parametrize( ("input_blocks", "expected_output"), [ @@ -132,6 +149,18 @@ def test_process_mrkdwn(input_text, expected_output): ], "Image: https://example.com/image.jpg", ), + ( + [ + { + "type": "section", + "fields": [ + {"type": "mrkdwn", "text": "Field 1"}, + {"type": "mrkdwn", "text": "Field 2"}, + ], + } + ], + "Field 1\nField 2", + ), ], ) def test_blocks_to_text(input_blocks, expected_output): @@ -189,6 +218,7 @@ def test_get_news_data(monkeypatch): mock_get = Mock(return_value=mock_response) monkeypatch.setattr("requests.get", mock_get) + get_news_data.cache_clear() result = get_news_data() length = 3 @@ -213,6 +243,8 @@ def test_get_staff_data(monkeypatch): mock_response.text = MOCK_STAFF_YAML mock_get = Mock(return_value=mock_response) monkeypatch.setattr("requests.get", mock_get) + get_staff_data.cache_clear() + length = 3 result = get_staff_data() assert len(result) == length @@ -223,3 +255,61 @@ def test_get_staff_data(monkeypatch): mock_get.assert_called_once_with( "https://raw.githubusercontent.com/OWASP/owasp.github.io/main/_data/staff.yml", timeout=30 ) + + +def test_get_staff_data_request_exception(monkeypatch): + """Test get_staff_data handles RequestException gracefully.""" + mock_get = Mock(side_effect=RequestException("Network error")) + monkeypatch.setattr("requests.get", mock_get) + get_staff_data.cache_clear() + + result = get_staff_data() + assert result is None + + +def test_get_sponsors_data(): + """Test get_sponsors_data returns sponsors queryset.""" + mock_sponsor = Mock() + mock_queryset = Mock() + mock_queryset.__getitem__ = Mock(return_value=[mock_sponsor]) + + with patch("apps.owasp.models.sponsor.Sponsor.objects") as mock_objects: + mock_objects.all.return_value = mock_queryset + + result = get_sponsors_data(limit=5) + mock_objects.all.assert_called_once() + assert result is not None + + +def test_get_sponsors_data_exception(): + """Test get_sponsors_data handles exceptions gracefully.""" + with patch("apps.owasp.models.sponsor.Sponsor.objects") as mock_objects: + mock_objects.all.side_effect = Exception("Database error") + + result = get_sponsors_data() + assert result is None + + +def test_get_posts_data(): + """Test get_posts_data returns posts queryset.""" + mock_post = Mock() + mock_queryset = Mock() + mock_queryset.__getitem__ = Mock(return_value=[mock_post]) + + with patch("apps.owasp.models.post.Post.recent_posts") as mock_recent: + mock_recent.return_value = mock_queryset + get_posts_data.cache_clear() + + result = get_posts_data(limit=3) + mock_recent.assert_called_once() + assert result is not None + + +def test_get_posts_data_exception(): + """Test get_posts_data handles exceptions gracefully.""" + with patch("apps.owasp.models.post.Post.recent_posts") as mock_recent: + mock_recent.side_effect = Exception("Database error") + get_posts_data.cache_clear() + + result = get_posts_data() + assert result is None diff --git a/cspell/custom-dict.txt b/cspell/custom-dict.txt index 17cd6ae864..a5fa8d84c9 100644 --- a/cspell/custom-dict.txt +++ b/cspell/custom-dict.txt @@ -13,6 +13,7 @@ Cañón DRF ELB ELEVENLABS +Emay FOSS GBP GFKs @@ -23,6 +24,8 @@ Golovanova Hackbright Héllo Illia +Ime +Iyonsi Kateryna Kerlyn Keshav diff --git a/cspell/package.json b/cspell/package.json index 77c0a1bfe9..b7ea7714ef 100644 --- a/cspell/package.json +++ b/cspell/package.json @@ -2,7 +2,7 @@ "devDependencies": { "@cspell/dict-aws": "^4.0.17", "@cspell/dict-data-science": "^2.0.13", - "@cspell/dict-en_us": "^4.4.28", + "@cspell/dict-en_us": "^4.4.29", "@cspell/dict-fullstack": "^3.2.8", "@cspell/dict-golang": "^6.0.26", "@cspell/dict-k8s": "^1.0.12", diff --git a/cspell/pnpm-lock.yaml b/cspell/pnpm-lock.yaml index e0e38f877a..b3bff05589 100644 --- a/cspell/pnpm-lock.yaml +++ b/cspell/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^2.0.13 version: 2.0.13 '@cspell/dict-en_us': - specifier: ^4.4.28 - version: 4.4.28 + specifier: ^4.4.29 + version: 4.4.29 '@cspell/dict-fullstack': specifier: ^3.2.8 version: 3.2.8 @@ -119,8 +119,8 @@ packages: '@cspell/dict-en-gb@1.1.33': resolution: {integrity: sha512-tKSSUf9BJEV+GJQAYGw5e+ouhEe2ZXE620S7BLKe3ZmpnjlNG9JqlnaBhkIMxKnNFkLY2BP/EARzw31AZnOv4g==} - '@cspell/dict-en_us@4.4.28': - resolution: {integrity: sha512-/rzhbZaZDsDWmXbc9Fmmr4/ngmaNcG2b+TGT+ZjGqpOXVQYI75yZ9+XduyI43xJ5O38QcX3QIbJY5GWaJqxPEg==} + '@cspell/dict-en_us@4.4.29': + resolution: {integrity: sha512-G3B27++9ziRdgbrY/G/QZdFAnMzzx17u8nCb2Xyd4q6luLpzViRM/CW3jA+Mb/cGT5zR/9N+Yz9SrGu1s0bq7g==} '@cspell/dict-filetypes@3.0.15': resolution: {integrity: sha512-uDMeqYlLlK476w/muEFQGBy9BdQWS0mQ7BJiy/iQv5XUWZxE2O54ZQd9nW8GyQMzAgoyg5SG4hf9l039Qt66oA==} @@ -196,8 +196,8 @@ packages: '@cspell/dict-node@5.0.9': resolution: {integrity: sha512-hO+ga+uYZ/WA4OtiMEyKt5rDUlUyu3nXMf8KVEeqq2msYvAPdldKBGH7lGONg6R/rPhv53Rb+0Y1SLdoK1+7wQ==} - '@cspell/dict-npm@5.2.32': - resolution: {integrity: sha512-H0XD0eg4d96vevle8VUKVoPhsgsw003ByJ47XzipyiMKoQTZ2IAUW+VTkQq8wU1floarNjmThQJOoKL9J4UYuw==} + '@cspell/dict-npm@5.2.33': + resolution: {integrity: sha512-U1gfDxdFR6nnojvtdkF2Ati3jfIlnW5nJkFb2jS1JunlhrSYdZXwz/4bI//h1W3aaeYQoSlvTIqk3vlnIDrNng==} '@cspell/dict-people-names@1.1.16': resolution: {integrity: sha512-jiV+V32DVdaMqpznnqqNNMNaKFtyaHnZvak7HrVLWulGgobilQk+8NzFO9mtkyDs7Pde7CEGSExBAvc+xZxgeA==} @@ -467,7 +467,7 @@ snapshots: '@cspell/dict-elixir': 4.0.8 '@cspell/dict-en-common-misspellings': 2.1.12 '@cspell/dict-en-gb': 1.1.33 - '@cspell/dict-en_us': 4.4.28 + '@cspell/dict-en_us': 4.4.29 '@cspell/dict-filetypes': 3.0.15 '@cspell/dict-flutter': 1.1.1 '@cspell/dict-fonts': 4.0.5 @@ -491,7 +491,7 @@ snapshots: '@cspell/dict-markdown': 2.0.14(@cspell/dict-css@4.0.19)(@cspell/dict-html-symbol-entities@4.0.5)(@cspell/dict-html@4.0.14)(@cspell/dict-typescript@3.2.3) '@cspell/dict-monkeyc': 1.0.12 '@cspell/dict-node': 5.0.9 - '@cspell/dict-npm': 5.2.32 + '@cspell/dict-npm': 5.2.33 '@cspell/dict-php': 4.1.1 '@cspell/dict-powershell': 5.0.15 '@cspell/dict-public-licenses': 2.0.15 @@ -559,7 +559,7 @@ snapshots: '@cspell/dict-en-gb@1.1.33': {} - '@cspell/dict-en_us@4.4.28': {} + '@cspell/dict-en_us@4.4.29': {} '@cspell/dict-filetypes@3.0.15': {} @@ -612,7 +612,7 @@ snapshots: '@cspell/dict-node@5.0.9': {} - '@cspell/dict-npm@5.2.32': {} + '@cspell/dict-npm@5.2.33': {} '@cspell/dict-people-names@1.1.16': {} diff --git a/docker-compose/proxy/compose.yaml b/docker-compose/proxy/compose.yaml index 1d3cf22305..6862a77811 100644 --- a/docker-compose/proxy/compose.yaml +++ b/docker-compose/proxy/compose.yaml @@ -2,7 +2,7 @@ services: nest-certbot: container_name: nest-certbot entrypoint: /bin/sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot --quiet; sleep 12h & wait $${!}; done;' - image: certbot/certbot + image: certbot/certbot:v5.3.0 restart: unless-stopped volumes: - ./blocked_ips.conf:/etc/nginx/blocked_ips.conf @@ -17,7 +17,7 @@ services: nest-nginx: container_name: nest-nginx - image: nginx:latest + image: nginx:1.29.5-alpine networks: - production_nest-app-network - staging_nest-app-network diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 9717409350..9eaff52aac 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -33,7 +33,7 @@ USER owasp COPY --chmod=444 --chown=root:root poetry.lock pyproject.toml ./ RUN --mount=type=cache,target=${POETRY_CACHE_DIR},uid=${OWASP_UID},gid=${OWASP_GID} \ - poetry install --no-root --without test --without video + poetry install --no-root --verbose --without test --without video COPY apps apps COPY Makefile entrypoint.sh manage.py wsgi.py ./ diff --git a/docker/backend/Dockerfile.local b/docker/backend/Dockerfile.local index dc5b73137c..8215c90b62 100644 --- a/docker/backend/Dockerfile.local +++ b/docker/backend/Dockerfile.local @@ -28,7 +28,7 @@ WORKDIR /home/owasp COPY --chmod=444 --chown=root:root poetry.lock pyproject.toml ./ RUN --mount=type=cache,target=${POETRY_CACHE_DIR},uid=${OWASP_UID},gid=${OWASP_GID} \ - poetry install --no-root --without test --without video + poetry install --no-root --verbose --without test --without video FROM python:3.13.11-alpine3.23 diff --git a/docker/backend/Dockerfile.test b/docker/backend/Dockerfile.test index c453733c5e..8b734a7a2d 100644 --- a/docker/backend/Dockerfile.test +++ b/docker/backend/Dockerfile.test @@ -26,7 +26,7 @@ USER owasp COPY --chmod=444 --chown=root:root poetry.lock pyproject.toml ./ RUN --mount=type=cache,target=${POETRY_CACHE_DIR},uid=${OWASP_UID},gid=${OWASP_GID} \ - poetry install --no-root + poetry install --no-root --verbose COPY .env.example .env.example COPY apps apps diff --git a/docker/backend/Dockerfile.video b/docker/backend/Dockerfile.video index 1ea9ce5ef9..e718816d13 100644 --- a/docker/backend/Dockerfile.video +++ b/docker/backend/Dockerfile.video @@ -19,7 +19,7 @@ RUN --mount=type=cache,target=${PIP_CACHE_DIR} \ COPY --chmod=444 --chown=root:root poetry.lock pyproject.toml ./ RUN --mount=type=cache,target=${POETRY_CACHE_DIR},uid=${OWASP_UID},gid=${OWASP_GID} \ - poetry install --no-root --without test && \ + poetry install --no-root --verbose --without test && \ python -m pip uninstall -y poetry RUN --mount=type=cache,target=${APK_CACHE_DIR} \ diff --git a/docker/docs/Dockerfile.local b/docker/docs/Dockerfile.local index 23af39e0d1..8a49ad3d51 100644 --- a/docker/docs/Dockerfile.local +++ b/docker/docs/Dockerfile.local @@ -28,7 +28,7 @@ USER owasp COPY --chmod=444 --chown=root:root docs/poetry.lock docs/pyproject.toml mkdocs.yaml ./ RUN --mount=type=cache,target=${POETRY_CACHE_DIR},uid=${OWASP_UID},gid=${OWASP_GID} \ - poetry install --no-root && \ + poetry install --no-root --verbose && \ rm -rf docs/poetry.lock docs/pyproject.toml FROM python:3.13.11-alpine3.23 diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile index a20c3773d6..4d81fb2efd 100644 --- a/docker/frontend/Dockerfile +++ b/docker/frontend/Dockerfile @@ -47,25 +47,23 @@ RUN --mount=type=secret,id=RELEASE_VERSION \ # Production image, copy all the files and run next. FROM base AS runner -WORKDIR /app ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production +WORKDIR /tmp + # Fix CVE-2026-23745: Update npm's bundled tar to 7.5.7 in runner stage +# Fix CVE-2026-25547: Update npm's bundled @isaacs/brace-expansion to 5.0.1 # Note: Must download tar with npm pack BEFORE removing the old tar (npm needs it) -RUN cd /tmp && \ - npm pack tar@7.5.7 && \ +RUN npm pack tar@7.5.7 && \ tar -xzf tar-7.5.7.tgz && \ TAR_DIR="/usr/local/lib/node_modules/npm/node_modules/tar" && \ rm -rf "${TAR_DIR}" && \ cp -r package "${TAR_DIR}" && \ chmod -R 755 "${TAR_DIR}" && \ rm -rf package tar-7.5.7.tgz && \ - grep -q 'version.*7.5.7' "${TAR_DIR}/package.json" - -# Fix CVE-2026-25547: Update npm's bundled @isaacs/brace-expansion to 5.0.1 in runner stage -RUN cd /tmp && \ + grep -q 'version.*7.5.7' "${TAR_DIR}/package.json" && \ npm pack @isaacs/brace-expansion@5.0.1 && \ tar -xzf isaacs-brace-expansion-5.0.1.tgz && \ BRACE_DIR="/usr/local/lib/node_modules/npm/node_modules/@isaacs/brace-expansion" && \ @@ -74,10 +72,12 @@ RUN cd /tmp && \ cp -r package "${BRACE_DIR}" && \ chmod -R 755 "${BRACE_DIR}" && \ rm -rf package isaacs-brace-expansion-5.0.1.tgz && \ - grep -q '"version": "5.0.1"' "${BRACE_DIR}/package.json" - -RUN addgroup --system --gid 1001 nodejs && \ + grep -q '"version": "5.0.1"' "${BRACE_DIR}/package.json" && \ + addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 -G nodejs nextjs + +WORKDIR /app + # Copying files with root as owner, so that executing user cannot change the container. COPY --from=builder --chown=root:root --chmod=555 /app/public public diff --git a/docker/frontend/Dockerfile.e2e.test b/docker/frontend/Dockerfile.e2e.test index caff18b454..52b95e7f05 100644 --- a/docker/frontend/Dockerfile.e2e.test +++ b/docker/frontend/Dockerfile.e2e.test @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/playwright:v1.58.1-jammy +FROM mcr.microsoft.com/playwright:v1.58.2-jammy ENV FORCE_COLOR=1 \ NPM_CACHE="/app/.npm" \ diff --git a/docs/poetry.lock b/docs/poetry.lock index d76b141799..490c0d6df8 100644 --- a/docs/poetry.lock +++ b/docs/poetry.lock @@ -283,14 +283,14 @@ files = [ [[package]] name = "markdown" -version = "3.10.1" +version = "3.10.2" description = "Python implementation of John Gruber's Markdown." optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3"}, - {file = "markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a"}, + {file = "markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36"}, + {file = "markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950"}, ] [package.extras] @@ -535,14 +535,14 @@ mkdocs = ">=1.4.1" [[package]] name = "mkdocstrings" -version = "1.0.2" +version = "1.0.3" description = "Automatic documentation from sources, for MkDocs." optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "mkdocstrings-1.0.2-py3-none-any.whl", hash = "sha256:41897815a8026c3634fe5d51472c3a569f92ded0ad8c7a640550873eea3b6817"}, - {file = "mkdocstrings-1.0.2.tar.gz", hash = "sha256:48edd0ccbcb9e30a3121684e165261a9d6af4d63385fc4f39a54a49ac3b32ea8"}, + {file = "mkdocstrings-1.0.3-py3-none-any.whl", hash = "sha256:0d66d18430c2201dc7fe85134277382baaa15e6b30979f3f3bdbabd6dbdb6046"}, + {file = "mkdocstrings-1.0.3.tar.gz", hash = "sha256:ab670f55040722b49bb45865b2e93b824450fb4aef638b00d7acb493a9020434"}, ] [package.dependencies] diff --git a/docs/pyproject.toml b/docs/pyproject.toml index 458c7b028e..ae97d10b65 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -8,11 +8,9 @@ version = "0.1.0" description = "OWASP Nest Documentation" authors = [ "Arkadii Yakovets " ] license = "MIT" - -[tool.poetry.dependencies] -mkdocs = "^1.6.1" -mkdocs-material = "^9.6.19" -mkdocs-minify-plugin = "^0.8.0" -mkdocstrings = "^1.0.0" -pymdown-extensions = "^10.20.0" -python = "^3.13" +dependencies.mkdocs = "^1.6.1" +dependencies.mkdocs-material = "^9.6.19" +dependencies.mkdocs-minify-plugin = "^0.8.0" +dependencies.mkdocstrings = "^1.0.0" +dependencies.pymdown-extensions = "^10.20.0" +dependencies.python = "^3.13" diff --git a/frontend/__tests__/mockData/mockCommitteeDetailsData.ts b/frontend/__tests__/mockData/mockCommitteeDetailsData.ts index ec8d11ec84..15ba8501f7 100644 --- a/frontend/__tests__/mockData/mockCommitteeDetailsData.ts +++ b/frontend/__tests__/mockData/mockCommitteeDetailsData.ts @@ -3,6 +3,7 @@ export const mockCommitteeDetailsData = { contributorsCount: 10, forksCount: 5, issuesCount: 3, + key: 'test_committee', leaders: ['Leader 1', 'Leader 2'], name: 'Test Committee', relatedUrls: ['https://twitter.com/testcommittee', 'https://github.com/testcommittee'], diff --git a/frontend/__tests__/mockData/mockOrganizationData.ts b/frontend/__tests__/mockData/mockOrganizationData.ts index 49bd182716..19c621e429 100644 --- a/frontend/__tests__/mockData/mockOrganizationData.ts +++ b/frontend/__tests__/mockData/mockOrganizationData.ts @@ -102,6 +102,7 @@ export const mockOrganizationDetailsData = { tagName: 'v1.0.0', publishedAt: 1727390000, url: 'https://github.com/test-org/test-repo-1/releases/tag/v1.0.0', + organizationName: 'test-org', repositoryName: 'test-repo-1', author: { login: 'user1', @@ -113,6 +114,7 @@ export const mockOrganizationDetailsData = { tagName: 'v2.0.0', publishedAt: 1727380000, url: 'https://github.com/test-org/test-repo-2/releases/tag/v2.0.0', + organizationName: 'test-org', repositoryName: 'test-repo-2', author: { login: 'user2', diff --git a/frontend/__tests__/unit/components/CalendarButton.test.tsx b/frontend/__tests__/unit/components/CalendarButton.test.tsx index 55203cb7e1..fa8607b9de 100644 --- a/frontend/__tests__/unit/components/CalendarButton.test.tsx +++ b/frontend/__tests__/unit/components/CalendarButton.test.tsx @@ -332,58 +332,62 @@ describe('CalendarButton', () => { }) }) - describe('long title overflow handling', () => { - it('remains accessible with very long event titles', () => { - const longTitle = - 'This Is A Very Long Event Title That Extends Beyond Normal Length With Additional Description' - render( - - ) + describe('hover state', () => { + it('toggles icon on hover - shows FaCalendarPlus when hovering', async () => { + render() const button = screen.getByRole('button') - expect(button).toBeInTheDocument() - expect(button).toHaveAttribute('aria-label', `Add ${longTitle} to Calendar`) + + // Initially should show FaCalendar (not hovered) + const initialIconMarkup = button.querySelector('svg')?.outerHTML + + // Simulate mouse enter + fireEvent.mouseEnter(button) + await waitFor(() => { + // After hover, FaCalendarPlus should be shown (different SVG) + const hoveredIconMarkup = button.querySelector('svg')?.outerHTML + expect(hoveredIconMarkup).not.toBe(initialIconMarkup) + }) }) - it('maintains visibility with flex-shrink-0 class', () => { - render( - - ) + it('reverts to FaCalendar icon when mouse leaves', async () => { + render() const button = screen.getByRole('button') - expect(button).toHaveClass('flex-shrink-0') - expect(button).toBeVisible() + + // Capture initial icon (FaCalendar) + const initialIconHtml = button.innerHTML + + // Mouse enter - hover state true + fireEvent.mouseEnter(button) + + await waitFor(() => { + // Icon should change to FaCalendarPlus + expect(button.innerHTML).not.toEqual(initialIconHtml) + }) + + // Mouse leave - hover state false + fireEvent.mouseLeave(button) + + await waitFor(() => { + // Icon should revert to FaCalendar + expect(button.innerHTML).toEqual(initialIconHtml) + }) }) - it('works correctly in flex container with long text sibling', () => { - const { container } = render( -
- - -
- ) - const button = container.querySelector('button[aria-label="Add Event to Calendar"]') - expect(button).toBeInTheDocument() - expect(button).toHaveClass('flex-shrink-0') + it('maintains button functionality during hover state transitions', async () => { + render() + const button = screen.getByRole('button') + + fireEvent.mouseEnter(button) + fireEvent.mouseLeave(button) + + expect(button).not.toBeDisabled() + + fireEvent.click(button) + expect(button).toBeDisabled() + + await waitFor(() => { + expect(button).not.toBeDisabled() + }) }) }) }) diff --git a/frontend/__tests__/unit/components/Card.test.tsx b/frontend/__tests__/unit/components/Card.test.tsx index d527a4bed9..89a2ba8455 100644 --- a/frontend/__tests__/unit/components/Card.test.tsx +++ b/frontend/__tests__/unit/components/Card.test.tsx @@ -647,4 +647,87 @@ describe('Card', () => { expect(screen.queryByTestId('label-more')).not.toBeInTheDocument() expect(screen.getAllByTestId('label')).toHaveLength(5) }) + + describe('timeline rendering', () => { + it('renders timeline when both start and end dates are provided', () => { + const propsWithTimeline = { + ...baseProps, + timeline: { + start: '2024-01-01', + end: '2024-12-31', + }, + } + render() + expect(screen.getByTestId('calendar-icon')).toBeInTheDocument() + }) + + it('does not render timeline when start date is empty string', () => { + const propsWithEmptyStart = { + ...baseProps, + timeline: { + start: '', + end: '2024-12-31', + }, + } + render() + expect(screen.queryByTestId('calendar-icon')).not.toBeInTheDocument() + }) + + it('does not render timeline when end date is empty string', () => { + const propsWithEmptyEnd = { + ...baseProps, + timeline: { + start: '2024-01-01', + end: '', + }, + } + render() + expect(screen.queryByTestId('calendar-icon')).not.toBeInTheDocument() + }) + + it('does not render timeline when timeline is undefined', () => { + render() + expect(screen.queryByTestId('calendar-icon')).not.toBeInTheDocument() + }) + }) + + describe('social media aria-label fallback', () => { + it('uses item title as aria-label when provided', () => { + const propsWithSocialTitle = { + ...baseProps, + social: [ + { title: 'GitHub Profile', url: 'https://github.com/test', icon: MockIcon as IconType }, + ], + } + render() + const socialLink = screen.getByRole('link', { name: 'GitHub Profile' }) + expect(socialLink).toHaveAttribute('aria-label', 'GitHub Profile') + }) + + it('uses fallback aria-label when item title is empty', () => { + const propsWithEmptySocialTitle = { + ...baseProps, + social: [{ title: '', url: 'https://github.com/test', icon: MockIcon as IconType }], + } + render() + const socialLink = screen.getByRole('link', { name: 'Social media link' }) + expect(socialLink).toHaveAttribute('aria-label', 'Social media link') + }) + + it('uses fallback aria-label when item title is undefined', () => { + const propsWithUndefinedSocialTitle = { + ...baseProps, + social: [ + { + title: undefined as unknown as string, + url: 'https://github.com/test', + icon: MockIcon as IconType, + }, + ], + } + render() + const socialLink = screen.getByRole('link', { name: 'Social media link' }) + expect(socialLink).toHaveAttribute('aria-label', 'Social media link') + }) + }) }) diff --git a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx index 8b11320acb..2692f9072a 100644 --- a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx +++ b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx @@ -43,6 +43,33 @@ jest.mock('utils/env.client', () => ({ IS_PROJECT_HEALTH_ENABLED: true, })) +jest.mock('next-auth/react', () => { + return { + useSession: jest.fn(() => ({ + data: null, + status: 'unauthenticated', + })), + SessionProvider: ({ children }: { children: React.ReactNode }) => children, + } +}) + +jest.mock('utils/scrollToAnchor', () => ({ + scrollToAnchor: jest.fn(), +})) + +jest.mock('utils/dateFormatter', () => ({ + formatDate: (date: string | number) => { + if (typeof date === 'string') return date + return new Date(date).toISOString().split('T')[0] + }, +})) + +jest.mock('utils/urlFormatter', () => ({ + getMemberUrl: (login: string) => `/members/${login}`, + getMenteeUrl: (programKey: string, entityKey: string, login: string) => + `/programs/${programKey}/mentees/${login}`, +})) + jest.mock('utils/urlIconMappings', () => ({ getSocialIcon: (url: string) => { const safe = encodeURIComponent(url) @@ -355,12 +382,14 @@ jest.mock('components/ToggleableList', () => ({ icon: _icon, label, entityKey: _entityKey, + isDisabled: _isDisabled, ...props }: { items: string[] _icon: unknown label: React.ReactNode entityKey: string + isDisabled?: boolean [key: string]: unknown }) => (
@@ -376,24 +405,154 @@ jest.mock('components/ContributorsList', () => ({ maxInitialDisplay, // eslint-disable-next-line @typescript-eslint/no-unused-vars icon, - label = 'Contributors', + title = 'Contributors', // eslint-disable-next-line @typescript-eslint/no-unused-vars getUrl, ...props }: { contributors: unknown[] icon?: unknown - label?: string + title?: string maxInitialDisplay: number getUrl: (login: string) => string [key: string]: unknown }) => (
- {label} ({contributors.length} items, max display: {maxInitialDisplay}) + {title} ({contributors.length} items, max display: {maxInitialDisplay}) +
+ ), +})) + +jest.mock('components/EntityActions', () => ({ + __esModule: true, + default: ({ + type, + programKey, + moduleKey, + status: _status, + setStatus: _setStatus, + ...props + }: { + type: string + programKey?: string + moduleKey?: string + status?: string + setStatus?: (status: string) => void + [key: string]: unknown + }) => ( +
+ EntityActions: type={type}, programKey={programKey}, moduleKey={moduleKey} +
+ ), +})) + +jest.mock('components/Leaders', () => { + return { + __esModule: true, + default: ({ users, ...props }: { users: unknown[]; [key: string]: unknown }) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const usersList = users as any[] + return ( +
+

Leaders

+ {Array.isArray(usersList) && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + usersList.map((user: any, index: number) => { + const uniqueKey = `leader-${index}-${user.login || 'unknown'}` + return ( +
+
{user.member?.name || user.memberName || 'Unknown'}
+
{user.description || ''}
+
+ ) + })} +
+ ) + }, + } +}) + +jest.mock('components/StatusBadge', () => ({ + __esModule: true, + default: ({ + status, + _size, + ...props + }: { + status: string + _size?: string + [key: string]: unknown + }) => ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ), +})) + +jest.mock('components/MarkdownWrapper', () => ({ + __esModule: true, + default: ({ content, ...props }: { content: string; [key: string]: unknown }) => ( +
+ {content} +
+ ), +})) + +jest.mock('components/ModuleCard', () => ({ + __esModule: true, + default: ({ + modules, + accessLevel: _accessLevel, + admins: _admins, + ...props + }: { + modules: unknown[] + accessLevel: string + admins?: unknown[] + [key: string]: unknown + }) => ( +
+ ModuleCard ({modules?.length || 0} modules)
), })) +jest.mock('components/ShowMoreButton', () => { + function ShowMoreButtonMock({ + onToggle, + ...props + }: Readonly<{ + onToggle: () => void + [key: string]: unknown + }>) { + const [isExpanded, setIsExpanded] = React.useState(false) + return ( + + ) + } + return { + __esModule: true, + default: ShowMoreButtonMock, + } +}) + +jest.mock('components/TruncatedText', () => ({ + __esModule: true, + TruncatedText: ({ text }: { text: string }) => {text}, +})) + describe('CardDetailsPage', () => { const createMalformedData = >( validData: T, @@ -663,7 +822,7 @@ describe('CardDetailsPage', () => { render() expect(screen.getByText('Inactive')).toBeInTheDocument() - // Updated classes for consistent badge styling + // Updated classes for consistent badge styling. expect(screen.getByText('Inactive')).toHaveClass('bg-red-50', 'text-red-800') }) @@ -809,6 +968,23 @@ describe('CardDetailsPage', () => { expect(screen.getByTestId('metrics-score-circle')).toBeInTheDocument() }) + it('calls scrollToAnchor when MetricsScoreCircle is clicked', () => { + const { scrollToAnchor } = jest.requireMock('utils/scrollToAnchor') + + render( + + ) + + const healthButton = screen.getByRole('button') + fireEvent.click(healthButton) + + expect(scrollToAnchor).toHaveBeenCalledWith('issues-trend') + }) + it('renders social links with correct hrefs and target attributes', () => { const socialLinks = ['https://github.com/test', 'https://twitter.com/test'] render() @@ -1822,6 +1998,131 @@ describe('CardDetailsPage', () => { expect(screen.getByText('Milestone 4')).toBeInTheDocument() expect(screen.queryByText(/Show more/i)).not.toBeInTheDocument() }) + + it('renders milestone author avatar when showAvatar is true and author data is complete', () => { + const milestonesWithAuthor = [ + { + author: { + login: 'author-user', + name: 'Author User', + avatarUrl: 'https://example.com/author-avatar.jpg', + }, + body: 'Milestone with author', + closedIssuesCount: 3, + createdAt: new Date(Date.now() - 10000000).toISOString(), + openIssuesCount: 1, + repositoryName: 'test-repo', + organizationName: 'test-org', + state: 'open', + title: 'Milestone With Author', + url: 'https://github.com/test/project/milestone/1', + }, + ] + + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + recentMilestones: milestonesWithAuthor, + showAvatar: true, + modules: [], + } + + render() + + expect(screen.getByText('Milestone With Author')).toBeInTheDocument() + // The avatar image should be rendered + const avatarImg = screen.getByAltText("Author User's avatar") + expect(avatarImg).toBeInTheDocument() + expect(avatarImg).toHaveAttribute('src', 'https://example.com/author-avatar.jpg') + }) + + it('renders milestone without author avatar when author data is missing', () => { + const milestonesWithoutAuthor = [ + { + author: null, + body: 'Milestone without author', + closedIssuesCount: 3, + createdAt: new Date(Date.now() - 10000000).toISOString(), + openIssuesCount: 1, + repositoryName: 'test-repo', + organizationName: 'test-org', + state: 'open', + title: 'Milestone No Author', + url: 'https://github.com/test/project/milestone/1', + }, + ] + + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + recentMilestones: milestonesWithoutAuthor, + showAvatar: true, + modules: [], + } + + render() + + expect(screen.getByText('Milestone No Author')).toBeInTheDocument() + }) + + it('renders milestone title without link when URL is missing', () => { + const milestonesWithoutUrl = [ + { + author: mockUser, + body: 'Milestone without URL', + closedIssuesCount: 3, + createdAt: new Date(Date.now() - 10000000).toISOString(), + openIssuesCount: 1, + repositoryName: 'test-repo', + organizationName: 'test-org', + state: 'open', + title: 'Milestone No URL', + url: null, + }, + ] + + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + recentMilestones: milestonesWithoutUrl, + modules: [], + } + + render() + + expect(screen.getByText('Milestone No URL')).toBeInTheDocument() + // The title should not be a link + const title = screen.getByText('Milestone No URL') + expect(title.closest('a')).toBeNull() + }) + + it('renders milestone without repository link when repositoryName or organizationName is missing', () => { + const milestonesWithoutRepo = [ + { + author: mockUser, + body: 'Milestone without repo', + closedIssuesCount: 3, + createdAt: new Date(Date.now() - 10000000).toISOString(), + openIssuesCount: 1, + repositoryName: null, + organizationName: null, + state: 'open', + title: 'Milestone No Repo', + url: 'https://github.com/test/project/milestone/1', + }, + ] + + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + recentMilestones: milestonesWithoutRepo, + modules: [], + } + + render() + + expect(screen.getByText('Milestone No Repo')).toBeInTheDocument() + }) }) describe('Module Pull Requests Display', () => { @@ -1918,4 +2219,529 @@ describe('CardDetailsPage', () => { expect(screen.queryByText(/Show more/i)).not.toBeInTheDocument() }) }) + + describe('Module Admin EntityActions and Mentees', () => { + it('renders EntityActions for module type when user is an admin', () => { + const { useSession } = jest.requireMock('next-auth/react') + useSession.mockReturnValue({ + data: { + user: { + login: 'admin-user', + name: 'Admin User', + email: 'admin@example.com', + }, + }, + }) + + const adminUser = { + id: 'admin-id', + login: 'admin-user', + name: 'Admin User', + avatarUrl: 'https://example.com/admin-avatar.jpg', + } + + const moduleProps: DetailsCardProps = { + ...defaultProps, + type: 'module' as const, + accessLevel: 'admin', + admins: [adminUser], + programKey: 'test-program', + entityKey: 'test-module', + modules: [], + } + + render() + + expect(screen.getByTestId('entity-actions')).toBeInTheDocument() + expect(screen.getByTestId('entity-actions')).toHaveTextContent('type=module') + }) + + it('does not render EntityActions for module type when user is not an admin', () => { + const { useSession } = jest.requireMock('next-auth/react') + useSession.mockReturnValue({ + data: { + user: { + login: 'regular-user', + name: 'Regular User', + email: 'user@example.com', + }, + }, + }) + + const adminUser = { + id: 'admin-id', + login: 'admin-user', + name: 'Admin User', + avatarUrl: 'https://example.com/admin-avatar.jpg', + } + + const moduleProps: DetailsCardProps = { + ...defaultProps, + type: 'module' as const, + accessLevel: 'admin', + admins: [adminUser], + programKey: 'test-program', + entityKey: 'test-module', + modules: [], + } + + render() + + expect(screen.queryByTestId('entity-actions')).not.toBeInTheDocument() + }) + + it('renders mentees section when mentees are provided', () => { + const mentees = [ + { + id: 'mentee-1', + login: 'mentee_user1', + name: 'Mentee User 1', + avatarUrl: 'https://example.com/mentee1.jpg', + }, + { + id: 'mentee-2', + login: 'mentee_user2', + name: 'Mentee User 2', + avatarUrl: 'https://example.com/mentee2.jpg', + }, + ] + + const propsWithMentees: DetailsCardProps = { + ...defaultProps, + mentees, + programKey: 'test-program', + entityKey: 'test-entity', + } + + render() + + const allContributorsLists = screen.getAllByTestId('contributors-list') + const menteesSection = allContributorsLists.find((el) => el.textContent?.includes('Mentees')) + expect(menteesSection).toHaveTextContent('Mentees (2 items, max display: 6)') + }) + + it('does not render mentees section when no mentees are provided', () => { + const propsWithoutMentees: DetailsCardProps = { + ...defaultProps, + mentees: [], + } + render() + // Make sure mentees section is not rendered + const allContributorsLists = screen.queryAllByTestId('contributors-list') + const menteesList = allContributorsLists.find((el) => el.textContent?.includes('Mentees')) + expect(menteesList).toBeUndefined() + }) + + it('renders mentees with custom URL formatter', () => { + const mentees = [ + { + id: 'mentee-1', + login: 'test_mentee', + name: 'Test Mentee', + avatarUrl: 'https://example.com/mentee.jpg', + }, + ] + + const propsWithMentees: DetailsCardProps = { + ...defaultProps, + mentees, + programKey: 'program-key-123', + entityKey: 'entity-key-456', + } + + render() + + const allContributorsLists = screen.getAllByTestId('contributors-list') + const menteesSection = allContributorsLists.find((el) => el.textContent?.includes('Mentees')) + expect(menteesSection).toHaveTextContent('Mentees (1 items, max display: 6)') + }) + + it('handles null/undefined mentees array gracefully', () => { + const propsWithNullMentees: DetailsCardProps = { + ...defaultProps, + mentees: null, + } + + expect(() => render()).not.toThrow() + }) + + it('renders program EntityActions when type is program with appropriate access', () => { + const { useSession } = jest.requireMock('next-auth/react') + useSession.mockReturnValue({ + data: { + user: { + login: 'program-admin', + name: 'Program Admin', + email: 'admin@example.com', + }, + }, + }) + + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + accessLevel: 'admin', + canUpdateStatus: true, + status: 'active', + setStatus: jest.fn(), + programKey: 'test-program', + modules: [], + } + + render() + + expect(screen.getByTestId('entity-actions')).toBeInTheDocument() + expect(screen.getByTestId('entity-actions')).toHaveTextContent('type=program') + }) + + it('does not render program EntityActions when canUpdateStatus is false', () => { + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + accessLevel: 'admin', + canUpdateStatus: false, + status: 'active', + setStatus: jest.fn(), + programKey: 'test-program', + modules: [], + } + + render() + + expect(screen.queryByTestId('entity-actions')).not.toBeInTheDocument() + }) + + it('does not render program EntityActions when accessLevel is not admin', () => { + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + accessLevel: 'user', + canUpdateStatus: true, + status: 'active', + setStatus: jest.fn(), + programKey: 'test-program', + modules: [], + } + + render() + + expect(screen.queryByTestId('entity-actions')).not.toBeInTheDocument() + }) + }) + + describe('Program and Module Tags, Domains, and Labels', () => { + it('renders tags for program type', () => { + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + tags: ['tag1', 'tag2', 'tag3'], + modules: [], + } + + render() + + expect(screen.getByText(/Tags/)).toBeInTheDocument() + expect(screen.getByText(/tag1, tag2, tag3/)).toBeInTheDocument() + }) + + it('renders domains for program type', () => { + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + domains: ['domain1', 'domain2'], + modules: [], + } + + render() + + expect(screen.getByText(/Domains/)).toBeInTheDocument() + expect(screen.getByText(/domain1, domain2/)).toBeInTheDocument() + }) + + it('renders labels for program type', () => { + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + labels: ['label1', 'label2'], + modules: [], + } + + render() + + expect(screen.getByText(/Labels/)).toBeInTheDocument() + expect(screen.getByText(/label1, label2/)).toBeInTheDocument() + }) + + it('renders tags and domains in same row for module type', () => { + const moduleProps: DetailsCardProps = { + ...defaultProps, + type: 'module' as const, + tags: ['moduleTag1'], + domains: ['moduleDomain1'], + modules: [], + } + + render() + + expect(screen.getByText(/Tags/)).toBeInTheDocument() + expect(screen.getByText(/Domains/)).toBeInTheDocument() + }) + + it('does not render tags section when tags array is empty', () => { + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + tags: [], + domains: ['domain1'], + modules: [], + } + + render() + + expect(screen.queryByText(/Tags:/)).not.toBeInTheDocument() + expect(screen.getByText(/Domains/)).toBeInTheDocument() + }) + + it('does not render domains section when domains array is empty', () => { + const programProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + tags: ['tag1'], + domains: [], + modules: [], + } + + render() + + expect(screen.getByText(/Tags/)).toBeInTheDocument() + expect(screen.queryByText(/Domains:/)).not.toBeInTheDocument() + }) + }) + + describe('Program Module Rendering', () => { + const mockModules = [ + { + id: 'module-1-id', + key: 'module-1', + name: 'Module 1', + description: 'First module', + endedAt: new Date(Date.now() + 86400000).toISOString(), + startedAt: new Date(Date.now() - 86400000).toISOString(), + experienceLevel: 'BEGINNER', + mentors: [], + }, + { + id: 'module-2-id', + key: 'module-2', + name: 'Module 2', + description: 'Second module', + endedAt: new Date(Date.now() + 86400000).toISOString(), + startedAt: new Date(Date.now() - 86400000).toISOString(), + experienceLevel: 'INTERMEDIATE', + mentors: [], + }, + ] as DetailsCardProps['modules'] + + it('renders single module without SecondaryCard wrapper', () => { + const singleModuleProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + modules: [mockModules![0]], + } + + render() + + expect(screen.getByTestId('module-card')).toBeInTheDocument() + // Single module should not have "Modules" title + expect(screen.queryByText('Modules')).not.toBeInTheDocument() + }) + + it('renders multiple modules with SecondaryCard wrapper and title', () => { + const multiModuleProps: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + modules: mockModules, + } + + render() + + expect(screen.getByTestId('module-card')).toBeInTheDocument() + expect(screen.getByText('Modules')).toBeInTheDocument() + }) + }) + + describe('Mentors and Admins Lists', () => { + const mockMentors = [ + { + id: 'mentor-1', + login: 'mentor_user1', + name: 'Mentor User 1', + avatarUrl: 'https://example.com/mentor1.jpg', + }, + { + id: 'mentor-2', + login: 'mentor_user2', + name: 'Mentor User 2', + avatarUrl: 'https://example.com/mentor2.jpg', + }, + ] + + const mockAdmins = [ + { + id: 'admin-1', + login: 'admin_user1', + name: 'Admin User 1', + avatarUrl: 'https://example.com/admin1.jpg', + }, + ] + + it('renders mentors section when mentors are provided', () => { + const propsWithMentors: DetailsCardProps = { + ...defaultProps, + mentors: mockMentors, + } + + render() + + const allContributorsLists = screen.getAllByTestId('contributors-list') + const mentorsSection = allContributorsLists.find((el) => el.textContent?.includes('Mentors')) + expect(mentorsSection).toHaveTextContent('Mentors (2 items, max display: 6)') + }) + + it('does not render mentors section when mentors array is empty', () => { + const propsWithoutMentors: DetailsCardProps = { + ...defaultProps, + mentors: [], + } + + render() + + // Mentors section should not be rendered + const allContributorsLists = screen.queryAllByTestId('contributors-list') + const mentorsSection = allContributorsLists.find((el) => el.textContent?.includes('Mentors')) + expect(mentorsSection).toBeUndefined() + }) + + it('renders admins section when type is program and admins are provided', () => { + const propsWithAdmins: DetailsCardProps = { + ...defaultProps, + type: 'program' as const, + admins: mockAdmins, + modules: [], + } + + render() + + const allContributorsLists = screen.getAllByTestId('contributors-list') + const adminsSection = allContributorsLists.find((el) => el.textContent?.includes('Admins')) + expect(adminsSection).toHaveTextContent('Admins (1 items, max display: 6)') + }) + + it('does not render admins section for non-program types', () => { + const propsWithAdmins: DetailsCardProps = { + ...defaultProps, + type: 'project' as const, + admins: mockAdmins, + } + + render() + + const allContributorsLists = screen.queryAllByTestId('contributors-list') + const adminsSection = allContributorsLists.find((el) => el.textContent?.includes('Admins')) + expect(adminsSection).toBeUndefined() + }) + }) + + describe('Repository Rendering for Different Types', () => { + it('renders repositories for user type', () => { + const userProps: DetailsCardProps = { + ...defaultProps, + type: 'user' as const, + repositories: mockRepositories, + } + + render() + + expect(screen.getByText('Repositories')).toBeInTheDocument() + expect(screen.getByTestId('repositories-card')).toBeInTheDocument() + }) + + it('renders repositories for organization type', () => { + const orgProps: DetailsCardProps = { + ...defaultProps, + type: 'organization' as const, + repositories: mockRepositories, + } + + render() + + expect(screen.getByText('Repositories')).toBeInTheDocument() + expect(screen.getByTestId('repositories-card')).toBeInTheDocument() + }) + + it('does not render repositories for chapter type', () => { + const chapterProps: DetailsCardProps = { + ...defaultProps, + type: 'chapter' as const, + repositories: mockRepositories, + } + + render() + + expect(screen.queryByText('Repositories')).not.toBeInTheDocument() + }) + }) + + describe('Sponsor Card Rendering', () => { + it('renders sponsor card for chapter type', () => { + const chapterProps: DetailsCardProps = { + ...defaultProps, + type: 'chapter' as const, + entityKey: 'test-chapter', + } + + render() + + expect(screen.getByTestId('sponsor-card')).toBeInTheDocument() + expect(screen.getByTestId('sponsor-card')).toHaveTextContent('Type: chapter') + }) + + it('renders sponsor card for repository type', () => { + const repoProps: DetailsCardProps = { + ...defaultProps, + type: 'repository' as const, + entityKey: 'test-repo', + } + + render() + + expect(screen.getByTestId('sponsor-card')).toBeInTheDocument() + expect(screen.getByTestId('sponsor-card')).toHaveTextContent('Type: project') + }) + + it('uses projectName as title when provided', () => { + const projectProps: DetailsCardProps = { + ...defaultProps, + type: 'project' as const, + entityKey: 'test-project', + projectName: 'Custom Project Name', + } + + render() + + expect(screen.getByTestId('sponsor-card')).toHaveTextContent('Title: Custom Project Name') + }) + + it('does not render sponsor card when entityKey is missing', () => { + const propsWithoutKey: DetailsCardProps = { + ...defaultProps, + type: 'project' as const, + entityKey: undefined, + } + + render() + + expect(screen.queryByTestId('sponsor-card')).not.toBeInTheDocument() + }) + }) }) diff --git a/frontend/__tests__/unit/components/ChapterMapWrapper.test.tsx b/frontend/__tests__/unit/components/ChapterMapWrapper.test.tsx new file mode 100644 index 0000000000..35c1daa972 --- /dev/null +++ b/frontend/__tests__/unit/components/ChapterMapWrapper.test.tsx @@ -0,0 +1,319 @@ +import { render, waitFor, fireEvent } from '@testing-library/react' +import React, { JSX } from 'react' +import { Chapter } from 'types/chapter' +import * as geolocationUtils from 'utils/geolocationUtils' + +// Mock next/dynamic +jest.mock('next/dynamic', () => { + return function mockDynamic( + importFn: () => Promise<{ default: React.ComponentType }>, + options?: { ssr?: boolean } + ) { + // Ignore options for SSR: false — reference it without using `void` + if (options) { + /* intentionally unused */ + } + // Return a component that resolves the import synchronously for testing + const Component = React.lazy(importFn) + return function DynamicComponent(props: Record): JSX.Element { + return ( + Loading...
}> + + + ) + } + } +}) + +// Mock ChapterMap component +const mockOnShareLocation = jest.fn() +jest.mock('components/ChapterMap', () => { + return function MockChapterMap(props: { + geoLocData: Chapter[] + showLocal: boolean + style: React.CSSProperties + userLocation?: { latitude: number; longitude: number } | null + onShareLocation?: () => void + }): JSX.Element { + // Capture the onShareLocation prop for testing + if (props.onShareLocation) { + mockOnShareLocation.mockImplementation(props.onShareLocation) + } + return ( +
+ {props.geoLocData.length} + {String(props.showLocal)} + {props.userLocation && ( + + {props.userLocation.latitude},{props.userLocation.longitude} + + )} + {props.onShareLocation && ( + + )} +
+ ) + } +}) + +// Mock geolocation utilities +jest.mock('utils/geolocationUtils', () => ({ + getUserLocationFromBrowser: jest.fn(), + sortChaptersByDistance: jest.fn(), +})) + +describe('ChapterMapWrapper', () => { + const mockChapterData: Chapter[] = [ + { + _geoloc: { lat: 40.7128, lng: -74.006 }, + key: 'new-york', + name: 'New York Chapter', + } as Chapter, + { + geoLocation: { lat: 51.5074, lng: -0.1278 }, + key: 'london', + name: 'London Chapter', + } as Chapter, + ] + + const defaultProps = { + geoLocData: mockChapterData, + showLocal: false, + style: { width: '100%', height: '400px' }, + } + + beforeEach(() => { + jest.clearAllMocks() + jest.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + // We need to import the component after mocks are set up + const getChapterMapWrapper = async () => { + const chapterModule = await import('components/ChapterMapWrapper') + return chapterModule.default + } + + describe('when showLocationSharing is false or undefined', () => { + it('renders ChapterMap directly without wrapper when showLocationSharing is false', async () => { + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId } = render( + + ) + + await waitFor(() => { + expect(getByTestId('chapter-map')).toBeInTheDocument() + }) + // Should not have share location button (no onShareLocation passed) + expect(getByTestId('geo-loc-data-length')).toHaveTextContent('2') + }) + + it('renders ChapterMap directly without wrapper when showLocationSharing is undefined', async () => { + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId } = render() + + await waitFor(() => { + expect(getByTestId('chapter-map')).toBeInTheDocument() + }) + expect(getByTestId('geo-loc-data-length')).toHaveTextContent('2') + }) + }) + + describe('when showLocationSharing is true', () => { + it('renders ChapterMap with wrapping div and onShareLocation handler', async () => { + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId, container } = render( + + ) + + await waitFor(() => { + expect(getByTestId('chapter-map')).toBeInTheDocument() + }) + + // Check for the wrapper div with h-full w-full classes + const wrapper = container.querySelector('.h-full.w-full') + expect(wrapper).toBeInTheDocument() + + // Should have share location button + expect(getByTestId('share-location-btn')).toBeInTheDocument() + }) + + it('uses original geoLocData when sortedData is null', async () => { + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId } = render( + + ) + + await waitFor(() => { + expect(getByTestId('geo-loc-data-length')).toHaveTextContent('2') + }) + }) + }) + + describe('handleShareLocation function', () => { + it('clears user location when location is already set (toggle off)', async () => { + const mockLocation = { latitude: 40.7128, longitude: -74.006 } + const mockSortedChapters = [ + { ...mockChapterData[0], _distance: 0 }, + { ...mockChapterData[1], _distance: 100 }, + ] + + ;(geolocationUtils.getUserLocationFromBrowser as jest.Mock).mockResolvedValue(mockLocation) + ;(geolocationUtils.sortChaptersByDistance as jest.Mock).mockReturnValue(mockSortedChapters) + + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId, queryByTestId } = render( + + ) + + await waitFor(() => { + expect(getByTestId('share-location-btn')).toBeInTheDocument() + }) + + // First click - enable location sharing + fireEvent.click(getByTestId('share-location-btn')) + + await waitFor(() => { + expect(getByTestId('user-location')).toHaveTextContent('40.7128,-74.006') + }) + + // Second click - disable location sharing (toggle off) + fireEvent.click(getByTestId('share-location-btn')) + + await waitFor(() => { + expect(queryByTestId('user-location')).not.toBeInTheDocument() + }) + }) + + it('fetches and sets user location on successful geolocation', async () => { + const mockLocation = { latitude: 51.5074, longitude: -0.1278 } + const mockSortedChapters = [ + { ...mockChapterData[1], _distance: 0 }, + { ...mockChapterData[0], _distance: 5000 }, + ] + + ;(geolocationUtils.getUserLocationFromBrowser as jest.Mock).mockResolvedValue(mockLocation) + ;(geolocationUtils.sortChaptersByDistance as jest.Mock).mockReturnValue(mockSortedChapters) + + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId } = render( + + ) + + await waitFor(() => { + expect(getByTestId('share-location-btn')).toBeInTheDocument() + }) + + fireEvent.click(getByTestId('share-location-btn')) + + await waitFor(() => { + expect(geolocationUtils.getUserLocationFromBrowser).toHaveBeenCalled() + expect(geolocationUtils.sortChaptersByDistance).toHaveBeenCalledWith( + mockChapterData, + mockLocation + ) + expect(getByTestId('user-location')).toHaveTextContent('51.5074,-0.1278') + }) + }) + + it('does nothing when geolocation returns null', async () => { + ;(geolocationUtils.getUserLocationFromBrowser as jest.Mock).mockResolvedValue(null) + + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId, queryByTestId } = render( + + ) + + await waitFor(() => { + expect(getByTestId('share-location-btn')).toBeInTheDocument() + }) + + fireEvent.click(getByTestId('share-location-btn')) + + await waitFor(() => { + expect(geolocationUtils.getUserLocationFromBrowser).toHaveBeenCalled() + expect(geolocationUtils.sortChaptersByDistance).not.toHaveBeenCalled() + expect(queryByTestId('user-location')).not.toBeInTheDocument() + }) + }) + + it('logs error when geolocation throws an error', async () => { + const mockError = new Error('Geolocation permission denied') + ;(geolocationUtils.getUserLocationFromBrowser as jest.Mock).mockRejectedValue(mockError) + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId, queryByTestId } = render( + + ) + + await waitFor(() => { + expect(getByTestId('share-location-btn')).toBeInTheDocument() + }) + + fireEvent.click(getByTestId('share-location-btn')) + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith('Error detecting location:', mockError) + expect(queryByTestId('user-location')).not.toBeInTheDocument() + }) + }) + + it('correctly maps sorted data removing _distance property', async () => { + const mockLocation = { latitude: 40.7128, longitude: -74.006 } + const mockSortedChapters = [ + { ...mockChapterData[0], _distance: 0 }, + { ...mockChapterData[1], _distance: 5000 }, + ] + + ;(geolocationUtils.getUserLocationFromBrowser as jest.Mock).mockResolvedValue(mockLocation) + ;(geolocationUtils.sortChaptersByDistance as jest.Mock).mockReturnValue(mockSortedChapters) + + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId } = render( + + ) + + await waitFor(() => { + expect(getByTestId('share-location-btn')).toBeInTheDocument() + }) + + fireEvent.click(getByTestId('share-location-btn')) + + await waitFor(() => { + // The component should still have 2 chapters after sorting + expect(getByTestId('geo-loc-data-length')).toHaveTextContent('2') + }) + }) + }) + + describe('props forwarding', () => { + it('forwards showLocal prop to ChapterMap', async () => { + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId } = render( + + ) + + await waitFor(() => { + expect(getByTestId('show-local')).toHaveTextContent('true') + }) + }) + + it('forwards style prop to ChapterMap', async () => { + const customStyle = { width: '500px', height: '300px' } + const ChapterMapWrapper = await getChapterMapWrapper() + const { getByTestId } = render( + + ) + + await waitFor(() => { + expect(getByTestId('chapter-map')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx index dbddc2d84d..7a55210905 100644 --- a/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx +++ b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx @@ -170,6 +170,84 @@ describe('ContributionHeatmap', () => { ) expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() }) + + it('handles missing startDate by using default dates', () => { + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + // Should render with default date range (1 year) + expect(screen.getByTestId('mock-heatmap-chart')).toHaveAttribute('data-series-length', '7') + }) + + it('handles missing endDate by using default dates', () => { + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + expect(screen.getByTestId('mock-heatmap-chart')).toHaveAttribute('data-series-length', '7') + }) + + it('handles both missing startDate and endDate', () => { + renderWithTheme() + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles invalid startDate by using default dates', () => { + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles invalid endDate by using default dates', () => { + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles both invalid startDate and endDate', () => { + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles swapped dates (startDate > endDate) by swapping them', () => { + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + expect(screen.getByTestId('mock-heatmap-chart')).toHaveAttribute('data-series-length', '7') + }) + + it('handles startDate after endDate and swaps them correctly', () => { + const data = { + '2024-01-15': 5, + '2024-01-20': 10, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) }) describe('Theme & Styling', () => { diff --git a/frontend/__tests__/unit/components/ContributorAvatar.test.tsx b/frontend/__tests__/unit/components/ContributorAvatar.test.tsx index e59736edd9..dc169408f7 100644 --- a/frontend/__tests__/unit/components/ContributorAvatar.test.tsx +++ b/frontend/__tests__/unit/components/ContributorAvatar.test.tsx @@ -241,4 +241,120 @@ describe('ContributorAvatar', () => { it('is properly memoized with displayName', () => { expect(ContributorAvatar.displayName).toBe('ContributorAvatar') }) + + describe('branch coverage for isAlgoliaContributor', () => { + it('handles contributor without avatarUrl property (non-Algolia path)', () => { + // Test with a contributor that doesn't have avatarUrl to hit the false branch + const nonAlgoliaContributor = { + id: 'contributor-non-algolia', + login: 'arkid15r', + name: 'Kateryna User', + projectKey: 'test-key', + projectName: 'Test-Project', + contributionsCount: 10, + } as unknown as Contributor + + // We need to mock the component behavior by providing avatarUrl separately + // Since the component always expects avatarUrl, we test the edge case + render() + expect(screen.getByTestId('contributor-avatar')).toBeInTheDocument() + }) + + it('shows repository info in tooltip when projectName is present and has contributions', () => { + const contributorWithProject: Contributor = { + id: 'contributor-with-project', + login: 'projectuser', + name: 'Project User', + avatarUrl: 'https://github.com/projectuser.png', + contributionsCount: 5, + projectKey: 'test-key', + projectName: 'My-Project', + } + render( + + ) + const tooltip = screen.getByTestId('avatar-tooltip-projectuser-project-info-test') + // All contributors are treated as Algolia, so projectName is not included + expect(tooltip).toHaveAttribute('title', '5 contributions by Project User') + }) + + it('shows repository info in tooltip when projectName is present without contributions', () => { + const contributorWithProjectNoContrib: Contributor = { + id: 'contributor-project-no-contrib', + login: 'projectuser2', + name: 'Project User 2', + avatarUrl: 'https://github.com/projectuser2.png', + projectKey: 'test-key', + projectName: 'Another-Project', + } + render( + + ) + const tooltip = screen.getByTestId('avatar-tooltip-projectuser2-project-no-contrib-test') + expect(tooltip).toHaveAttribute('title', 'Project User 2') + }) + + it('handles contributor with null name falling back to login', () => { + const contributorWithNullName: Contributor = { + id: 'contributor-null-name', + login: 'loginonly', + name: null as unknown as string, + avatarUrl: 'https://github.com/loginonly.png', + contributionsCount: 3, + projectKey: 'test-key', + } + render() + const tooltip = screen.getByTestId('avatar-tooltip-loginonly-null-name-test') + expect(tooltip).toHaveAttribute('title', '3 contributions by loginonly') + }) + + it('uses login as displayName when name is undefined', () => { + const contributorNoName = { + id: 'contributor-no-name', + login: 'usernameonly', + avatarUrl: 'https://github.com/usernameonly.png', + projectKey: 'test-key', + } as Contributor + render() + const tooltip = screen.getByTestId('avatar-tooltip-usernameonly-no-name-test') + expect(tooltip).toHaveAttribute('title', 'usernameonly') + }) + + it('renders avatar without &s=60 suffix when treated as non-Algolia', () => { + // This tests the false branch of isAlgolia check in src line 51 + // However, since all Contributor objects have avatarUrl, isAlgolia is always true + // We verify the positive case explicitly + const algoliaContributor: Contributor = { + id: 'contributor-algolia-check', + login: 'ahmedxgouda', + name: 'Ahmed User', + avatarUrl: 'https://algolia.com/avatar.png', + contributionsCount: 20, + projectKey: 'test-key', + } + render() + const avatar = screen.getByTestId('contributor-avatar') + expect(avatar).toHaveAttribute('src', 'https://algolia.com/avatar.png&s=60') + }) + + it('handles contributor object with extra properties', () => { + const contributorWithExtras = { + id: 'contributor-extras', + login: 'extrauser', + name: 'Extra User', + avatarUrl: 'https://github.com/extrauser.png', + contributionsCount: 7, + projectKey: 'test-key', + extraField: 'should be ignored', + anotherField: 123, + } as unknown as Contributor + render() + expect(screen.getByTestId('contributor-avatar')).toBeInTheDocument() + const tooltip = screen.getByTestId('avatar-tooltip-extrauser-extras-test') + expect(tooltip).toHaveAttribute('title', '7 contributions by Extra User') + }) + }) }) diff --git a/frontend/__tests__/unit/components/ContributorsList.test.tsx b/frontend/__tests__/unit/components/ContributorsList.test.tsx index 5e8a3185dc..b6f7ab4b91 100644 --- a/frontend/__tests__/unit/components/ContributorsList.test.tsx +++ b/frontend/__tests__/unit/components/ContributorsList.test.tsx @@ -302,7 +302,7 @@ describe('ContributorsList Component', () => { describe('Prop-based behavior', () => { it('uses custom label when provided', () => { const customLabel = 'Featured Contributors' - render() + render() expect(screen.getByText(customLabel)).toBeInTheDocument() }) @@ -551,7 +551,7 @@ describe('ContributorsList Component', () => { }) it('renders title with proper structure', () => { - render() + render() expect(screen.getByTestId('anchor-title')).toBeInTheDocument() expect(screen.getByText('Custom Title')).toBeInTheDocument() diff --git a/frontend/__tests__/unit/components/DashboardWrapper.test.tsx b/frontend/__tests__/unit/components/DashboardWrapper.test.tsx new file mode 100644 index 0000000000..28676a5e5a --- /dev/null +++ b/frontend/__tests__/unit/components/DashboardWrapper.test.tsx @@ -0,0 +1,240 @@ +import { render, screen } from '@testing-library/react' +import { useDjangoSession } from 'hooks/useDjangoSession' +import { notFound } from 'next/navigation' +import DashboardWrapper from 'components/DashboardWrapper' + +jest.mock('hooks/useDjangoSession') + +jest.mock('next/navigation', () => ({ + notFound: jest.fn(() => { + throw new Error('notFound') + }), +})) + +jest.mock('components/LoadingSpinner', () => { + return function MockLoadingSpinner() { + return
Loading...
+ } +}) + +describe('', () => { + const mockUseDjangoSession = useDjangoSession as jest.MockedFunction + const mockNotFound = notFound as jest.MockedFunction + + const mockSession = { + user: { + id: '1', + name: 'Test User', + email: 'test@example.com', + image: 'https://example.com/image.jpg', + isOwaspStaff: true, + }, + accessToken: 'test-token', + expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders LoadingSpinner when isSyncing is true', () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: true, + session: mockSession, + status: 'authenticated', + }) + + render( + +
Dashboard Content
+
+ ) + + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument() + expect(screen.queryByText('Dashboard Content')).not.toBeInTheDocument() + }) + + it('renders children when isSyncing is false and user is OWASP staff', () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: mockSession, + status: 'authenticated', + }) + + render( + +
Dashboard Content
+
+ ) + + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument() + expect(screen.getByText('Dashboard Content')).toBeInTheDocument() + expect(mockNotFound).not.toHaveBeenCalled() + }) + + it('calls notFound when user is not OWASP staff', () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: { + ...mockSession, + user: { + ...mockSession.user, + isOwaspStaff: false, + }, + }, + status: 'authenticated', + }) + + expect(() => { + render( + +
Dashboard Content
+
+ ) + }).toThrow('notFound') + expect(mockNotFound).toHaveBeenCalled() + }) + + it('calls notFound when session is undefined', () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: undefined, + status: 'unauthenticated', + }) + + expect(() => { + render( + +
Dashboard Content
+
+ ) + }).toThrow('notFound') + expect(mockNotFound).toHaveBeenCalled() + }) + + it('prioritizes loading state over authorization check', () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: true, + session: { + ...mockSession, + user: { + ...mockSession.user, + isOwaspStaff: false, + }, + }, + status: 'authenticated', + }) + + render( + +
Dashboard Content
+
+ ) + + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument() + expect(mockNotFound).not.toHaveBeenCalled() + }) + + it('handles status transitions correctly', () => { + // Initially syncing + mockUseDjangoSession.mockReturnValue({ + isSyncing: true, + session: mockSession, + status: 'loading', + }) + + const { rerender } = render( + +
Dashboard Content
+
+ ) + + rerender( + +
Dashboard Content
+
+ ) + + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument() + + // After sync completes + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: mockSession, + status: 'authenticated', + }) + + rerender( + +
Dashboard Content
+
+ ) + + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument() + expect(screen.getByText('Dashboard Content')).toBeInTheDocument() + }) + + it('handles authorization changes when session is updated', () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: mockSession, + status: 'authenticated', + }) + + const { rerender } = render( + +
Dashboard Content
+
+ ) + + expect(screen.getByText('Dashboard Content')).toBeInTheDocument() + mockNotFound.mockClear() + + // Update to non-staff user + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: { + ...mockSession, + user: { + ...mockSession.user, + isOwaspStaff: false, + }, + }, + status: 'authenticated', + }) + + expect(() => { + rerender( + +
Dashboard Content
+
+ ) + }).toThrow('notFound') + + expect(mockNotFound).toHaveBeenCalled() + }) + + it('renders children without extra wrapper', () => { + mockUseDjangoSession.mockReturnValue({ + isSyncing: false, + session: mockSession, + status: 'authenticated', + }) + + const { container } = render( + +
Test 1
+
Test 2
+
+ ) + + expect(screen.getByText('Test 1')).toBeInTheDocument() + expect(screen.getByText('Test 2')).toBeInTheDocument() + + const divElements = container.querySelectorAll('div') + const testDivs = Array.from(divElements).filter( + (div) => div.textContent === 'Test 1' || div.textContent === 'Test 2' + ) + expect(testDivs.length).toBe(2) + }) +}) diff --git a/frontend/__tests__/unit/components/FontLoaderWrapper.test.tsx b/frontend/__tests__/unit/components/FontLoaderWrapper.test.tsx new file mode 100644 index 0000000000..b07980c86d --- /dev/null +++ b/frontend/__tests__/unit/components/FontLoaderWrapper.test.tsx @@ -0,0 +1,206 @@ +import { render, screen, waitFor } from '@testing-library/react' +import FontLoaderWrapper from 'components/FontLoaderWrapper' + +jest.mock('components/LoadingSpinner', () => { + return function MockLoadingSpinner() { + return
Loading fonts...
+ } +}) + +describe('', () => { + let fontsReadyResolve: (() => void) | null = null + + beforeEach(() => { + const fontsReadyPromise = new Promise((resolve) => { + fontsReadyResolve = resolve + }) + + Object.defineProperty(document, 'fonts', { + value: { + ready: fontsReadyPromise, + }, + configurable: true, + }) + }) + + afterEach(() => { + jest.clearAllMocks() + fontsReadyResolve = null + }) + + it('renders LoadingSpinner initially while fonts are loading', () => { + render( + +
Content
+
+ ) + + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument() + expect(screen.queryByText('Content')).not.toBeInTheDocument() + }) + + it('renders children after fonts are loaded', async () => { + render( + +
Dashboard Content
+
+ ) + + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument() + + if (fontsReadyResolve) fontsReadyResolve() + + await waitFor(() => { + expect(screen.getByText('Dashboard Content')).toBeInTheDocument() + }) + }) + + it('hides LoadingSpinner when fonts are loaded', async () => { + render( + +
Content
+
+ ) + + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument() + + if (fontsReadyResolve) fontsReadyResolve() + + await waitFor(() => { + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument() + }) + }) + + it('renders multiple children correctly', async () => { + render( + +
Child 1
+
Child 2
+
Child 3
+
+ ) + + if (fontsReadyResolve) fontsReadyResolve() + + await waitFor(() => { + expect(screen.getByText('Child 1')).toBeInTheDocument() + expect(screen.getByText('Child 2')).toBeInTheDocument() + expect(screen.getByText('Child 3')).toBeInTheDocument() + }) + }) + + it('updates children when data prop changes', async () => { + const { rerender } = render( + +
Initial Content
+
+ ) + + if (fontsReadyResolve) fontsReadyResolve() + + await waitFor(() => { + expect(screen.getByText('Initial Content')).toBeInTheDocument() + }) + + rerender( + +
Updated Content
+
+ ) + + expect(screen.getByText('Updated Content')).toBeInTheDocument() + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument() + }) + + it('maintains fontsLoaded state after rerender', async () => { + const { rerender } = render( + +
Content 1
+
+ ) + + if (fontsReadyResolve) fontsReadyResolve() + + await waitFor(() => { + expect(screen.getByText('Content 1')).toBeInTheDocument() + }) + + rerender( + +
Content 2
+
+ ) + + expect(screen.getByText('Content 2')).toBeInTheDocument() + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument() + }) + + it('renders children after fonts promise resolves', async () => { + render( + +
Content
+
+ ) + + // Component calls .then() on document.fonts.ready and renders content once resolved + if (fontsReadyResolve) fontsReadyResolve() + + await waitFor(() => { + expect(screen.getByText('Content')).toBeInTheDocument() + }) + }) + + it('handles empty children', async () => { + render( + + <> + + ) + + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument() + + if (fontsReadyResolve) fontsReadyResolve() + + await waitFor(() => { + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument() + }) + }) + + it('renders children as fragment without extra wrapper', async () => { + render( + +

Title

+

Paragraph

+
+ ) + + if (fontsReadyResolve) fontsReadyResolve() + + await waitFor(() => { + expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.getByText('Paragraph')).toBeInTheDocument() + + const h1 = screen.getByText('Title') as HTMLElement + const p = screen.getByText('Paragraph') as HTMLElement + + expect(h1.tagName).toBe('H1') + expect(p.tagName).toBe('P') + }) + }) + + it('does not obscure interactive elements after fonts load', async () => { + render( + + + + ) + + if (fontsReadyResolve) fontsReadyResolve() + + await waitFor(() => { + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument() + }) + }) +}) diff --git a/frontend/__tests__/unit/components/InfoItem.test.tsx b/frontend/__tests__/unit/components/InfoItem.test.tsx index b3d7dad063..b743372ec5 100644 --- a/frontend/__tests__/unit/components/InfoItem.test.tsx +++ b/frontend/__tests__/unit/components/InfoItem.test.tsx @@ -3,7 +3,7 @@ import millify from 'millify' import React from 'react' import { FaUser } from 'react-icons/fa' import { pluralize } from 'utils/pluralize' -import InfoItem from 'components/InfoItem' +import InfoItem, { TextInfoItem } from 'components/InfoItem' jest.mock('millify', () => jest.fn()) jest.mock('utils/pluralize', () => ({ @@ -118,3 +118,48 @@ describe('InfoItem', () => { expect(screen.getByText('0')).toBeInTheDocument() }) }) + +describe('TextInfoItem', () => { + it('renders successfully with required props', () => { + render() + + expect(screen.getByText('Author:')).toBeInTheDocument() + expect(screen.getByText('John Doe')).toBeInTheDocument() + expect(document.querySelector('svg')).toBeInTheDocument() + }) + + it('applies correct DOM structure and classes', () => { + render() + + const container = screen.getByText('Role:').closest('div') + expect(container).toHaveClass( + 'flex', + 'items-center', + 'gap-2', + 'text-sm', + 'text-gray-600', + 'dark:text-gray-300' + ) + + const labelSpan = screen.getByText('Role:') + expect(labelSpan).toHaveClass('font-medium') + + const icon = document.querySelector('svg') + expect(icon).toBeInTheDocument() + expect(icon).toHaveClass('text-xs') + }) + + it('handles empty value string', () => { + render() + + expect(screen.getByText('Status:')).toBeInTheDocument() + }) + + it('handles long value strings', () => { + const longValue = 'This is a very long value string that might be displayed' + render() + + expect(screen.getByText('Description:')).toBeInTheDocument() + expect(screen.getByText(longValue)).toBeInTheDocument() + }) +}) diff --git a/frontend/__tests__/unit/components/IssuesTable.test.tsx b/frontend/__tests__/unit/components/IssuesTable.test.tsx index f9c805d183..f4c754a3c0 100644 --- a/frontend/__tests__/unit/components/IssuesTable.test.tsx +++ b/frontend/__tests__/unit/components/IssuesTable.test.tsx @@ -178,8 +178,9 @@ describe('', () => { it('renders Merged status badge when isMerged is true', () => { render() - const mergedBadges = screen.getAllByText('Merged') - expect(mergedBadges.length).toBeGreaterThan(0) + // Merged issues display with "Closed" text (purple badge) + const closedBadges = screen.getAllByText('Closed') + expect(closedBadges.length).toBeGreaterThan(0) }) it('defaults to Closed status for unknown states', () => { diff --git a/frontend/__tests__/unit/components/ItemCardList.test.tsx b/frontend/__tests__/unit/components/ItemCardList.test.tsx index d5c9ba522c..46f60eaf72 100644 --- a/frontend/__tests__/unit/components/ItemCardList.test.tsx +++ b/frontend/__tests__/unit/components/ItemCardList.test.tsx @@ -537,10 +537,81 @@ describe('ItemCardList Component', () => { /> ) + // When URL is missing, the title should render as plain text, not a link. + const titleText = screen.getByText('Test Issue Title') + expect(titleText).toBeInTheDocument() + + // Verify it's not wrapped in a link. + const titleLinks = screen.queryAllByTestId('link') + const titleLink = titleLinks.find((link) => link.textContent?.includes('Test Issue Title')) + expect(titleLink).toBeUndefined() + }) + + it('renders title as link when valid url is present', () => { + const itemWithUrl = { ...mockIssue, url: 'https://example.com/issue' } + + render( + + ) + const titleLinks = screen.getAllByTestId('link') const titleLink = titleLinks.find((link) => link.textContent?.includes('Test Issue Title')) + expect(titleLink).toHaveAttribute('href', 'https://example.com/issue') + }) + + it('uses id as key when objectID is not present', () => { + const itemWithId = { ...mockIssue, objectID: undefined, id: 'test-id-123' } + + render( + + ) + + expect(screen.getByText('Test Issue Title')).toBeInTheDocument() + }) + + it('generates fallback key when no identifiers are present', () => { + const itemWithoutIds = { + ...mockIssue, + objectID: undefined, + id: undefined, + repositoryName: '', + title: '', + name: '', + url: '', + } + + render( + + ) + + // Component should render without crashing + expect(screen.getByText('Fallback Key')).toBeInTheDocument() + }) + + it('uses objectID as primary key identifier', () => { + const itemWithObjectId = { ...mockIssue, objectID: 'object-123' } + + render( + + ) - expect(titleLink).toHaveAttribute('href', '') + expect(screen.getByText('Test Issue Title')).toBeInTheDocument() }) }) diff --git a/frontend/__tests__/unit/components/Leaders.test.tsx b/frontend/__tests__/unit/components/Leaders.test.tsx index e43a8d5e1d..00dcb4b92f 100644 --- a/frontend/__tests__/unit/components/Leaders.test.tsx +++ b/frontend/__tests__/unit/components/Leaders.test.tsx @@ -54,4 +54,20 @@ describe('Leaders Component', () => { expect(push).toHaveBeenCalledWith('/members/alice') }) + + it('navigates to search page when user.member is undefined', () => { + const userWithoutMember = [ + { + memberName: 'John Doe', + description: 'Team Lead', + member: undefined, + }, + ] + render() + + const viewProfileButton = screen.getByText('View Profile') + fireEvent.click(viewProfileButton) + + expect(push).toHaveBeenCalledWith('/members?q=John%20Doe') + }) }) diff --git a/frontend/__tests__/unit/components/LogoCarousel.test.tsx b/frontend/__tests__/unit/components/LogoCarousel.test.tsx index 964334de99..496dd0d1ed 100644 --- a/frontend/__tests__/unit/components/LogoCarousel.test.tsx +++ b/frontend/__tests__/unit/components/LogoCarousel.test.tsx @@ -29,16 +29,27 @@ jest.mock('next/image', () => { return function MockImage({ src, alt, - style, - fill, + className, + width, + height, }: { src: string alt: string - style?: React.CSSProperties - fill?: boolean + className?: string + width?: number + height?: number }) { - // eslint-disable-next-line @next/next/no-img-element - return {alt} + return ( + // eslint-disable-next-line @next/next/no-img-element -- mock for unit tests + {alt} + ) } }) @@ -156,20 +167,20 @@ describe('MovingLogos (LogoCarousel)', () => { render() const scroller = document.querySelector('.animate-scroll') - expect(scroller).toHaveStyle('animation-duration: 6s') + expect(scroller).toHaveStyle('animation-duration: 9s') }) it('updates animation duration when sponsors change', () => { const { rerender } = render() let scroller = document.querySelector('.animate-scroll') - expect(scroller).toHaveStyle('animation-duration: 6s') + expect(scroller).toHaveStyle('animation-duration: 9s') const newSponsors = [...mockSponsors, ...mockSponsors] rerender() scroller = document.querySelector('.animate-scroll') - expect(scroller).toHaveStyle('animation-duration: 12s') + expect(scroller).toHaveStyle('animation-duration: 18s') }) }) @@ -260,7 +271,7 @@ describe('MovingLogos (LogoCarousel)', () => { const scroller = document.querySelector('.animate-scroll') expect(scroller).toBeInTheDocument() - expect(scroller).toHaveClass('animate-scroll', 'flex', 'w-full', 'gap-6') + expect(scroller).toHaveClass('animate-scroll', 'flex', 'w-max', 'gap-6') }) }) @@ -284,9 +295,11 @@ describe('MovingLogos (LogoCarousel)', () => { it('provides fallback for empty imageUrl', () => { render() - const imageContainer = document.querySelector('.relative.mb-4') - expect(imageContainer).toBeInTheDocument() - expect(imageContainer?.querySelector('img')).not.toBeInTheDocument() + const images = screen.queryAllByTestId('sponsor-image') + expect(images).toHaveLength(0) + + const fallbackText = screen.getAllByText('No Image Sponsor') + expect(fallbackText.length).toBeGreaterThan(0) }) it('uses generic fallback alt text when sponsor name is missing', () => { @@ -426,7 +439,7 @@ describe('MovingLogos (LogoCarousel)', () => { expect(screen.getAllByTestId('sponsor-image')).toHaveLength(200) const scroller = document.querySelector('.animate-scroll') - expect(scroller).toHaveStyle('animation-duration: 200s') + expect(scroller).toHaveStyle('animation-duration: 300s') }) }) @@ -482,11 +495,8 @@ describe('MovingLogos (LogoCarousel)', () => { const overflowContainer = document.querySelector('.relative.overflow-hidden.py-2') expect(overflowContainer).toBeInTheDocument() - const scroller = document.querySelector('.animate-scroll.flex.w-full.gap-6') + const scroller = document.querySelector('.animate-scroll.flex.w-max.gap-6') expect(scroller).toBeInTheDocument() - - const sponsorContainers = document.querySelectorAll('[class*="min-w-[220px]"]') - expect(sponsorContainers).toHaveLength(6) }) it('applies correct styles to images', () => { @@ -494,8 +504,7 @@ describe('MovingLogos (LogoCarousel)', () => { const images = screen.getAllByTestId('sponsor-image') for (const image of images) { - expect(image).toHaveAttribute('style', 'object-fit: contain;') - expect(image).toHaveAttribute('data-fill', 'true') + expect(image).toHaveClass('h-full', 'w-full', 'object-contain') } }) @@ -508,14 +517,11 @@ describe('MovingLogos (LogoCarousel)', () => { const scroller = overflowContainer?.querySelector('.animate-scroll') expect(scroller).toBeInTheDocument() - const sponsorContainer = scroller?.querySelector('[class*="min-w-[220px]"]') - expect(sponsorContainer).toBeInTheDocument() - - const link = sponsorContainer?.querySelector('a') + const link = scroller?.querySelector('a') expect(link).toBeInTheDocument() - const imageContainer = link?.querySelector('.relative.mb-4') - expect(imageContainer).toBeInTheDocument() + const logoWrapper = link?.querySelector('.bg-white.rounded-lg.shadow-md') + expect(logoWrapper).toBeInTheDocument() }) it('applies correct footer styling', () => { @@ -547,14 +553,14 @@ describe('MovingLogos (LogoCarousel)', () => { expect(donateLink).toHaveClass('text-primary', 'font-medium', 'hover:underline') }) - it('sets correct minimum width for sponsor containers', () => { + it('wraps logos in white containers for dark mode visibility', () => { render() - const sponsorContainers = document.querySelectorAll('[class*="min-w-[220px]"]') - expect(sponsorContainers).toHaveLength(6) + const logoWrappers = document.querySelectorAll('.bg-white.rounded-lg.shadow-md') + expect(logoWrappers).toHaveLength(6) - for (const container of sponsorContainers) { - expect(container).toHaveClass('min-w-[220px]') + for (const wrapper of logoWrappers) { + expect(wrapper).toHaveClass('bg-white', 'rounded-lg', 'shadow-md') } }) }) diff --git a/frontend/__tests__/unit/components/MentorshipPullRequest.test.tsx b/frontend/__tests__/unit/components/MentorshipPullRequest.test.tsx new file mode 100644 index 0000000000..cc8dd149ed --- /dev/null +++ b/frontend/__tests__/unit/components/MentorshipPullRequest.test.tsx @@ -0,0 +1,162 @@ +import { render, screen } from 'wrappers/testUtil' +import type { PullRequest } from 'types/pullRequest' +import MentorshipPullRequest, { getPRStatus } from 'components/MentorshipPullRequest' + +describe('MentorshipPullRequest Component', () => { + const mockPullRequestOpen: PullRequest = { + id: '1', + title: 'Add new feature to dashboard', + state: 'open', + url: 'https://github.com/test/repo/pull/1', + createdAt: '2024-01-15', + mergedAt: null, + author: { + login: 'testuser', + avatarUrl: 'https://avatars.githubusercontent.com/u/12345?v=4', + }, + } + + const mockPullRequestMerged: PullRequest = { + id: '2', + title: 'Fix critical bug in authentication', + state: 'closed', + url: 'https://github.com/test/repo/pull/2', + createdAt: '2024-01-10', + mergedAt: '2024-01-12', + author: { + login: 'Golovanova', + avatarUrl: 'https://avatars.githubusercontent.com/u/54321?v=4', + }, + } + + const mockPullRequestClosed: PullRequest = { + id: '3', + title: 'Rejected feature proposal', + state: 'closed', + url: 'https://github.com/test/repo/pull/3', + createdAt: '2024-01-05', + mergedAt: null, + author: { + login: 'Oleksiuk', + avatarUrl: 'https://avatars.githubusercontent.com/u/99999?v=4', + }, + } + + const mockPullRequestNoAuthor: PullRequest = { + id: '4', + title: 'Unknown author PR', + state: 'open', + url: 'https://github.com/test/repo/pull/4', + createdAt: '2024-01-20', + mergedAt: null, + author: { + login: null, + avatarUrl: null, + }, + } + + describe('getPRStatus function', () => { + test('returns correct status for merged PR', () => { + const status = getPRStatus(mockPullRequestMerged) + expect(status.label).toBe('Merged') + expect(status.backgroundColor).toBe('#8657E5') + }) + + test('returns correct status for closed PR', () => { + const status = getPRStatus(mockPullRequestClosed) + expect(status.label).toBe('Closed') + expect(status.backgroundColor).toBe('#DA3633') + }) + + test('returns correct status for open PR', () => { + const status = getPRStatus(mockPullRequestOpen) + expect(status.label).toBe('Open') + expect(status.backgroundColor).toBe('#238636') + }) + }) + + describe('MentorshipPullRequest component rendering', () => { + test('renders open PR with all details', () => { + render() + expect(screen.getByText('Add new feature to dashboard')).toBeInTheDocument() + expect(screen.getByText(/by testuser/)).toBeInTheDocument() + expect(screen.getByText('Open')).toBeInTheDocument() + }) + + test('renders merged PR with merged status', () => { + render() + expect(screen.getByText('Fix critical bug in authentication')).toBeInTheDocument() + expect(screen.getByText(/by Golovanova/)).toBeInTheDocument() + expect(screen.getByText('Merged')).toBeInTheDocument() + }) + + test('renders closed PR with closed status', () => { + render() + expect(screen.getByText('Rejected feature proposal')).toBeInTheDocument() + expect(screen.getByText(/by Oleksiuk/)).toBeInTheDocument() + expect(screen.getByText('Closed')).toBeInTheDocument() + }) + + test('renders PR with author avatar', () => { + render() + const avatar = screen.getByAltText('testuser') + expect(avatar).toBeInTheDocument() + expect(avatar).toHaveAttribute('src') + }) + + test('renders placeholder when author has no avatar URL', () => { + const { container } = render() + // When no avatar URL, a div placeholder should be rendered instead of Image + const images = container.querySelectorAll('img') + // Only the TruncatedText link should have an image, not the avatar + expect(images.length).toBeLessThan(2) + }) + + test('renders Unknown when author login is null', () => { + render() + expect(screen.getByText(/by Unknown/)).toBeInTheDocument() + }) + + test('renders PR title as a link with correct href', () => { + render() + const link = screen.getByRole('link') + expect(link).toHaveAttribute('href', mockPullRequestOpen.url) + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + + test('renders date in correct format', () => { + render() + const dateStr = new Date('2024-01-15').toLocaleDateString() + expect(screen.getByText(new RegExp(dateStr))).toBeInTheDocument() + }) + + test('applies correct styling to status badge for open PR', () => { + render() + const badge = screen.getByText('Open') + expect(badge).toHaveStyle('backgroundColor: #238636') + expect(badge).toHaveClass('text-white') + expect(badge).toHaveClass('text-xs') + expect(badge).toHaveClass('font-medium') + }) + + test('applies correct styling to status badge for merged PR', () => { + render() + const badge = screen.getByText('Merged') + expect(badge).toHaveStyle('backgroundColor: #8657E5') + }) + + test('applies correct styling to status badge for closed PR', () => { + render() + const badge = screen.getByText('Closed') + expect(badge).toHaveStyle('backgroundColor: #DA3633') + }) + + test('renders with PR link that opens in new tab', () => { + render() + const links = screen.getAllByRole('link') + expect(links[0]).toHaveAttribute('target', '_blank') + expect(links[0]).toHaveAttribute('rel', 'noopener noreferrer') + }) + }) +}) diff --git a/frontend/__tests__/unit/components/MetricsPDFButton.test.tsx b/frontend/__tests__/unit/components/MetricsPDFButton.test.tsx new file mode 100644 index 0000000000..4d99916e5f --- /dev/null +++ b/frontend/__tests__/unit/components/MetricsPDFButton.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import { fetchMetricsPDF } from 'server/fetchMetricsPDF' +import MetricsPDFButton from 'components/MetricsPDFButton' + +jest.mock('server/fetchMetricsPDF', () => ({ + fetchMetricsPDF: jest.fn(), +})) + +jest.mock('@heroui/tooltip', () => ({ + Tooltip: ({ children, content }: { children: React.ReactNode; content: string }) => ( +
+ {children} +
+ ), +})) + +describe('MetricsPDFButton', () => { + const mockFetchMetricsPDF = fetchMetricsPDF as jest.Mock + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders the download icon with tooltip', () => { + render() + + const tooltip = screen.getByTestId('tooltip') + expect(tooltip).toBeInTheDocument() + expect(tooltip).toHaveAttribute('title', 'Download as PDF') + }) + + it('calls fetchMetricsPDF when icon is clicked', async () => { + mockFetchMetricsPDF.mockResolvedValueOnce(undefined) + + render() + + const icon = screen.getByTestId('tooltip').querySelector('svg') + expect(icon).toBeInTheDocument() + + fireEvent.click(icon!) + + await waitFor(() => { + expect(mockFetchMetricsPDF).toHaveBeenCalledWith('/api/metrics', 'metrics.pdf') + }) + }) + + it('passes correct path and fileName props', async () => { + mockFetchMetricsPDF.mockResolvedValueOnce(undefined) + + render() + + const icon = screen.getByTestId('tooltip').querySelector('svg') + fireEvent.click(icon!) + + await waitFor(() => { + expect(mockFetchMetricsPDF).toHaveBeenCalledWith('/custom/path', 'custom-file.pdf') + }) + }) +}) diff --git a/frontend/__tests__/unit/components/Milestones.test.tsx b/frontend/__tests__/unit/components/Milestones.test.tsx index cc7a01bb99..3bfc7ccc60 100644 --- a/frontend/__tests__/unit/components/Milestones.test.tsx +++ b/frontend/__tests__/unit/components/Milestones.test.tsx @@ -25,8 +25,8 @@ jest.mock('utils/dateFormatter', () => { }) jest.mock('components/AnchorTitle', () => { - const MockAnchorTitle = ({ title, className }: { title: string; className?: string }) => { - return

{title}

+ const MockAnchorTitle = ({ title }: { title: string }) => { + return

{title}

} return { diff --git a/frontend/__tests__/unit/components/ModuleCard.test.tsx b/frontend/__tests__/unit/components/ModuleCard.test.tsx new file mode 100644 index 0000000000..000f93a8a7 --- /dev/null +++ b/frontend/__tests__/unit/components/ModuleCard.test.tsx @@ -0,0 +1,731 @@ +/** + * @file Complete unit tests for the ModuleCard component + * Targeting 90-95% code coverage. + */ +import { render, screen, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom' +import React from 'react' +import { ExperienceLevelEnum } from 'types/__generated__/graphql' +import type { Module } from 'types/mentorship' +import ModuleCard, { getSimpleDuration } from 'components/ModuleCard' + +// Mock next/navigation +const mockPathname = jest.fn() +jest.mock('next/navigation', () => ({ + usePathname: () => mockPathname(), +})) + +// Mock next/image +jest.mock('next/image', () => ({ + __esModule: true, + default: ({ + src, + alt, + title, + height, + width, + className, + }: { + src: string + alt: string + title?: string + height?: number + width?: number + className?: string + }) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ), +})) + +// Mock next/link +jest.mock('next/link', () => ({ + __esModule: true, + default: ({ + children, + href, + className, + }: { + children: React.ReactNode + href: string + className?: string + }) => ( + + {children} + + ), +})) + +// Mock react-icons +jest.mock('react-icons/fa6', () => ({ + FaChevronDown: (props: React.SVGProps) => ( + + ), + FaChevronUp: (props: React.SVGProps) => ( + + ), + FaTurnUp: (props: React.SVGProps) => , + FaCalendar: (props: React.SVGProps) => ( + + ), + FaHourglassHalf: (props: React.SVGProps) => ( + + ), +})) + +// Mock lodash capitalize +jest.mock('lodash', () => ({ + capitalize: (str: string) => + str ? str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() : '', +})) + +// Mock SingleModuleCard +jest.mock('components/SingleModuleCard', () => ({ + __esModule: true, + default: ({ + module, + accessLevel, + admins, + }: { + module: Module + accessLevel?: string + admins?: { login: string }[] + }) => ( +
+ Single Module: {module.name} + {admins && {admins.length}} +
+ ), +})) + +// Mock formatDate utility +jest.mock('utils/dateFormatter', () => ({ + formatDate: (date: string | number) => { + if (!date) return 'N/A' + const d = typeof date === 'number' ? new Date(date * 1000) : new Date(date) + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + }, +})) + +// Mock components +jest.mock('components/InfoItem', () => ({ + TextInfoItem: ({ + icon: Icon, + label, + value, + }: { + icon: React.ComponentType<{ className?: string }> + label: string + value: string + }) => ( +
+ + {label}: + {value} +
+ ), +})) + +jest.mock('components/TruncatedText', () => ({ + TruncatedText: ({ text }: { text: string }) => {text}, +})) + +describe('ModuleCard', () => { + const createMockModule = (overrides: Partial = {}): Module => ({ + id: '1', + key: 'test-module', + name: 'Test Module', + description: 'Test description', + experienceLevel: ExperienceLevelEnum.Beginner, + startedAt: '2024-01-01T00:00:00Z', + endedAt: '2024-03-01T00:00:00Z', + mentors: [], + mentees: [], + ...overrides, + }) + + const createMockContributor = (login: string, avatarUrl?: string, name?: string) => ({ + id: `id-${login}`, + login, + name: name || login, + avatarUrl: avatarUrl || `https://github.com/${login}.png`, + }) + + beforeEach(() => { + jest.clearAllMocks() + mockPathname.mockReturnValue('/my/mentorship/programs/test-program') + }) + + describe('Single Module Rendering', () => { + it('renders SingleModuleCard when given exactly one module', () => { + const modules = [createMockModule()] + + render() + + expect(screen.getByTestId('single-module-card')).toBeInTheDocument() + expect(screen.getByText('Single Module: Test Module')).toBeInTheDocument() + }) + + it('passes accessLevel to SingleModuleCard', () => { + const modules = [createMockModule()] + + render() + + const singleModuleCard = screen.getByTestId('single-module-card') + expect(singleModuleCard).toHaveAttribute('data-access-level', 'admin') + }) + + it('passes admins to SingleModuleCard', () => { + const modules = [createMockModule()] + const admins = [{ login: 'admin1' }, { login: 'admin2' }] + + render() + + expect(screen.getByTestId('admins-count')).toHaveTextContent('2') + }) + }) + + describe('Multiple Modules Rendering', () => { + it('renders multiple modules in a grid', () => { + const modules = [ + createMockModule({ key: 'mod1', name: 'Module 1' }), + createMockModule({ key: 'mod2', name: 'Module 2' }), + ] + + render() + + expect(screen.queryByTestId('single-module-card')).not.toBeInTheDocument() + expect(screen.getByText('Module 1')).toBeInTheDocument() + expect(screen.getByText('Module 2')).toBeInTheDocument() + }) + + it('shows only first 4 modules initially when more than 4 modules', () => { + const modules = [ + createMockModule({ key: 'mod1', name: 'Module 1' }), + createMockModule({ key: 'mod2', name: 'Module 2' }), + createMockModule({ key: 'mod3', name: 'Module 3' }), + createMockModule({ key: 'mod4', name: 'Module 4' }), + createMockModule({ key: 'mod5', name: 'Module 5' }), + createMockModule({ key: 'mod6', name: 'Module 6' }), + ] + + render() + + 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.queryByText('Module 5')).not.toBeInTheDocument() + expect(screen.queryByText('Module 6')).not.toBeInTheDocument() + }) + + it('shows "Show more" button when more than 4 modules', () => { + const modules = Array.from({ length: 6 }, (_, i) => + createMockModule({ key: `mod${i + 1}`, name: `Module ${i + 1}` }) + ) + + render() + + expect(screen.getByText('Show more')).toBeInTheDocument() + expect(screen.getByTestId('chevron-down')).toBeInTheDocument() + }) + + it('does not show "Show more" button when 4 or fewer modules', () => { + const modules = Array.from({ length: 4 }, (_, i) => + createMockModule({ key: `mod${i + 1}`, name: `Module ${i + 1}` }) + ) + + render() + + expect(screen.queryByText('Show more')).not.toBeInTheDocument() + }) + + it('toggles between showing all and showing limited modules on click', () => { + const modules = Array.from({ length: 6 }, (_, i) => + createMockModule({ key: `mod${i + 1}`, name: `Module ${i + 1}` }) + ) + + render() + + // Verify initial state + expect(screen.queryByText('Module 5')).not.toBeInTheDocument() + + // Click to show more + const showMoreButton = screen.getByText('Show more') + fireEvent.click(showMoreButton) + + // All modules should be visible + expect(screen.getByText('Module 5')).toBeInTheDocument() + expect(screen.getByText('Module 6')).toBeInTheDocument() + expect(screen.getByText('Show less')).toBeInTheDocument() + expect(screen.getByTestId('chevron-up')).toBeInTheDocument() + + // Click to show less + const showLessButton = screen.getByText('Show less') + fireEvent.click(showLessButton) + + // Should be back to initial state + expect(screen.queryByText('Module 5')).not.toBeInTheDocument() + expect(screen.getByText('Show more')).toBeInTheDocument() + }) + + it('handles keyboard navigation with Enter key', () => { + const modules = Array.from({ length: 6 }, (_, i) => + createMockModule({ key: `mod${i + 1}`, name: `Module ${i + 1}` }) + ) + + render() + + const showMoreButton = screen.getByRole('button') + fireEvent.keyDown(showMoreButton, { key: 'Enter', preventDefault: jest.fn() }) + + expect(screen.getByText('Module 5')).toBeInTheDocument() + expect(screen.getByText('Show less')).toBeInTheDocument() + }) + + it('handles keyboard navigation with Space key', () => { + const modules = Array.from({ length: 6 }, (_, i) => + createMockModule({ key: `mod${i + 1}`, name: `Module ${i + 1}` }) + ) + + render() + + const showMoreButton = screen.getByRole('button') + fireEvent.keyDown(showMoreButton, { key: ' ', preventDefault: jest.fn() }) + + expect(screen.getByText('Module 5')).toBeInTheDocument() + expect(screen.getByText('Show less')).toBeInTheDocument() + }) + + it('ignores other keyboard keys', () => { + const modules = Array.from({ length: 6 }, (_, i) => + createMockModule({ key: `mod${i + 1}`, name: `Module ${i + 1}` }) + ) + + render() + + const showMoreButton = screen.getByRole('button') + fireEvent.keyDown(showMoreButton, { key: 'Tab' }) + + // Should still show initial state + expect(screen.queryByText('Module 5')).not.toBeInTheDocument() + expect(screen.getByText('Show more')).toBeInTheDocument() + }) + + it('uses module.key for unique key if available, otherwise uses module.id', () => { + const modules = [ + createMockModule({ key: 'mod-key-1', id: 'mod-id-1', name: 'Module With Key' }), + createMockModule({ key: '', id: 'mod-id-2', name: 'Module Without Key' }), + ] + + render() + + expect(screen.getByText('Module With Key')).toBeInTheDocument() + expect(screen.getByText('Module Without Key')).toBeInTheDocument() + }) + }) + + describe('ModuleItem Component', () => { + it('renders module name with link to module details page', () => { + const modules = [ + createMockModule({ key: 'test-mod', name: 'Test Module' }), + createMockModule({ key: 'test-mod2', name: 'Test Module 2' }), + ] + + render() + + const links = screen.getAllByTestId('next-link') + const moduleLink = links.find((link) => + link.getAttribute('href')?.includes('/modules/test-mod') + ) + expect(moduleLink).toBeInTheDocument() + }) + + it('renders experience level info item', () => { + const modules = [ + createMockModule({ experienceLevel: ExperienceLevelEnum.Intermediate }), + createMockModule({ key: 'mod2', experienceLevel: ExperienceLevelEnum.Beginner }), + ] + + render() + + const levelItems = screen.getAllByTestId('info-item-level') + expect(levelItems.length).toBeGreaterThan(0) + expect(levelItems[0]).toHaveTextContent('Intermediate') + }) + + it('renders start date info item', () => { + const modules = [createMockModule(), createMockModule({ key: 'mod2' })] + + render() + + const startItems = screen.getAllByTestId('info-item-start') + expect(startItems.length).toBeGreaterThan(0) + }) + + it('renders duration info item', () => { + const modules = [createMockModule(), createMockModule({ key: 'mod2' })] + + render() + + const durationItems = screen.getAllByTestId('info-item-duration') + expect(durationItems.length).toBeGreaterThan(0) + }) + }) + + describe('Mentors and Mentees Display', () => { + it('does not render mentors/mentees section when both are empty', () => { + const modules = [ + createMockModule({ mentors: [], mentees: [] }), + createMockModule({ key: 'mod2', mentors: [], mentees: [] }), + ] + + render() + + expect(screen.queryByText('Mentors')).not.toBeInTheDocument() + expect(screen.queryByText('Mentees')).not.toBeInTheDocument() + }) + + it('renders mentors section when mentors have avatars', () => { + const modules = [ + createMockModule({ + mentors: [ + createMockContributor('mentor1', 'https://example.com/avatar1.png'), + createMockContributor('mentor2', 'https://example.com/avatar2.png'), + ], + }), + createMockModule({ key: 'mod2' }), + ] + + render() + + expect(screen.getByText('Mentors')).toBeInTheDocument() + }) + + it('renders mentees section when mentees have avatars', () => { + const modules = [ + createMockModule({ + mentees: [ + createMockContributor('mentee1', 'https://example.com/avatar1.png'), + createMockContributor('mentee2', 'https://example.com/avatar2.png'), + ], + }), + createMockModule({ key: 'mod2' }), + ] + + render() + + expect(screen.getByText('Mentees')).toBeInTheDocument() + }) + + it('shows only first 4 mentor avatars and +N for remaining', () => { + const mentors = Array.from({ length: 6 }, (_, i) => + createMockContributor(`mentor${i + 1}`, `https://example.com/avatar${i + 1}.png`) + ) + const modules = [createMockModule({ mentors }), createMockModule({ key: 'mod2' })] + + render() + + // Check for +2 indicator (6 mentors - 4 shown = 2 remaining) + expect(screen.getByText('+2')).toBeInTheDocument() + }) + + it('shows only first 4 mentee avatars and +N for remaining', () => { + const mentees = Array.from({ length: 7 }, (_, i) => + createMockContributor(`mentee${i + 1}`, `https://example.com/avatar${i + 1}.png`) + ) + const modules = [createMockModule({ mentees }), createMockModule({ key: 'mod2' })] + + render() + + // Check for +3 indicator (7 mentees - 4 shown = 3 remaining) + expect(screen.getByText('+3')).toBeInTheDocument() + }) + + it('does not show +N when 4 or fewer mentors', () => { + const mentors = Array.from({ length: 4 }, (_, i) => + createMockContributor(`mentor${i + 1}`, `https://example.com/avatar${i + 1}.png`) + ) + const modules = [createMockModule({ mentors }), createMockModule({ key: 'mod2' })] + + render() + + expect(screen.queryByText(/^\+\d+$/)).not.toBeInTheDocument() + }) + + it('filters out mentors without avatar URLs', () => { + const mentors = [ + createMockContributor('mentor1', 'https://example.com/avatar1.png'), + { id: 'id-mentor2', login: 'mentor2', name: 'Mentor 2', avatarUrl: '' }, // No avatar + { + id: 'id-mentor3', + login: 'mentor3', + name: 'Mentor 3', + avatarUrl: undefined as unknown as string, + }, // Undefined avatar + ] + const modules = [createMockModule({ mentors }), createMockModule({ key: 'mod2' })] + + render() + + // Should only show mentors with valid avatars + const images = screen.getAllByTestId('next-image') + expect(images.length).toBe(1) // Only mentor1 has avatar + }) + + it('displays mentor avatar with proper size parameter in URL', () => { + const mentors = [createMockContributor('mentor1', 'https://example.com/avatar1.png')] + const modules = [createMockModule({ mentors }), createMockModule({ key: 'mod2' })] + + render() + + const images = screen.getAllByTestId('next-image') + const mentorImage = images[0] + expect(mentorImage.getAttribute('src')).toContain('s=60') + }) + + it('handles avatar URL with existing query parameters', () => { + const mentors = [createMockContributor('mentor1', 'https://example.com/avatar1.png?v=2')] + const modules = [createMockModule({ mentors }), createMockModule({ key: 'mod2' })] + + render() + + const images = screen.getAllByTestId('next-image') + const mentorImage = images[0] + expect(mentorImage.getAttribute('src')).toContain('s=60') + }) + + it('handles invalid avatar URL gracefully', () => { + const mentors = [createMockContributor('mentor1', 'invalid-url')] + const modules = [createMockModule({ mentors }), createMockModule({ key: 'mod2' })] + + render() + + const images = screen.getAllByTestId('next-image') + // Should fall back to appending ?s=60 + expect(images[0].getAttribute('src')).toContain('s=60') + }) + + it('links mentee to member page when not on /my/mentorship path', () => { + mockPathname.mockReturnValue('/mentorship/programs/test-program') + const mentees = [createMockContributor('mentee1', 'https://example.com/avatar1.png')] + const modules = [createMockModule({ mentees }), createMockModule({ key: 'mod2' })] + + render() + + const links = screen.getAllByTestId('next-link') + const menteeLink = links.find((link) => + link.getAttribute('href')?.includes('/members/mentee1') + ) + expect(menteeLink).toBeInTheDocument() + }) + + it('links mentee to mentorship details page when on /my/mentorship path', () => { + mockPathname.mockReturnValue('/my/mentorship/programs/test-program') + const mentees = [createMockContributor('mentee1', 'https://example.com/avatar1.png')] + const modules = [ + createMockModule({ key: 'test-module', mentees }), + createMockModule({ key: 'mod2' }), + ] + + render() + + const links = screen.getAllByTestId('next-link') + const menteeLink = links.find((link) => + link + .getAttribute('href') + ?.includes('/my/mentorship/programs/test-program/modules/test-module/mentees/mentee1') + ) + expect(menteeLink).toBeInTheDocument() + }) + + it('links mentor to member page regardless of path', () => { + mockPathname.mockReturnValue('/my/mentorship/programs/test-program') + const mentors = [createMockContributor('mentor1', 'https://example.com/avatar1.png')] + const modules = [createMockModule({ mentors }), createMockModule({ key: 'mod2' })] + + render() + + const links = screen.getAllByTestId('next-link') + const mentorLink = links.find((link) => + link.getAttribute('href')?.includes('/members/mentor1') + ) + expect(mentorLink).toBeInTheDocument() + }) + + it('renders both mentors and mentees sections with border separator when both exist', () => { + const modules = [ + createMockModule({ + mentors: [createMockContributor('mentor1', 'https://example.com/avatar1.png')], + mentees: [createMockContributor('mentee1', 'https://example.com/avatar2.png')], + }), + createMockModule({ key: 'mod2' }), + ] + + render() + + expect(screen.getByText('Mentors')).toBeInTheDocument() + expect(screen.getByText('Mentees')).toBeInTheDocument() + }) + + it('uses contributor name for avatar alt and title when available', () => { + const mentors = [ + createMockContributor('mentor1', 'https://example.com/avatar1.png', 'John Doe'), + ] + const modules = [createMockModule({ mentors }), createMockModule({ key: 'mod2' })] + + render() + + const image = screen.getAllByTestId('next-image')[0] + expect(image.getAttribute('alt')).toBe('John Doe') + expect(image.getAttribute('title')).toBe('John Doe') + }) + + it('falls back to login for avatar alt and title when name is not available', () => { + const mentors = [ + { + id: 'id-mentor1', + login: 'mentor1', + name: '', + avatarUrl: 'https://example.com/avatar1.png', + }, + ] + const modules = [createMockModule({ mentors }), createMockModule({ key: 'mod2' })] + + render() + + const image = screen.getAllByTestId('next-image')[0] + expect(image.getAttribute('alt')).toBe('mentor1') + expect(image.getAttribute('title')).toBe('mentor1') + }) + }) + + describe('Path Handling', () => { + it('extracts programKey from pathname correctly', () => { + mockPathname.mockReturnValue('/my/mentorship/programs/program-123/modules') + const modules = [ + createMockModule({ + key: 'mod1', + mentees: [createMockContributor('mentee1', 'https://example.com/avatar.png')], + }), + createMockModule({ key: 'mod2' }), + ] + + render() + + const links = screen.getAllByTestId('next-link') + const menteeLink = links.find((link) => + link.getAttribute('href')?.includes('/programs/program-123/modules/') + ) + expect(menteeLink).toBeInTheDocument() + }) + + it('handles undefined pathname gracefully', () => { + mockPathname.mockReturnValue(undefined) + const modules = [createMockModule(), createMockModule({ key: 'mod2' })] + + // Should not throw + expect(() => render()).not.toThrow() + }) + + it('handles pathname without /programs/ segment', () => { + mockPathname.mockReturnValue('/some/other/path') + const modules = [createMockModule(), createMockModule({ key: 'mod2' })] + + render() + + const moduleElements = screen.getAllByText('Test Module') + expect(moduleElements.length).toBe(2) + }) + }) +}) + +describe('getSimpleDuration', () => { + it('returns N/A when start is missing', () => { + expect(getSimpleDuration('', '2024-03-01T00:00:00Z')).toBe('N/A') + }) + + it('returns N/A when end is missing', () => { + expect(getSimpleDuration('2024-01-01T00:00:00Z', '')).toBe('N/A') + }) + + it('returns N/A when both start and end are missing', () => { + expect(getSimpleDuration('', '')).toBe('N/A') + }) + + it('returns N/A for falsy numeric values', () => { + expect(getSimpleDuration(0, 1709251200)).toBe('N/A') + }) + + it('calculates duration correctly for string dates', () => { + // 2 months = approximately 8-9 weeks + const result = getSimpleDuration('2024-01-01T00:00:00Z', '2024-03-01T00:00:00Z') + expect(result).toMatch(/^\d+ weeks?$/) + }) + + it('calculates duration correctly for numeric timestamps (Unix seconds)', () => { + // Jan 1, 2024 to Jan 22, 2024 = 3 weeks + const start = 1704067200 // Jan 1, 2024 + const end = 1705881600 // Jan 22, 2024 + const result = getSimpleDuration(start, end) + expect(result).toBe('3 weeks') + }) + + it('returns "1 week" for exactly 7 days', () => { + const start = 1704067200 // Jan 1, 2024 + const end = 1704672000 // Jan 8, 2024 + const result = getSimpleDuration(start, end) + expect(result).toBe('1 week') + }) + + it('rounds up partial weeks', () => { + // 10 days should be 2 weeks (ceil(10/7) = 2) + const start = 1704067200 // Jan 1, 2024 + const end = 1704931200 // Jan 11, 2024 + const result = getSimpleDuration(start, end) + expect(result).toBe('2 weeks') + }) + + it('returns "Invalid duration" for invalid start date string', () => { + expect(getSimpleDuration('invalid-date', '2024-03-01T00:00:00Z')).toBe('Invalid duration') + }) + + it('returns "Invalid duration" for invalid end date string', () => { + expect(getSimpleDuration('2024-01-01T00:00:00Z', 'invalid-date')).toBe('Invalid duration') + }) + + it('returns "Invalid duration" when both dates are invalid', () => { + expect(getSimpleDuration('not-a-date', 'also-not-a-date')).toBe('Invalid duration') + }) + + it('handles mixed string and number inputs', () => { + const result = getSimpleDuration('2024-01-01T00:00:00Z', 1709251200) // String start, number end + expect(result).toMatch(/^\d+ weeks?$/) + }) + + it('handles very short durations (less than a week)', () => { + // 3 days should round up to 1 week + const start = 1704067200 // Jan 1, 2024 + const end = 1704326400 // Jan 4, 2024 + const result = getSimpleDuration(start, end) + expect(result).toBe('1 week') + }) + + it('handles negative duration (end before start)', () => { + const result = getSimpleDuration('2024-03-01T00:00:00Z', '2024-01-01T00:00:00Z') + // When end date is before start date, function returns negative weeks + expect(result).toBe('-8 weeks') + }) +}) diff --git a/frontend/__tests__/unit/components/ModuleForm.test.tsx b/frontend/__tests__/unit/components/ModuleForm.test.tsx new file mode 100644 index 0000000000..fb21e6f46a --- /dev/null +++ b/frontend/__tests__/unit/components/ModuleForm.test.tsx @@ -0,0 +1,843 @@ +/** + * @file Comprehensive unit tests for the ModuleForm component + * Targeting 90-95% code coverage. + */ +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' +import '@testing-library/jest-dom' +import React from 'react' +import ModuleForm, { ProjectSelector } from 'components/ModuleForm' + +// Mock next/navigation +const mockBack = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + back: mockBack, + }), +})) + +// Mock apollo client hooks +const mockQuery = jest.fn() +jest.mock('@apollo/client/react', () => ({ + ...jest.requireActual('@apollo/client/react'), + useApolloClient: () => ({ + query: mockQuery, + }), +})) + +// Mock heroui components +jest.mock('@heroui/react', () => ({ + Autocomplete: ({ + children, + inputValue, + _selectedKey, + onInputChange, + onSelectionChange, + isInvalid, + errorMessage, + isLoading, + label, + id, + }: { + children: React.ReactNode + inputValue?: string + _selectedKey?: string | null + onInputChange?: (value: string) => void + onSelectionChange?: (key: React.Key | Set | 'all') => void + isInvalid?: boolean + errorMessage?: string + isLoading?: boolean + label?: string + id?: string + }) => ( +
+ + onInputChange?.(e.target.value)} + data-loading={isLoading} + data-invalid={isInvalid} + /> + {errorMessage && {errorMessage}} +
{children}
+ + + + +
+ ), + AutocompleteItem: ({ + children, + textValue, + }: { + children: React.ReactNode + textValue?: string + }) => ( +
+ {children} +
+ ), +})) + +jest.mock('@heroui/select', () => ({ + Select: ({ + children, + selectedKeys, + onSelectionChange, + isInvalid, + errorMessage, + label, + id, + }: { + children: React.ReactNode + selectedKeys?: Set + onSelectionChange?: (keys: React.Key | Set | 'all') => void + isInvalid?: boolean + errorMessage?: string + label?: string + id?: string + }) => ( +
+ + + {errorMessage && {errorMessage}} + + + +
+ ), + SelectItem: ({ children }: { children: React.ReactNode }) => ( + + ), +})) + +// Mock form components +jest.mock('components/forms/shared/FormButtons', () => ({ + FormButtons: ({ loading, submitText }: { loading: boolean; submitText?: string }) => ( +
+ + +
+ ), +})) + +jest.mock('components/forms/shared/FormDateInput', () => ({ + FormDateInput: ({ + id, + label, + value, + onValueChange, + error, + touched, + }: { + id: string + label: string + value: string + onValueChange: (value: string) => void + error?: string + touched?: boolean + }) => ( +
+ + onValueChange(e.target.value)} + data-error={error} + data-touched={touched} + /> + {touched && error && {error}} +
+ ), +})) + +jest.mock('components/forms/shared/FormTextarea', () => ({ + FormTextarea: ({ + id, + label, + value, + onChange, + error, + touched, + }: { + id: string + label: string + value: string + onChange: (e: React.ChangeEvent) => void + error?: string + touched?: boolean + }) => ( +
+ +