diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 18235a8b53..35025013ca 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,9 +1,14 @@ +# https://docs.coderabbit.ai/reference/configuration + chat: auto_reply: true code_generation: docstrings: language: en-US early_access: true +issue_enrichment: + auto_enrich: + enabled: false language: en-US reviews: assess_linked_issues: true @@ -19,6 +24,6 @@ reviews: labeling_instructions: [] poem: false profile: chill - request_changes_workflow: false + request_changes_workflow: true review_status: true sequence_diagrams: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a92e25b019..47eda00761 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,8 +1,21 @@ - + +## STOP AND READ BEFORE SUBMITTING! REMOVE THIS PARAGRAPH BEFORE OPENING THE PR + +Thank you for your interest in contributing to OWASP Nest! + +Before starting any work, all external contributors **must first be assigned to an issue** in the repository. +This is a mandatory step in the OWASP Nest workflow and ensures that effort is coordinated, approved, and tracked properly. + +**Exception:** OWASP leaders are not required to follow this rule —- just make sure your username is included in the [exception list](https://github.com/OWASP/Nest/blob/main/.github/workflows/check-pr-issue-skip-usernames.txt). + +**If you were not assigned to the issue you are trying to resolve, stop right now and do NOT create this PR.** +Unassigned pull requests are automatically closed by our workflows — please don't waste your time. + +If you want to be assigned on any available issue, comment on it and wait for confirmation from the maintainers or project leads. ## Proposed change - + Resolves #(put the issue number here) @@ -10,5 +23,6 @@ Add the PR description here. ## Checklist -- [ ] I've read and followed the [contributing guidelines](https://github.com/OWASP/Nest/blob/main/CONTRIBUTING.md). -- [ ] I've run `make check-test` locally; all checks and tests passed. +- [ ] **Required:** I read and followed the [contributing guidelines](https://github.com/OWASP/Nest/blob/main/CONTRIBUTING.md) +- [ ] **Required:** I ran `make check-test` locally and all tests passed +- [ ] I used AI for code, documentation, or tests in this PR diff --git a/.github/ansible/production/nest.yaml b/.github/ansible/production/nest.yaml index fb2546529b..944f2a25d2 100644 --- a/.github/ansible/production/nest.yaml +++ b/.github/ansible/production/nest.yaml @@ -3,7 +3,7 @@ tasks: - name: Copy docker-compose.yaml ansible.builtin.copy: - src: '{{ github_workspace }}/docker-compose/production.yaml' + src: '{{ github_workspace }}/docker-compose/production/compose.yaml' dest: ~/docker-compose.yaml mode: '0644' @@ -63,7 +63,7 @@ ansible.builtin.command: cmd: crontab /tmp/production_crontab - - name: Retart services + - name: Restart services shell: cmd: docker compose up -d --pull always diff --git a/.github/ansible/production/proxy.yaml b/.github/ansible/production/proxy.yaml index 4c7ff7ce04..0bf1bebccd 100644 --- a/.github/ansible/production/proxy.yaml +++ b/.github/ansible/production/proxy.yaml @@ -1,22 +1,29 @@ - - name: Deploy Production Nest Proxy - hosts: production_nest_proxy - tasks: - - name: Copy proxy configuration files - copy: - src: '{{ github_workspace }}/proxy/{{ item }}' - dest: ~/ - mode: '0644' - loop: - - blocked_ips.conf - - docker-compose.yaml - - headers.conf - - production.conf - - redirects.conf +- name: Deploy Production Nest Proxy + hosts: production_nest_proxy + tasks: + - name: Copy proxy configuration files + copy: + src: '{{ github_workspace }}/proxy/{{ item }}' + dest: ~/ + mode: '0644' + loop: + - blocked_ips.conf + - cloudflare_realip.conf + - headers.conf + - production.conf + - proxy_cache.conf + - redirects.conf - - name: Retart services - shell: - cmd: docker compose up -d --pull always + - name: Copy docker compose file + copy: + src: '{{ github_workspace }}/docker-compose/proxy/compose.yaml' + dest: ~/docker-compose.yaml + mode: '0644' - - name: Prune docker images - shell: - cmd: docker image prune -f + - name: Restart services + shell: + cmd: docker compose up -d --pull always && docker compose restart + + - name: Prune docker images + shell: + cmd: docker image prune -f diff --git a/.github/ansible/staging/nest.yaml b/.github/ansible/staging/nest.yaml index 6259942226..c8dafb5d55 100644 --- a/.github/ansible/staging/nest.yaml +++ b/.github/ansible/staging/nest.yaml @@ -3,7 +3,7 @@ tasks: - name: Copy docker-compose.yaml ansible.builtin.copy: - src: '{{ github_workspace }}/docker-compose/staging.yaml' + src: '{{ github_workspace }}/docker-compose/staging/compose.yaml' dest: ~/docker-compose.yaml mode: '0644' @@ -67,7 +67,7 @@ ansible.builtin.command: cmd: crontab /tmp/staging_crontab - - name: Retart services + - name: Restart services shell: cmd: docker compose up -d --pull always diff --git a/.github/ansible/staging/proxy.yaml b/.github/ansible/staging/proxy.yaml index 1d27bf6f6e..dc75d8c107 100644 --- a/.github/ansible/staging/proxy.yaml +++ b/.github/ansible/staging/proxy.yaml @@ -1,22 +1,29 @@ - - name: Deploy Staging Nest Proxy - hosts: staging_nest_proxy - tasks: - - name: Copy proxy configuration files - copy: - src: '{{ github_workspace }}/proxy/{{ item }}' - dest: ~/ - mode: '0644' - loop: - - blocked_ips.conf - - docker-compose.yaml - - headers.conf - - redirects.conf - - staging.conf +- name: Deploy Staging Nest Proxy + hosts: staging_nest_proxy + tasks: + - name: Copy proxy configuration files + copy: + src: '{{ github_workspace }}/proxy/{{ item }}' + dest: ~/ + mode: '0644' + loop: + - blocked_ips.conf + - cloudflare_realip.conf + - headers.conf + - proxy_cache.conf + - redirects.conf + - staging.conf - - name: Retart services - shell: - cmd: docker compose up -d --pull always + - name: Copy docker compose file + copy: + src: '{{ github_workspace }}/docker-compose/proxy/compose.yaml' + dest: ~/docker-compose.yaml + mode: '0644' - - name: Prune docker images - shell: - cmd: docker image prune -f + - name: Restart services + shell: + cmd: docker compose up -d --pull always && docker compose restart + + - name: Prune docker images + shell: + cmd: docker image prune -f diff --git a/.github/dependabot.yml b/.github/dependabot.yml index faa567cc81..26898ec789 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,22 +1,42 @@ version: 2 updates: - package-ecosystem: docker - directory: /backend/docker + directory: /docker/backend schedule: interval: daily - package-ecosystem: docker - directory: /cspell + directory: /docker/cspell + schedule: + interval: daily + + - package-ecosystem: docker-compose + directory: /docker-compose/local + schedule: + interval: daily + + - package-ecosystem: docker-compose + directory: /docker-compose/production + schedule: + interval: daily + + - package-ecosystem: docker-compose + directory: /docker-compose/proxy + schedule: + interval: daily + + - package-ecosystem: docker-compose + directory: /docker-compose/staging schedule: interval: daily - package-ecosystem: docker - directory: /docs/docker + directory: /docker/docs schedule: interval: daily - package-ecosystem: docker - directory: /frontend/docker + directory: /docker/frontend schedule: interval: daily diff --git a/.github/workflows/check-pr-issue-skip-usernames.txt b/.github/workflows/check-pr-issue-skip-usernames.txt new file mode 100644 index 0000000000..87a93fc213 --- /dev/null +++ b/.github/workflows/check-pr-issue-skip-usernames.txt @@ -0,0 +1,654 @@ +0xchrisb +0xdsousa +0xmariz +1xpert +23bartman +3asm +4ng3lhacker +4ppsec +7a +aaronweaver +aaryanbhujang +abaan9350 +abdullah1908 +abdussaboor +abhijitio +abhisek +abhisek-r-owasp +acs-web-tech +act1vand0 +ad3liae +adeyosemanputra +adityadamodaran +adrianwinckles +agent-skywalker +agg-owasp-barna +agordonbrooks +ahsraeisi +airween +akemery +akritisagar028-prog +akzharkyndm +alanh0vx +albertchae +aleks-ry +alex-nork +alexzac +alezza +algomaster99 +ali-razmjoo +ali-yazdani +alphaspiderman +alterakey +alya-verma +aman566 +anatolybodner +andgubarev +andreashappe +andreasmayer +ankit-karan +ankurg132 +anthturner +anuragayumishra +anwar-haq +aphilipti +aramhovsepyan +aramrami +aravind2000-786 +aravindhakumar-m +aressec +arkid15r +aruneeshsalhotra +ashfleet +aswanth1508 +asyafaat +atharvdanave04 +athesh-pargau7 +atzgg132 +avalz +avidouglen +ayossef21 +azaralili +aztr4x +babdan +banditmukund +behyka +ben-cremit +ben-zhao +bendehaan +benjamin-ky +bennpick +bernardofsr +bilcorry +binarymist +bkimminich +bluesquare1 +bra001 +brentoptiv +brettcrawley +bugresearch +cabelo +caffix +cahnaro +cameron-w-owasp +canozsec +canvo +carlosallendes +carloskardan +carmonagaston +castillorm +cbassem +celiakassa +cerrussell +cgivre +chancecarmichael +charlesbickel +chirag1800 +chkoelbl +christophniehoff +ckarande +clara-andress +cldrn +cmlong +cmrevoredo +coderpatros +cognitivegears +coj337 +commjoen +corrupted-brain +cpholguera +craigfrancis +cristianovisk +cristianzsh +cronchie +cruzgio +curea +cw-owasp +cyberahmedx +cyberbeta +cyberengross +cyberfascinate +cyberlior +cybernot +d0znpp +dadowasp +daniel-cider +danielcuthbert +danielkrasnokucki +danielmiessler +danielonsecurity +danielvzh +danmullen +danyalejaz +darthhater +davewichers +davidlambertcyber +davisfreimanis +deepak-r-16 +default-eshu +denisemurt +denispodgurskii +devgrega +dewaldodev +disenchant +divine016 +divyesh-0x01 +dkefer +dm-17 +dmarushkin +donnieblt +dordali-cyolo +dr0verrid3 +draichev +drneox +drwetter +dsmartins +dubrefjord +dune73 +e-shuster +edusantos33 +edwingozeling +eivindarvesen +elarlang +elvinmollinedo +emergar07 +emomartin-owasp +epabloseven +erezyalon +erik-ceballos +erlend-oftedal-adsk +ernest-mougoue +etnoy +evtil +eydunjacobsen +fabasi +faizanmir01 +faizzaidi +fatma8989 +fernandodebrando +fernandofloreslima +fguisso +folivaes +francoisfried +furk4n0zturk +fwalbuloushi +fzipi +g3l3i4silv4 +galoget +ganeshc-305 +ganggreentempertatum +gansb +garthoid +gautam-hacxie +geovanamelo +gianlucafrei +giftcup +glyn-owasp +godfreynolan +gojo-satorou-v7 +govindarajanl +gr33nm0nk2802 +greenhill-owasp +gregorspagnolo +guled +h-fantome +h3xstream +h4r4k1r1 +hackkery +hahwul +haiitscraig +hardlyhuman +haydencorry +haythmkenway +hblankenship +hellodanielting +henrikaslund +henrikplate +henriqueocabral +heracias +hoheinzollern +hunt259 +huseyingulsin +hvb-xx7 +iamsanskarsharma +idanbasre +igorblum +ilkinjavadov +infosec-experiments +infosec-rahul +infosecdad +inonshk +int16-justin +interference-security +irfaansantoe +irishmoneyman +isanori-sakanashi-owasp +itscooper +itsrvsingh +ivanmugabi +ivuk +izar +j0sper +j12934 +jahangiralamdhaka +janceluizowasp +janmasarik +janmejayaswainofficial +janwolff +jasminmair +jaswanthravichandran +jaythakker +jcchavezs +jeevan-singh +jeremychoi +jeremylong +jerryhoff +jersalamon +jeymz +jgadsden +jinsonvarghese +jitu-ranjan +jjmschofield +jklow +jkowalleck +jmanico +jmarcil +jmbmxer +jme-iteratec +jmehnle +joeapiiro +johanna-a +johnchd +johndileo +johnellingsworth +johnson-chandler +johnvargas +jonathansinger +josephkonieczka +josteitv +jptosso +jrm16020 +jsokol +jsotiro +jtmelton +juanjodomenech +jun-m2mt +justmorpheus +jyotiraval +karthik-uj +karuppan-the-pentester +kashhassan +kasya +katyanton +keidi16 +kellysantalucia +kenhuangus +kenprole +kerberosmansour +kevcody +kingthorin +kirk-owasp +kishanjaisoorya +kmcquade +korniko98 +korvalas +kozmic +ks-raj +ksg97031 +ktreptow +kunwaratulhax0r +kwwall +laflamablanca988 +lanverly +lavelliane +lazydk +lcrudolph +lestertechms +lethalcyanide +liffe93 +lightos +lilr00t +lirantal +lizfrenz +lmarqueta +loganaden +lreading +lrosario +luca-piras +luisco +luizboina +lutfumertceylan +m3t3kh4n +mackowski +macsdani +madhuakula +mamicidal +manasharsh +manuelwalder +marcel-haag +marcstern +markdenihan +martingalloar +martinklubal +martinmarsicano +matayoshi +matb42 +mateo-martinez +matheusbanhos +matheuscezar +matowasp +matreurai +matt-perrotti +mavensecurity +mbrg +mccabe615 +meetsaad +mehulenc +mesquidar +mgrotheer +miglen +mihailstoynov +mihir-shah99 +mike-goodwin +mikesamuel +milanowasp +minimike86 +mjhouseman +mlessio +mmorana1 +mohakrudrakshh +mohfateh +moshaad7 +mowzk +mpast +mpiperevski +mrjoshw +mrtaheramine +mrtc0 +mrutkows +mtesauro +mtgsjr +mtrampuz +mwalkowski +nalarson +nampereira +nanzggits +napster7 +nbaars +neelamadheswari +neelsoni26 +neil-smithline +nets4geeks +ni5am +nickmillerinfosec +nigelkay +nihal-tiwari +nihelowasp +nimsilva +ninedter +nipunnegi2 +nitinraghavendra +njilav +nkalexiou +nodeswithsumit +noorazam +noqcks +northdpole +notquitelost +nscuro +ntoskernel +nunoloureiro +ocical +ocwong +officernordberg +okdt +omarkurt +omer-cider +omerfarukkocaefe +oorryy +opheliar-chan +orace-github +orarch +oritro +orlyjamie +owasp-seoul +owasp-tobias-glemser +owasprox +owaspsgu +p3tra-wp +p4nk4jv +padi-owasp +palzuri +parthpatil7 +patrickleclerc-lacapitale +paul-ion +pauloasilva +paulschwarzenberger +peachycloudsecurity +pedrogabriellima +pfortuna +pgomez-redsauce +phenggeler +phischde +pignicorn +piyushroshan +pkoistin +pm2003 +pocketjawa +pontocom +prabhu +prajit-sengupta +prasadsalvi +prasanthc41m +prathamk2003 +praveen-h-p +preetkaran20 +psiinon +psillanp +pzzd +quinnturner +r0h1t3 +r3dw0lfsec +racso20 +rafaelenrike +rahulkamilya +rajanagori +rakeshelamaran98 +rameshsagar +ramsal +rarmando +raulsiles +ravi55555mishra +rbozburun +rcbarnett +rejahrehim +rewtd +rhishinathvarma +ricard64x +righettod +riramar +robinvanloonowasp +robvanderveer +rockhoppersec +rodrigovb +ronilichtman +ronvider +ronykris +rosesecurity +rossayoung +roxana-calderon-owasp +rseveymant +rupakbiswas-2304 +s-sriharsha +saeeddhqan +sagarbhure +saidas45 +saikrishnagobburi +sakerman +salmankhwaja +sampsonc +samyak8ktr +satiracode +sbrown17 +sc-hattori +sclinton +scriptingxss +sea-erkin +seanduggan +seanwrightsec +seb-kw +sebadele +secureideas +securestep9 +securient +sempf +seniorstoryteller +serrovsky +sethlaw +sevdegul +seyedgol +shaneinsweden +sharkeonix +sharonfrankln +shashank-in +shreyank-sh +shreyaskgit +shruti-s-kulkarni +shsingh +shubham735 +siamakhatami +sidd-oo +siegfriedhollerer +simonrommer +singh9993 +slaughterjames +slega +smaranchand +snbig +soboris +soos-jjennings +spassarop +spinkham +spoint42 +sriniously +sslhello +starmtp +stefanoamorelli +stevekrez +stevespringett +sttor +subzer0girl2 +sudosharma +sumanth8495 +summitt +sunny6300 +superuserx +sushi2k +swapnil-srivastava273 +swarnav0305 +swaroopsy +swierckx +sydseter +syedasadrazadevops +szh +takaharuogasa +takeshi-murai +talastrix +tejax-v2 +tghosth +thavisoukj1 +thecyberfang +thejonmccoy +thekyles +theseion +thomasbreland +thompson005 +tiana-chandler +timandrejacobsen +timbastin +timmyteo +timurxyz +tkisason +tlkristensen +tmendo +tobyirvine +todorolev +tom-morita +tomconner +tomer-mobb +tomspencer +tonylturner +tonyuv +totollygeek +troymarshall +tt-amiran +tulasigr +udaykor +ueno1000 +upgoingstar +vah13 +valerymelou +vanderaj +varchashva +vavkamil +vchan-in +vdbaan +vedantfr +velandra666 +vermava +vincepascale +vineethsai +virtualsteve-star +vitorluigi +viyatb +vlakol +vruiz-silikn +vxvera-owasp +vyshnavvizz +wadealcorn +wagrodrigo +wangjie8578 +warlordsam07 +weltraumschaf +wendysegura +wesleyparsons +winsyr +wurstbrot +xcaciv +xeno6696 +xpert98 +yangsec888 +yarivtal +yash-roongta +yasuberry +ycanatilgan +yehuju +yfc-owasp +yilangtsai +ylepage +yusufarbc +yuvvi01 +yyudhi +zangobot +zbraiterman +zubcevic diff --git a/.github/workflows/check-pr-issue.yaml b/.github/workflows/check-pr-issue.yaml index 9498ac7e6c..3a1f8c199b 100644 --- a/.github/workflows/check-pr-issue.yaml +++ b/.github/workflows/check-pr-issue.yaml @@ -1,24 +1,29 @@ name: Check PR linked issue and assignee on: - - pull_request_target - -permissions: - contents: read - issues: read - pull-requests: write + pull_request_target: + types: + - opened jobs: check-pr-issue: + permissions: + contents: read + issues: read + pull-requests: write runs-on: ubuntu-latest - steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + with: + ref: ${{ github.event.pull_request.base.sha }} + - name: Check PR linked issue and assignee - uses: arkid15r/check-pr-issue-action@a3635191c798f111aae577759b579dc37bb13e02 + uses: arkid15r/check-pr-issue-action@6abc0af0d795f1ddec70fbe6225f41b45ac26f74 with: + check_issue_reference: 'true' close_pr_on_failure: 'true' github_token: ${{ secrets.GITHUB_TOKEN }} no_assignee_message: 'The linked issue must be assigned to the PR author.' no_issue_message: 'The PR must be linked to an issue assigned to the PR author.' require_assignee: 'true' - skip_users: 'arkid15r,kasya' + skip_users_file_path: '.github/workflows/check-pr-issue-skip-usernames.txt' diff --git a/.github/workflows/label-issues.yaml b/.github/workflows/label-issues.yaml index 6261dc299d..53f07ca059 100644 --- a/.github/workflows/label-issues.yaml +++ b/.github/workflows/label-issues.yaml @@ -6,11 +6,10 @@ on: - edited - opened -permissions: - issues: write - jobs: label: + permissions: + issues: write runs-on: ubuntu-latest steps: - name: Apply Labels to Issues diff --git a/.github/workflows/label-pull-requests.yaml b/.github/workflows/label-pull-requests.yaml index f61873877a..f3616e883a 100644 --- a/.github/workflows/label-pull-requests.yaml +++ b/.github/workflows/label-pull-requests.yaml @@ -3,12 +3,11 @@ name: Label Pull Requests on: - pull_request_target -permissions: - contents: read - pull-requests: write - jobs: labeler: + permissions: + contents: read + pull-requests: write runs-on: ubuntu-latest steps: - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b diff --git a/.github/workflows/run-ci-cd.yaml b/.github/workflows/run-ci-cd.yaml index 797030703d..b40825635e 100644 --- a/.github/workflows/run-ci-cd.yaml +++ b/.github/workflows/run-ci-cd.yaml @@ -19,36 +19,31 @@ on: - published workflow_dispatch: -permissions: - contents: read - -concurrency: - cancel-in-progress: true - group: ${{ github.repository }}-${{ github.workflow }}-${{ github.ref }} - env: FORCE_COLOR: 1 jobs: pre-commit: name: Run pre-commit checks + permissions: + contents: read runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Install Poetry run: pipx install poetry - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 with: cache: 'poetry' cache-dependency-path: backend/poetry.lock python-version: '3.13' - name: Set up pre-commit cache - uses: actions/cache@v4 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb with: path: ~/.cache/pre-commit key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} @@ -76,10 +71,12 @@ jobs: check-frontend: name: Run frontend checks + permissions: + contents: read runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Install pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 @@ -88,9 +85,9 @@ jobs: run_install: true - name: Set up Node - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f with: - node-version: 22 + node-version: 24 cache: 'pnpm' cache-dependency-path: frontend/pnpm-lock.yaml @@ -109,10 +106,12 @@ jobs: spellcheck: name: Run spell check + permissions: + contents: read runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Run cspell run: | @@ -124,10 +123,12 @@ jobs: - check-frontend - pre-commit - spellcheck + permissions: + contents: read runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Run Trivy Repository Scan uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 @@ -138,15 +139,17 @@ jobs: version: latest scan-ci-dependencies: - name: Run CI Denendencies Scan + name: Run CI Dependencies Scan needs: - check-frontend - pre-commit - spellcheck + permissions: + contents: read runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Run Trivy Filesystem Scan uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 @@ -161,13 +164,15 @@ jobs: needs: - scan-code - scan-ci-dependencies + permissions: + contents: read runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Set up Docker buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f - name: Build backend test image uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 @@ -178,7 +183,7 @@ jobs: cache-to: | type=gha,compression=zstd context: backend - file: backend/docker/Dockerfile.test + file: docker/backend/Dockerfile.test load: true platforms: linux/amd64 tags: owasp/nest:test-backend-latest @@ -186,19 +191,22 @@ jobs: - name: Run backend tests run: | docker run -e DJANGO_SETTINGS_MODULE=settings.test --env-file backend/.env.example owasp/nest:test-backend-latest pytest + timeout-minutes: 10 run-frontend-unit-tests: name: Run frontend unit tests needs: - scan-code - scan-ci-dependencies + permissions: + contents: read runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Set up Docker buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f - name: Build frontend unit-testing image uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 @@ -209,7 +217,7 @@ jobs: cache-to: | type=gha,compression=zstd context: frontend - file: frontend/docker/Dockerfile.unit.test + file: docker/frontend/Dockerfile.unit.test load: true platforms: linux/amd64 tags: owasp/nest:test-frontend-unit-latest @@ -217,19 +225,22 @@ jobs: - name: Run frontend unit tests run: | docker run --env-file frontend/.env.example owasp/nest:test-frontend-unit-latest pnpm run test:unit + timeout-minutes: 10 run-frontend-e2e-tests: name: Run frontend e2e tests needs: - scan-code - scan-ci-dependencies + permissions: + contents: read runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Set up Docker buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f - name: Build frontend end-to-end testing image uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 @@ -238,7 +249,7 @@ jobs: type=gha type=registry,ref=owasp/nest:test-frontend-e2e-cache context: frontend - file: frontend/docker/Dockerfile.e2e.test + file: docker/frontend/Dockerfile.e2e.test load: true platforms: linux/amd64 tags: owasp/nest:test-frontend-e2e-latest @@ -246,12 +257,14 @@ jobs: - name: Run frontend end-to-end tests run: | docker run --env-file frontend/.env.example owasp/nest:test-frontend-e2e-latest pnpm run test:e2e + timeout-minutes: 10 set-release-version: name: Set release version - runs-on: ubuntu-latest outputs: release_version: ${{ steps.set.outputs.release_version }} + permissions: {} + runs-on: ubuntu-latest steps: - name: Set release version id: set @@ -275,16 +288,18 @@ jobs: - run-frontend-e2e-tests - run-frontend-unit-tests - set-release-version + permissions: + contents: read runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Set up QEMU uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 - name: Set up Docker buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f - name: Login to Docker Hub uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef @@ -304,7 +319,7 @@ jobs: cache-to: | type=registry,ref=owasp/nest:backend-staging-cache context: backend - file: backend/docker/Dockerfile + file: docker/backend/Dockerfile load: true platforms: linux/amd64 push: true @@ -352,7 +367,7 @@ jobs: cache-to: | type=registry,ref=owasp/nest:frontend-staging-cache context: frontend - file: frontend/docker/Dockerfile + file: docker/frontend/Dockerfile load: true platforms: linux/amd64 push: true @@ -377,15 +392,18 @@ jobs: echo "**Backend:** ${{ steps.backend-size.outputs.human_readable }}" echo "**Frontend:** ${{ steps.frontend-size.outputs.human_readable }}" } >> $GITHUB_STEP_SUMMARY + timeout-minutes: 10 scan-staging-images: name: Scan Staging Images needs: - build-staging-images + permissions: + contents: read runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Scan backend image continue-on-error: true @@ -423,10 +441,12 @@ jobs: needs: - scan-staging-images - set-release-version + permissions: + contents: read runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Prepare SSH key env: @@ -537,10 +557,12 @@ jobs: github.ref == 'refs/heads/main' needs: - deploy-staging-nest + permissions: + contents: read runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Prepare SSH key env: @@ -558,14 +580,16 @@ jobs: working-directory: .github/ansible run: ansible-playbook -i inventory.yaml staging/proxy.yaml -e "github_workspace=$GITHUB_WORKSPACE" - run-lighthouse-ci: + run-staging-lighthouse-ci: name: Run Lighthouse CI needs: - deploy-staging-nest-proxy + permissions: + contents: read runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Install pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 @@ -574,11 +598,11 @@ jobs: version: 10 - name: Set up Node - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f with: cache-dependency-path: frontend/pnpm-lock.yaml cache: 'pnpm' - node-version: 22 + node-version: 24 - name: Run lighthouse-ci env: @@ -588,6 +612,31 @@ jobs: timeout-minutes: 15 working-directory: frontend + run-staging-zap-baseline-scan: + name: Run ZAP Baseline Scan + needs: + - deploy-staging-nest-proxy + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Run ZAP Baseline Scan + uses: zaproxy/action-baseline@de8ad967d3548d44ef623df22cf95c3b0baf8b25 + with: + token: ${{ secrets.GITHUB_TOKEN }} + target: 'https://nest.owasp.dev' + allow_issue_writing: false + fail_action: false + cmd_options: '-a -r zap-report.html' + + - name: Upload ZAP report + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: zap-baseline-scan-report-${{ github.run_id }} + path: zap-report.html + + build-production-images: name: Build Production Images env: @@ -601,16 +650,18 @@ jobs: - run-frontend-e2e-tests - run-frontend-unit-tests - set-release-version + permissions: + contents: read runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Set up QEMU uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 - name: Set up Docker buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f - name: Login to Docker Hub uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef @@ -628,7 +679,7 @@ jobs: type=gha type=registry,ref=owasp/nest:backend-staging-cache context: backend - file: backend/docker/Dockerfile + file: docker/backend/Dockerfile load: true platforms: linux/amd64 push: true @@ -674,7 +725,7 @@ jobs: type=gha type=registry,ref=owasp/nest:frontend-staging-cache context: frontend - file: frontend/docker/Dockerfile + file: docker/frontend/Dockerfile load: true platforms: linux/amd64 push: true @@ -699,15 +750,18 @@ jobs: echo "**Backend:** ${{ steps.backend-size.outputs.human_readable }}" echo "**Frontend:** ${{ steps.frontend-size.outputs.human_readable }}" } >> $GITHUB_STEP_SUMMARY + timeout-minutes: 10 scan-production-images: name: Scan Production Images needs: - build-production-images + permissions: + contents: read runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Setup Trivy uses: aquasecurity/setup-trivy@e6c2c5e321ed9123bda567646e2f96565e34abe1 @@ -749,10 +803,12 @@ jobs: needs: - scan-production-images - set-release-version + permissions: + contents: read runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Prepare SSH key env: @@ -874,10 +930,12 @@ jobs: github.event.action == 'published' needs: - deploy-production-nest + permissions: + contents: read runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Prepare SSH key env: @@ -894,3 +952,27 @@ jobs: - name: Run proxy deploy working-directory: .github/ansible run: ansible-playbook -i inventory.yaml production/proxy.yaml -e "github_workspace=$GITHUB_WORKSPACE" + + run-production-zap-baseline-scan: + name: Run ZAP Baseline Scan + needs: + - deploy-production-nest-proxy + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Run ZAP Baseline Scan + uses: zaproxy/action-baseline@de8ad967d3548d44ef623df22cf95c3b0baf8b25 + with: + token: ${{ secrets.GITHUB_TOKEN }} + target: 'https://nest.owasp.org' + allow_issue_writing: false + fail_action: false + cmd_options: '-a -r zap-report.html' + + - name: Upload ZAP report + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: zap-baseline-scan-report-${{ github.run_id }} + path: zap-report.html diff --git a/.github/workflows/run-code-ql.yaml b/.github/workflows/run-code-ql.yaml index 271fd64f33..d97bc4414b 100644 --- a/.github/workflows/run-code-ql.yaml +++ b/.github/workflows/run-code-ql.yaml @@ -12,13 +12,11 @@ on: - main workflow_dispatch: -permissions: - contents: read - jobs: code-ql: name: CodeQL permissions: + contents: read security-events: write runs-on: ubuntu-latest strategy: @@ -28,10 +26,10 @@ jobs: - python steps: - name: Check out repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Initialize CodeQL - uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b + uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 with: languages: ${{ matrix.language }} @@ -43,9 +41,9 @@ jobs: - name: Set up Node if: matrix.language == 'javascript-typescript' - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f with: - node-version: 22 + node-version: 24 cache: 'pnpm' cache-dependency-path: frontend/pnpm-lock.yaml @@ -55,6 +53,6 @@ jobs: run: pnpm install --frozen-lockfile - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@e12f0178983d466f2f6028f5cc7a6d786fd97f4b + uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 with: category: /language:${{ matrix.language }} diff --git a/.github/workflows/update-nest-test-images.yaml b/.github/workflows/update-nest-test-images.yaml index 115fd85f1f..fbc35c72d4 100644 --- a/.github/workflows/update-nest-test-images.yaml +++ b/.github/workflows/update-nest-test-images.yaml @@ -8,19 +8,18 @@ on: env: FORCE_COLOR: 1 -permissions: - contents: read - jobs: update-nest-test-images: name: Update Nest test images if: ${{ github.repository == 'OWASP/Nest' }} + permissions: + contents: read runs-on: ubuntu-latest steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Set up Docker buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f - name: Login to Docker Hub uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef @@ -38,7 +37,7 @@ jobs: type=gha,compression=zstd type=registry,ref=owasp/nest:test-backend-cache context: backend - file: backend/docker/Dockerfile.test + file: docker/backend/Dockerfile.test platforms: linux/amd64 push: true tags: owasp/nest:test-backend-latest @@ -53,7 +52,7 @@ jobs: type=gha,compression=zstd type=registry,ref=owasp/nest:test-frontend-unit-cache context: frontend - file: frontend/docker/Dockerfile.unit.test + file: docker/frontend/Dockerfile.unit.test platforms: linux/amd64 push: true tags: owasp/nest:test-frontend-unit-latest @@ -68,7 +67,7 @@ jobs: type=gha,compression=zstd type=registry,ref=owasp/nest:test-frontend-e2e-cache context: frontend - file: frontend/docker/Dockerfile.e2e.test + file: docker/frontend/Dockerfile.e2e.test platforms: linux/amd64 push: true tags: owasp/nest:test-frontend-e2e-latest diff --git a/.gitignore b/.gitignore index 6b9ecd4c8c..2a4a490621 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ __pycache__ .bash_history .cache .coverage +.cursor/rules/snyk_rules.mdc .DS_Store .env* !.env.example diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1f3cc690df..df8beaa5dc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: - --args=--config=__GIT_WORKING_DIR__/infrastructure/.tflint.hcl - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.6 + rev: v0.14.10 hooks: - id: ruff-check args: @@ -48,7 +48,7 @@ repos: exclude: (.github|pnpm-lock.yaml) - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.18.2 + rev: v1.19.1 hooks: - id: mypy additional_dependencies: @@ -80,7 +80,7 @@ repos: - id: file-contents-sorter args: - --unique - files: cspell/custom-dict.txt + files: (.github/workflows/check-pr-issue-skip-usernames.txt|cspell/custom-dict.txt) - id: mixed-line-ending args: - --fix=lf diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 09c8cc1de9..53b8936f86 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -223,28 +223,16 @@ Follow these steps to set up the OWASP Nest application: 1. **Create Environment Files**: - - Create a local environment file in the `backend` directory: + - Copy the contents from the template file into your new backend local environment file: ```bash - touch backend/.env + cp backend/.env.example backend/.env ``` - - Copy the contents from the template file into your new local environment file: + - Copy the contents from the template file into your new frontend local environment file: ```bash - cat backend/.env.example > backend/.env - ``` - - - Create a local environment file in the `frontend` directory: - - ```bash - touch frontend/.env - ``` - - - Copy the contents from the template file into your new local environment file: - - ```bash - cat frontend/.env.example > frontend/.env + cp frontend/.env.example frontend/.env ``` Ensure that all `.env` files are saved in **UTF-8 format without BOM (Byte Order Mark)**. This is crucial to prevent "Unexpected character" errors during application execution or Docker image building. @@ -262,7 +250,8 @@ Ensure that all `.env` files are saved in **UTF-8 format without BOM (Byte Order 1. **Set Up Algolia**: - Go to [Algolia](https://www.algolia.com/) and create a free account. - - After creating an account, create an Algolia app. + - An Algolia app is automatically created for you when you sign up. + - During the sign up process, you may be asked to import data. You can skip this step. - Update your `backend/.env` file with the following keys from your Algolia app (use **write** API key for backend): ```plaintext @@ -270,7 +259,7 @@ Ensure that all `.env` files are saved in **UTF-8 format without BOM (Byte Order DJANGO_ALGOLIA_WRITE_API_KEY= ``` - - Ensure that your API key has index write permissions. You can ignore any onboarding wizard instructions provided by Algolia. + - Note: The default write API key should have index write permissions (addObject permission). If you do not use the default write API key, ensure that your API key has this permission. - If you encounter any issues, you can refer directly to Algolia's [documentation](https://www.algolia.com/doc/guides/getting-started/quick-start/) 1. **Run the Application**: @@ -427,6 +416,82 @@ If you are adding new functionality, include relevant test cases. ## Contributing Workflow +The following diagram illustrates the complete contribution workflow: + +```mermaid +flowchart TD + Start([Start]) --> CreateIssue[Create New Issue] + Start --> FindIssue[Find Existing Issue] + CreateIssue --> GetAssigned["**Get Assigned to Issue**
PRs will be automatically
closed if you're not assigned"] + FindIssue --> GetAssigned + GetAssigned --> ResolveIssue[**Resolve Issue**
work on code/docs/tests updates] + + ResolveIssue --> RunChecks{**Run `make check-test`**
locally! This is a required step -- you will not be assigned to new issues if you ignore this} + RunChecks -->|Fails| WP1[ ] + RunChecks -->|Passes| PushChanges[**Push Changes to
GitHub Fork Branch**] + WP1 -.-> ResolveIssue + + PushChanges --> HasPR{PR Exists?} + HasPR -->|No| CreateDraftPR[Create Draft PR] + HasPR -->|Yes| WaitAutoChecks[**Wait for Automated
Checks to Finish**] + CreateDraftPR --> WaitAutoChecks + + WaitAutoChecks --> CheckAutoTools{All **CodeRabbit and
SonarQube** Comments
Resolved?} + CheckAutoTools -->|No| MarkDraft[Make Sure PR Is **Marked as a Draft**] + CheckAutoTools -->|Yes| MarkReady[Mark PR as Ready
for Review] + MarkDraft --> WP2[ ] + WP2 -.-> ResolveIssue + + MarkReady --> RequestReview[Request Review from
Project Maintainers] + RequestReview --> WaitMaintainer[Wait for Maintainers'
Comments] + + WaitMaintainer --> HasMaintainerComments{**Maintainers' Comments
Resolved**?} + HasMaintainerComments -->|No| MarkDraft + HasMaintainerComments -->|Yes| CheckCI{**CI/CD
Passing?**} + + CheckCI -->|Yes| ReadyMerge([PR Ready for Merge]) + CheckCI -->|No| MarkDraft + + style Start fill:#4caf50,stroke:#2e7d32,stroke-width:2px,color:#ffffff + style ReadyMerge fill:#4caf50,stroke:#2e7d32,stroke-width:2px,color:#ffffff + style ResolveIssue fill:#ff9800,stroke:#f57c00,stroke-width:2px,color:#000000 + style RunChecks fill:#2196f3,stroke:#1565c0,stroke-width:2px,color:#ffffff + style CheckAutoTools fill:#2196f3,stroke:#1565c0,stroke-width:2px,color:#ffffff + style HasMaintainerComments fill:#2196f3,stroke:#1565c0,stroke-width:2px,color:#ffffff + style CheckCI fill:#2196f3,stroke:#1565c0,stroke-width:2px,color:#ffffff + style MarkDraft fill:#ff9800,stroke:#f57c00,stroke-width:2px,color:#000000 + style MarkReady fill:#4caf50,stroke:#2e7d32,stroke-width:2px,color:#ffffff + style CreateDraftPR fill:#ff9800,stroke:#f57c00,stroke-width:2px,color:#000000 + style WP1 fill:transparent,stroke:transparent,color:transparent,width:0px,height:0px + style WP2 fill:transparent,stroke:transparent,color:transparent,width:0px,height:0px + + linkStyle 0 stroke:#4caf50,stroke-width:2px + linkStyle 1 stroke:#4caf50,stroke-width:2px + linkStyle 2 stroke:#4caf50,stroke-width:2px + linkStyle 3 stroke:#4caf50,stroke-width:2px + linkStyle 4 stroke:#4caf50,stroke-width:2px + linkStyle 5 stroke:#4caf50,stroke-width:2px + linkStyle 6 stroke:#f44336,stroke-width:2px + linkStyle 7 stroke:#4caf50,stroke-width:2px + linkStyle 8 stroke:#f44336,stroke-width:2px + linkStyle 9 stroke:#4caf50,stroke-width:2px + linkStyle 10 stroke:#9e9e9e,stroke-width:2px + linkStyle 11 stroke:#4caf50,stroke-width:2px + linkStyle 12 stroke:#4caf50,stroke-width:2px + linkStyle 13 stroke:#4caf50,stroke-width:2px + linkStyle 14 stroke:#f44336,stroke-width:2px + linkStyle 15 stroke:#4caf50,stroke-width:2px + linkStyle 16 stroke:#f44336,stroke-width:2px + linkStyle 17 stroke:#f44336,stroke-width:2px + linkStyle 18 stroke:#4caf50,stroke-width:2px + linkStyle 19 stroke:#4caf50,stroke-width:2px + linkStyle 20 stroke:#4caf50,stroke-width:2px + linkStyle 21 stroke:#f44336,stroke-width:2px + linkStyle 22 stroke:#4caf50,stroke-width:2px + linkStyle 23 stroke:#4caf50,stroke-width:2px + linkStyle 24 stroke:#f44336,stroke-width:2px +``` + ### 1. Find Something to Work On - Check the **Issues** tab for open issues: [https://github.com/owasp/nest/issues](https://github.com/owasp/nest/issues) diff --git a/Makefile b/Makefile index 8ddfd23737..2d29a5c668 100644 --- a/Makefile +++ b/Makefile @@ -50,9 +50,9 @@ prune: @docker volume prune -f run: - @COMPOSE_BAKE=true DOCKER_BUILDKIT=1 \ - docker compose -f docker-compose/local.yaml --project-name nest-local build && \ - docker compose -f docker-compose/local.yaml --project-name nest-local up --remove-orphans + @DOCKER_BUILDKIT=1 \ + docker compose -f docker-compose/local/compose.yaml --project-name nest-local build && \ + docker compose -f docker-compose/local/compose.yaml --project-name nest-local up --remove-orphans test: \ test-nest-app diff --git a/README.md b/README.md index 892cda068f..d0c33c15e2 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,5 @@
- - - OWASP Logo - - -# [OWASP Nest](https://nest.owasp.org/) - [![OWASP](https://img.shields.io/badge/Lab-blue?&label=owasp%20level&style=for-the-badge)](https://owasp.org/www-project-nest/) [![OWASP](https://img.shields.io/badge/Code-blue?label=OWASP%20Type&style=for-the-badge)](https://owasp.org/www-project-nest/) [![project-nest](https://img.shields.io/badge/%23project--nest-blue?label=OWASP%20Slack&logoColor=white&style=for-the-badge)](https://owasp.slack.com/messages/project-nest) [![License](https://img.shields.io/github/license/owasp/nest?color=blue&label=License&style=for-the-badge)](https://github.com/OWASP/Nest/blob/main/LICENSE) [![Last Commit](https://img.shields.io/github/last-commit/owasp/nest/main?color=blue&style=for-the-badge&label=Last%20commit)](https://github.com/OWASP/Nest/commits/main/) [![Contributors](https://img.shields.io/github/contributors/owasp/nest?style=for-the-badge&label=Contributors&color=blue)](https://github.com/OWASP/Nest/graphs/contributors) @@ -21,9 +14,16 @@ [![CREATED](https://img.shields.io/badge/created-aug,%202024-blue?style=for-the-badge)](https://github.com/OWASP/Nest/commit/2a213c2efcfc2f8889c2f1d330da0d2e6f649fc1) + + + OWASP Nest Logo + + +# [OWASP Nest](https://nest.owasp.org/) +
-**OWASP Nest** is a comprehensive platform designed to enhance collaboration and contribution within the OWASP community. The application serves as a central hub for exploring OWASP projects and ways to contribute to them, empowering contributors to find opportunities that align with their interests and expertise. +**OWASP Nest** is a comprehensive, community-first platform built to enhance collaboration and contribution across the OWASP community. The application serves as a central hub for exploring OWASP projects and ways to contribute to them, empowering contributors to find opportunities that align with their interests and expertise. Key features of the platform include: diff --git a/backend/Makefile b/backend/Makefile index 48a3269e62..6deb16130c 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -19,6 +19,7 @@ clean-backend-docker: @docker container rm -f nest-worker >/dev/null 2>&1 || true @docker image rm -f nest-local-backend >/dev/null 2>&1 || true @docker volume rm -f nest-local_backend-venv >/dev/null 2>&1 || true + @docker volume rm -f nest-local_cache-data >/dev/null 2>&1 || true create-superuser: @CMD="python manage.py createsuperuser" $(MAKE) exec-backend-command-it @@ -126,7 +127,7 @@ sync-data: \ test-backend: @DOCKER_BUILDKIT=1 docker build \ --cache-from nest-test-backend \ - -f backend/docker/Dockerfile.test backend \ + -f docker/backend/Dockerfile.test backend \ -t nest-test-backend @docker run \ -e DJANGO_SETTINGS_MODULE=settings.test \ @@ -145,6 +146,7 @@ update-data: \ github-update-related-organizations \ github-update-users \ owasp-aggregate-projects \ + owasp-aggregate-contributions \ owasp-update-events \ owasp-sync-posts \ owasp-update-sponsors \ diff --git a/backend/apps/ai/common/base/ai_command.py b/backend/apps/ai/common/base/ai_command.py index 30337603e6..dc9996c906 100644 --- a/backend/apps/ai/common/base/ai_command.py +++ b/backend/apps/ai/common/base/ai_command.py @@ -18,9 +18,9 @@ class BaseAICommand(BaseCommand): def __init__(self, *args, **kwargs): """Initialize the AI command with OpenAI client placeholder.""" super().__init__(*args, **kwargs) - self.openai_client: openai.OpenAI | None = None self.entity_name = self.model_class.__name__.lower() self.entity_name_plural = self.model_class.__name__.lower() + "s" + self.openai_client = None def source_name(self) -> str: """Return the source name for context creation. Override if different from default.""" diff --git a/backend/apps/api/rest/v0/__init__.py b/backend/apps/api/rest/v0/__init__.py index 63d8277835..93acf51a33 100644 --- a/backend/apps/api/rest/v0/__init__.py +++ b/backend/apps/api/rest/v0/__init__.py @@ -1,5 +1,7 @@ """OWASP REST API v0.""" +from typing import Any + from django.conf import settings from ninja import NinjaAPI, Swagger from ninja.pagination import RouterPaginated @@ -42,20 +44,18 @@ "docs": Swagger(settings={"persistAuthorization": True}), "throttle": [AuthRateThrottle("10/s")], "title": "OWASP Nest", - "version": "0.3.1", + "version": "0.3.6", } -api_settings_customization = {} -if settings.IS_LOCAL_ENVIRONMENT: +api_settings_customization: dict[str, Any] = {} +if settings.IS_PRODUCTION_ENVIRONMENT: api_settings_customization = { - "auth": None, "servers": [ { - "description": "Local", + "description": "Production", "url": settings.SITE_URL, } ], - "throttle": [], } elif settings.IS_STAGING_ENVIRONMENT: api_settings_customization = { @@ -66,14 +66,16 @@ } ], } -elif settings.IS_PRODUCTION_ENVIRONMENT: +elif settings.IS_LOCAL_ENVIRONMENT: api_settings_customization = { + "auth": None, "servers": [ { - "description": "Production", + "description": "Local", "url": settings.SITE_URL, } ], + "throttle": [], } api = NinjaAPI(**{**api_settings, **api_settings_customization}) diff --git a/backend/apps/api/rest/v0/chapter.py b/backend/apps/api/rest/v0/chapter.py index 863a60784d..be7d9290f1 100644 --- a/backend/apps/api/rest/v0/chapter.py +++ b/backend/apps/api/rest/v0/chapter.py @@ -11,7 +11,7 @@ from ninja.responses import Response from apps.api.decorators.cache import cache_response -from apps.api.rest.v0.common import LocationFilter +from apps.api.rest.v0.common import Leader, LocationFilter from apps.owasp.models.chapter import Chapter as ChapterModel router = RouterPaginated(tags=["Chapters"]) @@ -41,8 +41,17 @@ class ChapterDetail(ChapterBase): """Detail schema for Chapter (used in single item endpoints).""" country: str + leaders: list[Leader] region: str + @staticmethod + def resolve_leaders(obj): + """Resolve leaders.""" + return [ + Leader(key=leader.member.login if leader.member else None, name=leader.member_name) + for leader in obj.entity_leaders + ] + class ChapterError(Schema): """Chapter error schema.""" diff --git a/backend/apps/api/rest/v0/common.py b/backend/apps/api/rest/v0/common.py index 74d21efe25..123207e086 100644 --- a/backend/apps/api/rest/v0/common.py +++ b/backend/apps/api/rest/v0/common.py @@ -1,6 +1,13 @@ """Common schemas and filters for the API.""" -from ninja import Field, FilterSchema +from ninja import Field, FilterSchema, Schema + + +class Leader(Schema): + """Schema for Leader.""" + + key: str | None = None + name: str class LocationFilter(FilterSchema): diff --git a/backend/apps/api/rest/v0/project.py b/backend/apps/api/rest/v0/project.py index 8583ced431..11d676321e 100644 --- a/backend/apps/api/rest/v0/project.py +++ b/backend/apps/api/rest/v0/project.py @@ -11,6 +11,7 @@ from ninja.responses import Response from apps.api.decorators.cache import cache_response +from apps.api.rest.v0.common import Leader from apps.owasp.models.enums.project import ProjectLevel from apps.owasp.models.project import Project as ProjectModel @@ -40,6 +41,15 @@ class ProjectDetail(ProjectBase): """Detail schema for Project (used in single item endpoints).""" description: str + leaders: list[Leader] + + @staticmethod + def resolve_leaders(obj): + """Resolve leaders.""" + return [ + Leader(key=leader.member.login if leader.member else None, name=leader.member_name) + for leader in obj.entity_leaders + ] class ProjectError(Schema): diff --git a/backend/apps/api/rest/v0/snapshot.py b/backend/apps/api/rest/v0/snapshot.py index 0825f01107..50ef169d6c 100644 --- a/backend/apps/api/rest/v0/snapshot.py +++ b/backend/apps/api/rest/v0/snapshot.py @@ -23,6 +23,8 @@ from apps.owasp.models.project import Project as ProjectModel from apps.owasp.models.snapshot import Snapshot as SnapshotModel +ORDERING_FIELD_DESCRIPTION = "Ordering field" + router = RouterPaginated(tags=["Community"]) @@ -114,7 +116,7 @@ def list_snapshots( ] | None = Query( None, - description="Ordering field", + description=ORDERING_FIELD_DESCRIPTION, ), ) -> list[Snapshot]: """Get all snapshots.""" @@ -160,7 +162,7 @@ def list_snapshot_chapters( snapshot_id: str = Path(example="2025-02"), ordering: Literal["created_at", "-created_at", "updated_at", "-updated_at"] | None = Query( None, - description="Ordering field", + description=ORDERING_FIELD_DESCRIPTION, ), ) -> list[Chapter]: """Get new chapters in snapshot.""" @@ -185,7 +187,7 @@ def list_snapshot_issues( snapshot_id: str = Path(example="2025-02"), ordering: Literal["created_at", "-created_at", "updated_at", "-updated_at"] | None = Query( None, - description="Ordering field", + description=ORDERING_FIELD_DESCRIPTION, ), ) -> list[SnapshotIssue]: """Get new issues in snapshot.""" @@ -212,7 +214,7 @@ def list_snapshot_members( snapshot_id: str = Path(example="2025-02"), ordering: Literal["created_at", "-created_at", "updated_at", "-updated_at"] | None = Query( None, - description="Ordering field", + description=ORDERING_FIELD_DESCRIPTION, ), ) -> list[Member]: """Get new members in snapshot.""" @@ -237,7 +239,7 @@ def list_snapshot_projects( snapshot_id: str = Path(example="2025-02"), ordering: Literal["created_at", "-created_at", "updated_at", "-updated_at"] | None = Query( None, - description="Ordering field", + description=ORDERING_FIELD_DESCRIPTION, ), ) -> list[Project]: """Get new projects in snapshot.""" @@ -262,7 +264,7 @@ def list_snapshot_releases( snapshot_id: str = Path(example="2025-02"), ordering: Literal["created_at", "-created_at", "published_at", "-published_at"] | None = Query( None, - description="Ordering field", + description=ORDERING_FIELD_DESCRIPTION, ), ) -> list[SnapshotRelease]: """Get new releases in snapshot.""" diff --git a/backend/apps/common/extensions.py b/backend/apps/common/extensions.py new file mode 100644 index 0000000000..21e9373950 --- /dev/null +++ b/backend/apps/common/extensions.py @@ -0,0 +1,67 @@ +"""Strawberry extensions.""" + +import hashlib +import json +from functools import lru_cache + +from django.conf import settings +from django.core.cache import cache +from strawberry.extensions import SchemaExtension +from strawberry.permission import PermissionExtension +from strawberry.schema import Schema +from strawberry.utils.str_converters import to_camel_case + + +@lru_cache(maxsize=1) +def get_protected_fields(schema: Schema) -> tuple[str, ...]: + """Get protected field names. + + Args: + schema (Schema): The GraphQL schema. + + Returns: + tuple[str, ...]: Tuple of protected field names in camelCase. + + """ + query_type = schema.schema_converter.type_map.get("Query") + fields = getattr(getattr(query_type, "definition", None), "fields", ()) + return tuple( + to_camel_case(field.name) + for field in fields + if any(isinstance(ext, PermissionExtension) for ext in field.extensions) + ) + + +class CacheExtension(SchemaExtension): + """CacheExtension class.""" + + def generate_key(self, field_name: str, field_args: dict) -> str: + """Generate a unique cache key for a query. + + Args: + field_name (str): The GraphQL field name. + field_args (dict): The field's arguments. + + Returns: + str: The unique cache key. + + """ + key = f"{field_name}:{json.dumps(field_args, sort_keys=True)}" + return ( + f"{settings.GRAPHQL_RESOLVER_CACHE_PREFIX}-{hashlib.sha256(key.encode()).hexdigest()}" + ) + + def resolve(self, _next, root, info, *args, **kwargs): + """Wrap the resolver to provide caching.""" + if ( + info.field_name.startswith("__") + or info.parent_type.name != "Query" + or info.field_name in get_protected_fields(self.execution_context.schema) + ): + return _next(root, info, *args, **kwargs) + + return cache.get_or_set( + self.generate_key(info.field_name, kwargs), + lambda: _next(root, info, *args, **kwargs), + settings.GRAPHQL_RESOLVER_CACHE_TIME_SECONDS, + ) diff --git a/backend/apps/common/management/commands/purge_data.py b/backend/apps/common/management/commands/purge_data.py index 32ed671a6f..54f4252aae 100644 --- a/backend/apps/common/management/commands/purge_data.py +++ b/backend/apps/common/management/commands/purge_data.py @@ -1,7 +1,5 @@ """A command to purge OWASP Nest data.""" -# ruff: noqa: SLF001 https://docs.astral.sh/ruff/rules/private-member-access/ - from django.apps import apps from django.core.management.base import BaseCommand from django.db import connection diff --git a/backend/apps/common/search/query_parser.py b/backend/apps/common/search/query_parser.py index 4ff8817c00..c684512cc4 100644 --- a/backend/apps/common/search/query_parser.py +++ b/backend/apps/common/search/query_parser.py @@ -11,7 +11,7 @@ Word, ZeroOrMore, alphanums, - oneOf, + one_of, ) from pyparsing import ( Optional as PyparsingOptional, @@ -121,10 +121,10 @@ class QueryParser: # Grammar definitions (class-level constants). FIELD_NAME = Regex(r"[a-z0-9_]+") - QUOTED_VALUE = QuotedString(quoteChar='"', escChar="\\", unquoteResults=False) + QUOTED_VALUE = QuotedString(quote_char='"', esc_char="\\", unquote_results=False) UNQUOTED_VALUE = Word(alphanums + '+-.<>=/_"') FIELD_VALUE = QUOTED_VALUE | UNQUOTED_VALUE - COMPARISON_OPERATOR = PyparsingOptional(oneOf(">= <= > < ="), default="=") + COMPARISON_OPERATOR = PyparsingOptional(one_of(">= <= > < ="), default="=") COMPARISON_PATTERN = Group(COMPARISON_OPERATOR + UNQUOTED_VALUE) DATE_PATTERN = Regex(r"\d{4}-\d{2}-\d{2}") | Regex(r"\d{8}") # YYYY-MM-DD or YYYYMMDD format. BOOLEAN_TRUE_VALUES = {"true", "1", "yes", "on"} @@ -280,7 +280,7 @@ def _handle_unknown_field(self, field: str) -> None: def _is_field_name(field_name: str) -> bool: """Check if a field name is valid.""" try: - QueryParser.FIELD_NAME.parseString(field_name, parseAll=True) + QueryParser.FIELD_NAME.parse_string(field_name, parse_all=True) except ParseException: return False @@ -378,7 +378,7 @@ def _split_tokens(query: str) -> list[str]: ) ) try: - result = parser.parseString(query, parseAll=True) + result = parser.parse_string(query, parse_all=True) except ParseException as e: raise QueryParserError( message=f"Failed to tokenize query: {e!s}", @@ -457,8 +457,8 @@ def _parse_comparison_pattern(value: str) -> tuple[str, str]: """ try: - match = QueryParser.COMPARISON_PATTERN.parseString( - QueryParser._remove_quotes(value), parseAll=True + match = QueryParser.COMPARISON_PATTERN.parse_string( + QueryParser._remove_quotes(value), parse_all=True ) except ParseException as e: raise QueryParserError( @@ -483,7 +483,7 @@ def _parse_string_value(value: str) -> str: """ try: - return QueryParser.FIELD_VALUE.parseString(value, parseAll=True)[0] + return QueryParser.FIELD_VALUE.parse_string(value, parse_all=True)[0] except ParseException as e: raise QueryParserError( message=f"Invalid string value: {e!s}", error_type="STRING_VALUE_ERROR" @@ -568,7 +568,7 @@ def _parse_date_value(value: str) -> tuple[str, str]: operator, clean_value = QueryParser._parse_comparison_pattern( QueryParser._remove_quotes(value) ) - result = QueryParser.DATE_PATTERN.parseString(clean_value, parseAll=True) + result = QueryParser.DATE_PATTERN.parse_string(clean_value, parse_all=True) except ParseException as e: raise QueryParserError( message=f"Invalid date value: {e!s}", error_type="DATE_VALUE_ERROR" diff --git a/backend/apps/core/utils/index.py b/backend/apps/core/utils/index.py index 40637c91f4..6ea473383b 100644 --- a/backend/apps/core/utils/index.py +++ b/backend/apps/core/utils/index.py @@ -51,23 +51,28 @@ def unregister_indexes(self) -> None: unregister(model) -def deep_camelize(obj) -> dict | list: +def deep_camelize(obj) -> dict | list | None: """Deep camelize. Args: obj: The object to camelize. Returns: - The camelize object. + The camelize object or None. """ + if not obj: + return obj + if isinstance(obj, dict): return { convert_to_camel_case(key.removeprefix("idx_")): deep_camelize(value) for key, value in obj.items() } + if isinstance(obj, list): return [deep_camelize(item) for item in obj] + return obj diff --git a/backend/apps/github/index/registry/repository.py b/backend/apps/github/index/registry/repository.py index 8f28c88c21..beb8369686 100644 --- a/backend/apps/github/index/registry/repository.py +++ b/backend/apps/github/index/registry/repository.py @@ -72,6 +72,7 @@ def get_entities(self) -> QuerySet: """ return Repository.objects.filter( is_template=False, + organization__isnull=False, ).prefetch_related( "repositorycontributor_set", ) diff --git a/backend/apps/github/models/mixins/release.py b/backend/apps/github/models/mixins/release.py index 750e1dddc2..274d4c7540 100644 --- a/backend/apps/github/models/mixins/release.py +++ b/backend/apps/github/models/mixins/release.py @@ -2,7 +2,9 @@ from __future__ import annotations -from django.utils.text import Truncator +from apps.common.utils import truncate + +DESCRIPTION_MAX_LENGTH = 1000 class ReleaseIndexMixin: @@ -37,7 +39,7 @@ def idx_created_at(self) -> float: @property def idx_description(self) -> str: """Return description for indexing.""" - return Truncator(self.description).chars(1000, truncate="...") + return truncate(self.description, limit=DESCRIPTION_MAX_LENGTH) @property def idx_is_pre_release(self) -> bool: diff --git a/backend/apps/mentorship/api/internal/queries/mentorship.py b/backend/apps/mentorship/api/internal/queries/mentorship.py index 5f96abb035..cab13ed8d5 100644 --- a/backend/apps/mentorship/api/internal/queries/mentorship.py +++ b/backend/apps/mentorship/api/internal/queries/mentorship.py @@ -48,15 +48,13 @@ def is_mentor(self, login: str) -> bool: return Mentor.objects.filter(github_user=github_user).exists() @strawberry.field - def get_mentee_details( - self, program_key: str, module_key: str, mentee_handle: str - ) -> MenteeNode: + def get_mentee_details(self, program_key: str, module_key: str, mentee_key: str) -> MenteeNode: """Get detailed information about a mentee in a specific module.""" try: module = Module.objects.only("id").get(key=module_key, program__key=program_key) github_user = GithubUser.objects.only("login", "name", "avatar_url", "bio").get( - login=mentee_handle + login=mentee_key ) mentee = Mentee.objects.only("id", "experience_level", "domains", "tags").get( @@ -65,7 +63,7 @@ def get_mentee_details( is_enrolled = MenteeModule.objects.filter(mentee=mentee, module=module).exists() if not is_enrolled: - message = f"Mentee {mentee_handle} is not enrolled in module {module_key}" + message = f"Mentee {mentee_key} is not enrolled in module {module_key}" raise ObjectDoesNotExist(message) return MenteeNode( @@ -88,7 +86,7 @@ def get_mentee_module_issues( self, program_key: str, module_key: str, - mentee_handle: str, + mentee_key: str, limit: int = 20, offset: int = 0, ) -> list[IssueNode]: @@ -96,13 +94,13 @@ def get_mentee_module_issues( try: module = Module.objects.only("id").get(key=module_key, program__key=program_key) - github_user = GithubUser.objects.only("id").get(login=mentee_handle) + github_user = GithubUser.objects.only("id").get(login=mentee_key) mentee = Mentee.objects.only("id").get(github_user=github_user) is_enrolled = MenteeModule.objects.filter(mentee=mentee, module=module).exists() if not is_enrolled: - message = f"Mentee {mentee_handle} is not enrolled in module {module_key}" + message = f"Mentee {mentee_key} is not enrolled in module {module_key}" raise ObjectDoesNotExist(message) issues_qs = ( diff --git a/backend/apps/mentorship/management/commands/mentorship_update_comments.py b/backend/apps/mentorship/management/commands/mentorship_update_comments.py index 229a817961..50ad158b53 100644 --- a/backend/apps/mentorship/management/commands/mentorship_update_comments.py +++ b/backend/apps/mentorship/management/commands/mentorship_update_comments.py @@ -40,7 +40,15 @@ def process_mentorship_modules(self) -> None: self.stdout.write(self.style.SUCCESS("Starting mentorship issue processing job...")) - for module in published_modules: + modules_with_labels = published_modules.exclude(labels=[]).select_related("project") + + if not modules_with_labels.exists(): + self.stdout.write( + self.style.WARNING("No published mentorship modules with labels found. Exiting.") + ) + return + + for module in modules_with_labels: self.stdout.write(f"\nProcessing module: {module.name}...") self.process_module(module) diff --git a/backend/apps/nest/management/commands/nest_update_badges.py b/backend/apps/nest/management/commands/nest_update_badges.py index 209313134c..485469c90d 100644 --- a/backend/apps/nest/management/commands/nest_update_badges.py +++ b/backend/apps/nest/management/commands/nest_update_badges.py @@ -2,15 +2,19 @@ import logging +from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand from apps.github.models.user import User from apps.nest.models.badge import Badge from apps.nest.models.user_badge import UserBadge +from apps.owasp.models.entity_member import EntityMember +from apps.owasp.models.project import Project logger = logging.getLogger(__name__) OWASP_STAFF_BADGE_NAME = "OWASP Staff" +OWASP_PROJECT_LEADER_BADGE_NAME = "OWASP Project Leader" class Command(BaseCommand): @@ -22,6 +26,7 @@ def handle(self, *args, **options): """Execute the command.""" self.stdout.write("Syncing user badges...") self.update_owasp_staff_badge() + self.update_owasp_project_leader_badge() self.stdout.write(self.style.SUCCESS("User badges sync completed")) def update_owasp_staff_badge(self): @@ -73,3 +78,76 @@ def update_owasp_staff_badge(self): logger.info("Removed '%s' badge from %s users", OWASP_STAFF_BADGE_NAME, removed_count) self.stdout.write(f"Removed badge from {removed_count} non-employees") + + def update_owasp_project_leader_badge(self): + """Sync OWASP Project Leader badge for users.""" + badge, created = Badge.objects.get_or_create( + name=OWASP_PROJECT_LEADER_BADGE_NAME, + defaults={ + "description": "Official OWASP Project Leader", + "css_class": "fa-user-shield", + "weight": 90, + }, + ) + + if created: + logger.info("Created '%s' badge", OWASP_PROJECT_LEADER_BADGE_NAME) + self.stdout.write(f"Created badge: {badge.name}") + + project_leaders_without_badge = ( + User.objects.filter( + id__in=EntityMember.objects.filter( + entity_type=ContentType.objects.get_for_model(Project), + role=EntityMember.Role.LEADER, + is_active=True, + is_reviewed=True, + member__isnull=False, + ).values_list("member_id", flat=True), + ) + .distinct() + .exclude( + user_badges__badge=badge, + ) + ) + + count = project_leaders_without_badge.count() + + if count: + for user in project_leaders_without_badge: + user_badge, created = UserBadge.objects.get_or_create( + user=user, + badge=badge, + ) + if not user_badge.is_active: + user_badge.is_active = True + user_badge.save(update_fields=["is_active"]) + + logger.info("Added '%s' badge to %s users", OWASP_PROJECT_LEADER_BADGE_NAME, count) + self.stdout.write(f"Added badge to {count} project leaders") + + # Remove badge from users who are no longer project leaders. + current_leader_ids = ( + EntityMember.objects.filter( + entity_type=ContentType.objects.get_for_model(Project), + role=EntityMember.Role.LEADER, + is_active=True, + is_reviewed=True, + member__isnull=False, + ) + .values_list("member_id", flat=True) + .distinct() + ) + + non_leaders = UserBadge.objects.filter( + badge=badge, + is_active=True, + ).exclude(user_id__in=current_leader_ids) + removed_count = non_leaders.count() + + if removed_count: + non_leaders.update(is_active=False) + + logger.info( + "Removed '%s' badge from %s users", OWASP_PROJECT_LEADER_BADGE_NAME, removed_count + ) + self.stdout.write(f"Removed badge from {removed_count} non-project leaders") diff --git a/backend/apps/owasp/Makefile b/backend/apps/owasp/Makefile index 4febcd2572..e88a5b7325 100644 --- a/backend/apps/owasp/Makefile +++ b/backend/apps/owasp/Makefile @@ -2,6 +2,11 @@ owasp-aggregate-projects: @echo "Aggregating OWASP projects" @CMD="python manage.py owasp_aggregate_projects" $(MAKE) exec-backend-command +owasp-aggregate-contributions: + @echo "Aggregating OWASP contributions" + @CMD="python manage.py owasp_aggregate_contributions --entity-type chapter" $(MAKE) exec-backend-command + @CMD="python manage.py owasp_aggregate_contributions --entity-type project" $(MAKE) exec-backend-command + owasp-create-project-metadata-file: @echo "Generating metadata" @CMD="python manage.py owasp_create_project_metadata_file $(entity_key)" $(MAKE) exec-backend-command diff --git a/backend/apps/owasp/api/internal/nodes/chapter.py b/backend/apps/owasp/api/internal/nodes/chapter.py index 23eb7a7fb6..6e5c29bfb4 100644 --- a/backend/apps/owasp/api/internal/nodes/chapter.py +++ b/backend/apps/owasp/api/internal/nodes/chapter.py @@ -3,6 +3,7 @@ import strawberry import strawberry_django +from apps.core.utils.index import deep_camelize from apps.owasp.api.internal.nodes.common import GenericEntityNode from apps.owasp.models.chapter import Chapter @@ -18,6 +19,7 @@ class GeoLocationType: @strawberry_django.type( Chapter, fields=[ + "contribution_data", "country", "is_active", "meetup_group", @@ -31,6 +33,11 @@ class GeoLocationType: class ChapterNode(GenericEntityNode): """Chapter node.""" + @strawberry.field + def contribution_stats(self) -> strawberry.scalars.JSON | None: + """Resolve contribution stats with camelCase keys.""" + return deep_camelize(self.contribution_stats) + @strawberry.field def created_at(self) -> float: """Resolve created at.""" diff --git a/backend/apps/owasp/api/internal/nodes/project.py b/backend/apps/owasp/api/internal/nodes/project.py index 1b881d44ef..f040a3e5ff 100644 --- a/backend/apps/owasp/api/internal/nodes/project.py +++ b/backend/apps/owasp/api/internal/nodes/project.py @@ -3,6 +3,7 @@ import strawberry import strawberry_django +from apps.core.utils.index import deep_camelize 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 @@ -23,6 +24,7 @@ @strawberry_django.type( Project, fields=[ + "contribution_data", "contributors_count", "created_at", "forks_count", @@ -39,6 +41,11 @@ class ProjectNode(GenericEntityNode): """Project node.""" + @strawberry.field + def contribution_stats(self) -> strawberry.scalars.JSON | None: + """Resolve contribution stats with camelCase keys.""" + return deep_camelize(self.contribution_stats) + @strawberry.field def health_metrics_list(self, limit: int = 30) -> list[ProjectHealthMetricsNode]: """Resolve project health metrics.""" @@ -99,7 +106,12 @@ def recent_releases(self) -> list[ReleaseNode]: @strawberry.field def repositories(self) -> list[RepositoryNode]: """Resolve repositories.""" - return self.repositories.order_by("-pushed_at", "-updated_at") + return self.repositories.filter( + organization__isnull=False, + ).order_by( + "-pushed_at", + "-updated_at", + ) @strawberry.field def repositories_count(self) -> int: diff --git a/backend/apps/owasp/index/registry/project.py b/backend/apps/owasp/index/registry/project.py index 6c0aad786c..aba57975f2 100644 --- a/backend/apps/owasp/index/registry/project.py +++ b/backend/apps/owasp/index/registry/project.py @@ -105,7 +105,11 @@ def update_synonyms(): def get_entities(self): """Get entities for indexing.""" - return Project.objects.prefetch_related( - "organizations", - "repositories", + return ( + Project.objects.prefetch_related( + "organizations", + "repositories", + ) + .filter(organizations__isnull=False) + .distinct() ) diff --git a/backend/apps/owasp/management/commands/owasp_aggregate_contributions.py b/backend/apps/owasp/management/commands/owasp_aggregate_contributions.py new file mode 100644 index 0000000000..9202544fe3 --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_aggregate_contributions.py @@ -0,0 +1,264 @@ +"""Management command to aggregate contributions for chapters and projects.""" + +from datetime import datetime, timedelta + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from apps.github.models.commit import Commit +from apps.github.models.issue import Issue +from apps.github.models.pull_request import PullRequest +from apps.github.models.release import Release +from apps.owasp.models.chapter import Chapter +from apps.owasp.models.project import Project + + +class Command(BaseCommand): + """Aggregate contribution data for chapters and projects.""" + + help = "Aggregate contributions (commits, issues, PRs, releases) for chapters and projects" + + def add_arguments(self, parser): + """Add command arguments.""" + parser.add_argument( + "--entity-type", + choices=["chapter", "project"], + help="Entity type to aggregate: chapter, project", + required=True, + type=str, + ) + parser.add_argument( + "--days", + default=365, + help="Number of days to look back for contributions (default: 365)", + type=int, + ) + parser.add_argument( + "--key", + help="Specific chapter or project key to aggregate", + type=str, + ) + parser.add_argument( + "--offset", + default=0, + help="Skip the first N entities", + type=int, + ) + + def _aggregate_contribution_dates( + self, + queryset, + date_field: str, + contribution_map: dict[str, int], + ) -> None: + """Aggregate contribution dates from a queryset into the contribution map. + + Args: + queryset: Django queryset to aggregate + date_field: Name of the date field to aggregate on + contribution_map: Dictionary to update with counts + + """ + for date_value in queryset.values_list(date_field, flat=True): + if not date_value: + continue + + date_key = date_value.date().isoformat() + contribution_map[date_key] = contribution_map.get(date_key, 0) + 1 + + def _get_repository_ids(self, entity): + """Extract repository IDs from chapter or project.""" + repository_ids: set[int] = set() + + # Handle single owasp_repository. + if hasattr(entity, "owasp_repository") and entity.owasp_repository: + repository_ids.add(entity.owasp_repository.id) + + # Handle multiple repositories (for projects). + if hasattr(entity, "repositories"): + repository_ids.update([r.id for r in entity.repositories.all()]) + + return list(repository_ids) + + def aggregate_contributions(self, entity, start_date: datetime) -> dict[str, int]: + """Aggregate contributions for a chapter or project. + + Args: + entity: Chapter or Project instance + start_date: Start date for aggregation + + Returns: + Dictionary mapping YYYY-MM-DD to contribution count + + """ + contribution_map: dict[str, int] = {} + + repository_ids = self._get_repository_ids(entity) + if not repository_ids: + return contribution_map + + # Aggregate commits. + self._aggregate_contribution_dates( + Commit.objects.filter( + created_at__gte=start_date, + repository_id__in=repository_ids, + ), + "created_at", + contribution_map, + ) + + # Aggregate issues. + self._aggregate_contribution_dates( + Issue.objects.filter( + created_at__gte=start_date, + repository_id__in=repository_ids, + ), + "created_at", + contribution_map, + ) + + # Aggregate pull requests. + self._aggregate_contribution_dates( + PullRequest.objects.filter( + created_at__gte=start_date, + repository_id__in=repository_ids, + ), + "created_at", + contribution_map, + ) + + # Aggregate releases. + self._aggregate_contribution_dates( + Release.objects.filter( + is_draft=False, + published_at__gte=start_date, + repository_id__in=repository_ids, + ), + "published_at", + contribution_map, + ) + + return contribution_map + + def calculate_contribution_stats(self, entity, start_date: datetime) -> dict[str, int]: + """Calculate contribution statistics for a chapter or project. + + Args: + entity: Chapter or Project instance + start_date: Start date for calculation + + Returns: + Dictionary with commits, issues, pull requests, releases counts + + """ + stats = { + "commits": 0, + "issues": 0, + "pull_requests": 0, + "releases": 0, + "total": 0, + } + + repository_ids = self._get_repository_ids(entity) + if not repository_ids: + return stats + + # Count commits. + stats["commits"] = Commit.objects.filter( + created_at__gte=start_date, + repository_id__in=repository_ids, + ).count() + + # Count issues. + stats["issues"] = Issue.objects.filter( + created_at__gte=start_date, + repository_id__in=repository_ids, + ).count() + + # Count pull requests. + stats["pull_requests"] = PullRequest.objects.filter( + created_at__gte=start_date, + repository_id__in=repository_ids, + ).count() + + # Count releases. + stats["releases"] = Release.objects.filter( + is_draft=False, + published_at__gte=start_date, + repository_id__in=repository_ids, + ).count() + + stats["total"] = sum( + (stats["commits"], stats["issues"], stats["pull_requests"], stats["releases"]) + ) + + return stats + + def handle(self, *args, **options): + """Execute the command.""" + entity_type = options["entity_type"] + days = options["days"] + key = options.get("key") + offset = options["offset"] + + start_date = timezone.now() - timedelta(days=days) + + self.stdout.write( + self.style.SUCCESS( + f"Aggregating contributions since {start_date.date()} ({days} days back)", + ), + ) + + if entity_type == "chapter": + self._process_chapters(start_date, key, offset) + elif entity_type == "project": + self._process_projects(start_date, key, offset) + + self.stdout.write(self.style.SUCCESS("Done!")) + + def _process_chapters(self, start_date, key, offset): + """Process chapters for contribution aggregation.""" + queryset = Chapter.objects.filter(is_active=True).order_by("id") + + if key: + queryset = queryset.filter(key=key) + + queryset = queryset.select_related("owasp_repository") + + if offset: + queryset = queryset[offset:] + + self._process_entities(queryset, start_date, Chapter) + + def _process_projects(self, start_date, key, offset): + """Process projects for contribution aggregation.""" + queryset = ( + Project.objects.filter(is_active=True) + .order_by("id") + .select_related("owasp_repository") + .prefetch_related("repositories") + ) + + if key: + queryset = queryset.filter(key=key) + + if offset: + queryset = queryset[offset:] + + self._process_entities(queryset, start_date, Project) + + def _process_entities(self, queryset, start_date, model_class): + """Process entities (chapters or projects) for contribution aggregation.""" + entities = list(queryset) + label = model_class._meta.verbose_name_plural + total_count = len(entities) + + self.stdout.write(f"Processing {total_count} {label}...") + + for entity in entities: + entity.contribution_data = self.aggregate_contributions(entity, start_date) + entity.contribution_stats = self.calculate_contribution_stats(entity, start_date) + + if entities: + model_class.bulk_save(entities, fields=("contribution_data", "contribution_stats")) + self.stdout.write(self.style.SUCCESS(f"Updated {total_count} {label}")) diff --git a/backend/apps/owasp/management/commands/owasp_sync_board_candidates.py b/backend/apps/owasp/management/commands/owasp_sync_board_candidates.py index 5d906d5bcd..5a0546535d 100644 --- a/backend/apps/owasp/management/commands/owasp_sync_board_candidates.py +++ b/backend/apps/owasp/management/commands/owasp_sync_board_candidates.py @@ -158,7 +158,7 @@ def handle(self, *args, **options): for item in items if item.get("type") == "dir" and item.get("name", "").isdigit() ] - except (json.JSONDecodeError, KeyError, ValueError) as e: + except (KeyError, ValueError) as e: self.stderr.write(self.style.ERROR(f"Could not fetch repository structure: {e}")) return diff --git a/backend/apps/owasp/management/commands/owasp_sync_posts.py b/backend/apps/owasp/management/commands/owasp_sync_posts.py index e0fcf34efa..083aab664c 100644 --- a/backend/apps/owasp/management/commands/owasp_sync_posts.py +++ b/backend/apps/owasp/management/commands/owasp_sync_posts.py @@ -58,7 +58,10 @@ def handle(self, *args, **options) -> None: **options: Arbitrary keyword arguments. """ - # TODO(arkid15r): Add pagination support. + # The GitHub contents API has an upper limit of 1,000 files for a directory. + # The _posts directory currently contains a bit more than 100 files (as of Dec 2025). + # If you need to retrieve more files, use the Git Trees API. + # https://docs.github.com/en/rest/git/trees?apiVersion=2022-11-28 post_repository_content = get_repository_file_content( "https://api.github.com/repos/OWASP/owasp.github.io/contents/_posts" ) diff --git a/backend/apps/owasp/migrations/0066_chapter_contribution_data_project_contribution_data.py b/backend/apps/owasp/migrations/0066_chapter_contribution_data_project_contribution_data.py new file mode 100644 index 0000000000..c17d81a438 --- /dev/null +++ b/backend/apps/owasp/migrations/0066_chapter_contribution_data_project_contribution_data.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.8 on 2025-11-16 18:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0065_memberprofile_linkedin_page_id"), + ] + + operations = [ + migrations.AddField( + model_name="chapter", + name="contribution_data", + field=models.JSONField( + blank=True, + default=dict, + help_text="Daily contribution counts (YYYY-MM-DD -> count mapping)", + verbose_name="Contribution Data", + ), + ), + migrations.AddField( + model_name="project", + name="contribution_data", + field=models.JSONField( + blank=True, + default=dict, + help_text="Daily contribution counts (YYYY-MM-DD -> count mapping)", + verbose_name="Contribution Data", + ), + ), + ] diff --git a/backend/apps/owasp/migrations/0067_chapter_contribution_stats_and_more.py b/backend/apps/owasp/migrations/0067_chapter_contribution_stats_and_more.py new file mode 100644 index 0000000000..913b4b7606 --- /dev/null +++ b/backend/apps/owasp/migrations/0067_chapter_contribution_stats_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.8 on 2025-11-29 19:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0066_chapter_contribution_data_project_contribution_data"), + ] + + operations = [ + migrations.AddField( + model_name="chapter", + name="contribution_stats", + field=models.JSONField( + blank=True, + default=dict, + help_text="Detailed contribution breakdown (commits, issues, pullRequests, releases)", + verbose_name="Contribution Statistics", + ), + ), + migrations.AddField( + model_name="project", + name="contribution_stats", + field=models.JSONField( + blank=True, + default=dict, + help_text="Detailed contribution breakdown (commits, issues, pullRequests, releases)", + verbose_name="Contribution Statistics", + ), + ), + ] diff --git a/backend/apps/owasp/migrations/0068_alter_chapter_contribution_stats_and_more.py b/backend/apps/owasp/migrations/0068_alter_chapter_contribution_stats_and_more.py new file mode 100644 index 0000000000..91892dd19a --- /dev/null +++ b/backend/apps/owasp/migrations/0068_alter_chapter_contribution_stats_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 6.0 on 2025-12-10 04:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0067_chapter_contribution_stats_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="chapter", + name="contribution_stats", + field=models.JSONField( + blank=True, + default=dict, + help_text="Detailed contribution breakdown (commits, issues, pull requests, releases)", + verbose_name="Contribution Statistics", + ), + ), + migrations.AlterField( + model_name="project", + name="contribution_stats", + field=models.JSONField( + blank=True, + default=dict, + help_text="Detailed contribution breakdown (commits, issues, pull requests, releases)", + verbose_name="Contribution Statistics", + ), + ), + ] diff --git a/backend/apps/owasp/migrations/0069_alter_project_contribution_data_and_more.py b/backend/apps/owasp/migrations/0069_alter_project_contribution_data_and_more.py new file mode 100644 index 0000000000..6c2d9730ce --- /dev/null +++ b/backend/apps/owasp/migrations/0069_alter_project_contribution_data_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 6.0 on 2026-01-04 00:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0068_alter_chapter_contribution_stats_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="project", + name="contribution_data", + field=models.JSONField( + blank=True, + default=dict, + help_text="Daily contribution counts (YYYY-MM-DD -> count mapping)", + null=True, + verbose_name="Contribution Data", + ), + ), + migrations.AlterField( + model_name="project", + name="contribution_stats", + field=models.JSONField( + blank=True, + default=dict, + help_text="Detailed contribution breakdown (commits, issues, pull requests, releases)", + null=True, + verbose_name="Contribution Statistics", + ), + ), + ] diff --git a/backend/apps/owasp/models/chapter.py b/backend/apps/owasp/models/chapter.py index b777718498..fd199e12af 100644 --- a/backend/apps/owasp/models/chapter.py +++ b/backend/apps/owasp/models/chapter.py @@ -64,6 +64,19 @@ class Meta: latitude = models.FloatField(verbose_name="Latitude", blank=True, null=True) longitude = models.FloatField(verbose_name="Longitude", blank=True, null=True) + contribution_data = models.JSONField( + verbose_name="Contribution Data", + default=dict, + blank=True, + help_text="Daily contribution counts (YYYY-MM-DD -> count mapping)", + ) + contribution_stats = models.JSONField( + verbose_name="Contribution Statistics", + default=dict, + blank=True, + help_text="Detailed contribution breakdown (commits, issues, pull requests, releases)", + ) + # GRs. members = GenericRelation("owasp.EntityMember") diff --git a/backend/apps/owasp/models/common.py b/backend/apps/owasp/models/common.py index 64208aa31e..79193122f7 100644 --- a/backend/apps/owasp/models/common.py +++ b/backend/apps/owasp/models/common.py @@ -236,7 +236,7 @@ def get_metadata(self): """Get entity metadata.""" try: yaml_content = re.search( - r"^---\s*([\s\S]*?)\s*---", + r"^---\s*(.*?)\s*---", get_repository_file_content(self.index_md_url), re.DOTALL, ) diff --git a/backend/apps/owasp/models/mixins/project.py b/backend/apps/owasp/models/mixins/project.py index 515173359a..9af5a29f7c 100644 --- a/backend/apps/owasp/models/mixins/project.py +++ b/backend/apps/owasp/models/mixins/project.py @@ -8,6 +8,7 @@ from apps.github.models.repository_contributor import RepositoryContributor from apps.owasp.models.mixins.common import RepositoryBasedEntityModelMixin +DEFAULT_HEALTH_SCORE = 100 ISSUES_LIMIT = 6 RELEASES_LIMIT = 4 REPOSITORIES_LIMIT = 4 @@ -40,7 +41,7 @@ def idx_forks_count(self) -> int: def idx_health_score(self) -> float | None: """Return health score for indexing.""" # TODO(arkid15r): Enable real health score in production when ready. - return 100 if settings.IS_PRODUCTION_ENVIRONMENT else self.health_score + return DEFAULT_HEALTH_SCORE if settings.IS_PRODUCTION_ENVIRONMENT else self.health_score @property def idx_is_active(self) -> bool: diff --git a/backend/apps/owasp/models/project.py b/backend/apps/owasp/models/project.py index b377900bb7..9c3af5c1ba 100644 --- a/backend/apps/owasp/models/project.py +++ b/backend/apps/owasp/models/project.py @@ -97,6 +97,21 @@ class Meta: custom_tags = models.JSONField(verbose_name="Custom tags", default=list, blank=True) track_issues = models.BooleanField(verbose_name="Track issues", default=True) + contribution_data = models.JSONField( + verbose_name="Contribution Data", + default=dict, + blank=True, + null=True, + help_text="Daily contribution counts (YYYY-MM-DD -> count mapping)", + ) + contribution_stats = models.JSONField( + verbose_name="Contribution Statistics", + default=dict, + blank=True, + null=True, + help_text="Detailed contribution breakdown (commits, issues, pull requests, releases)", + ) + # GKs. members = GenericRelation("owasp.EntityMember") diff --git a/backend/apps/slack/common/constants.py b/backend/apps/slack/common/constants.py index 4ec05b6169..8432d1c19a 100644 --- a/backend/apps/slack/common/constants.py +++ b/backend/apps/slack/common/constants.py @@ -2,4 +2,3 @@ COMMAND_HELP = {"-h", "--help"} COMMAND_START = {"-s", "--start"} -TRUNCATION_INDICATOR = "..." diff --git a/backend/apps/slack/common/handlers/chapters.py b/backend/apps/slack/common/handlers/chapters.py index b4fcf7fc62..ec21f69095 100644 --- a/backend/apps/slack/common/handlers/chapters.py +++ b/backend/apps/slack/common/handlers/chapters.py @@ -3,12 +3,11 @@ from __future__ import annotations from django.conf import settings -from django.utils.text import Truncator +from django.template.defaultfilters import pluralize from apps.common.constants import NL -from apps.common.utils import get_absolute_url +from apps.common.utils import get_absolute_url, truncate from apps.slack.blocks import get_pagination_buttons, markdown -from apps.slack.common.constants import TRUNCATION_INDICATOR from apps.slack.common.presentation import EntityPresentation from apps.slack.constants import FEEDBACK_SHARING_INVITE from apps.slack.utils import escape @@ -75,24 +74,20 @@ def get_blocks( location = chapter["idx_suggested_location"] or chapter["idx_country"] leaders = chapter.get("idx_leaders", []) leaders_text = ( - f"_Leader{'' if len(leaders) == 1 else 's'}: {', '.join(leaders)}_{NL}" + f"_Leader{pluralize(len(leaders))}: {', '.join(leaders)}_{NL}" if leaders and presentation.include_metadata else "" ) - name = Truncator(escape(chapter["idx_name"])).chars( - presentation.name_truncation, truncate=TRUNCATION_INDICATOR - ) - summary = Truncator(chapter["idx_summary"]).chars( - presentation.summary_truncation, truncate=TRUNCATION_INDICATOR - ) + name = truncate(escape(chapter["idx_name"]), presentation.name_truncation) + summary = truncate(escape(chapter["idx_summary"]), presentation.summary_truncation) blocks.append( markdown( f"{offset + idx + 1}. <{chapter['idx_url']}|*{name}*>{NL}" f"_{location}_{NL}" f"{leaders_text}" - f"{escape(summary)}{NL}" + f"{summary}{NL}" ) ) diff --git a/backend/apps/slack/common/handlers/committees.py b/backend/apps/slack/common/handlers/committees.py index 207ef239c7..c745deb0a4 100644 --- a/backend/apps/slack/common/handlers/committees.py +++ b/backend/apps/slack/common/handlers/committees.py @@ -3,12 +3,11 @@ from __future__ import annotations from django.conf import settings -from django.utils.text import Truncator +from django.template.defaultfilters import pluralize from apps.common.constants import NL -from apps.common.utils import get_absolute_url +from apps.common.utils import get_absolute_url, truncate from apps.slack.blocks import get_pagination_buttons, markdown -from apps.slack.common.constants import TRUNCATION_INDICATOR from apps.slack.common.presentation import EntityPresentation from apps.slack.constants import FEEDBACK_SHARING_INVITE from apps.slack.utils import escape @@ -68,16 +67,12 @@ def get_blocks( ] for idx, committee in enumerate(committees): - name = Truncator(escape(committee["idx_name"])).chars( - presentation.name_truncation, truncate=TRUNCATION_INDICATOR - ) - summary = Truncator(committee["idx_summary"]).chars( - presentation.summary_truncation, truncate=TRUNCATION_INDICATOR - ) + name = truncate(escape(committee["idx_name"]), presentation.name_truncation) + summary = truncate(escape(committee["idx_summary"]), presentation.summary_truncation) leaders = committee.get("idx_leaders", []) leaders_text = ( - f"_Leader{'' if len(leaders) == 1 else 's'}: {', '.join(leaders)}_{NL}" + f"_Leader{pluralize(len(leaders))}: {', '.join(leaders)}_{NL}" if leaders and presentation.include_metadata else "" ) @@ -86,7 +81,7 @@ def get_blocks( markdown( f"{offset + idx + 1}. <{committee['idx_url']}|*{name}*>{NL}" f"{leaders_text}" - f"{escape(summary)}{NL}" + f"{summary}{NL}" ) ) diff --git a/backend/apps/slack/common/handlers/contribute.py b/backend/apps/slack/common/handlers/contribute.py index 2865e34446..3dcceccb8c 100644 --- a/backend/apps/slack/common/handlers/contribute.py +++ b/backend/apps/slack/common/handlers/contribute.py @@ -3,12 +3,10 @@ from __future__ import annotations from django.conf import settings -from django.utils.text import Truncator from apps.common.constants import NL -from apps.common.utils import get_absolute_url +from apps.common.utils import get_absolute_url, truncate from apps.slack.blocks import get_pagination_buttons, markdown -from apps.slack.common.constants import TRUNCATION_INDICATOR from apps.slack.common.presentation import EntityPresentation from apps.slack.constants import FEEDBACK_SHARING_INVITE from apps.slack.utils import escape @@ -62,14 +60,10 @@ def get_blocks( blocks = [] for idx, issue in enumerate(issues): - title = Truncator(escape(issue["idx_title"])).chars( - presentation.name_truncation, truncate=TRUNCATION_INDICATOR - ) project_name = escape(issue["idx_project_name"]) project_url = escape(issue["idx_project_url"]) - summary = Truncator(escape(issue["idx_summary"])).chars( - presentation.summary_truncation, truncate=TRUNCATION_INDICATOR - ) + summary = truncate(escape(issue["idx_summary"]), presentation.summary_truncation) + title = truncate(escape(issue["idx_title"]), presentation.name_truncation) blocks.append( markdown( diff --git a/backend/apps/slack/common/handlers/projects.py b/backend/apps/slack/common/handlers/projects.py index 31b082f0b2..2429d87c7a 100644 --- a/backend/apps/slack/common/handlers/projects.py +++ b/backend/apps/slack/common/handlers/projects.py @@ -3,12 +3,10 @@ from __future__ import annotations from django.conf import settings -from django.utils.text import Truncator from apps.common.constants import NL -from apps.common.utils import get_absolute_url, natural_date +from apps.common.utils import get_absolute_url, natural_date, truncate from apps.slack.blocks import get_pagination_buttons, markdown -from apps.slack.common.constants import TRUNCATION_INDICATOR from apps.slack.common.presentation import EntityPresentation from apps.slack.constants import FEEDBACK_SHARING_INVITE from apps.slack.utils import escape @@ -73,12 +71,8 @@ def get_blocks( ] for idx, project in enumerate(projects): - name = Truncator(escape(project["idx_name"])).chars( - presentation.name_truncation, truncate=TRUNCATION_INDICATOR - ) - summary = Truncator(project["idx_summary"]).chars( - presentation.summary_truncation, truncate=TRUNCATION_INDICATOR - ) + name = truncate(escape(project["idx_name"]), presentation.name_truncation) + summary = truncate(project["idx_summary"], presentation.summary_truncation) metadata = [] if presentation.include_metadata: diff --git a/backend/apps/slack/common/handlers/users.py b/backend/apps/slack/common/handlers/users.py index 1e89d13eac..492a0719cc 100644 --- a/backend/apps/slack/common/handlers/users.py +++ b/backend/apps/slack/common/handlers/users.py @@ -3,12 +3,10 @@ from __future__ import annotations from django.conf import settings -from django.utils.text import Truncator from apps.common.constants import NL -from apps.common.utils import get_absolute_url +from apps.common.utils import get_absolute_url, truncate from apps.slack.blocks import get_pagination_buttons, markdown -from apps.slack.common.constants import TRUNCATION_INDICATOR from apps.slack.common.presentation import EntityPresentation from apps.slack.constants import FEEDBACK_SHARING_INVITE from apps.slack.utils import escape @@ -74,16 +72,11 @@ def get_blocks( ), ] - blocks = [] for idx, user in enumerate(users): user_name_raw = user.get("idx_name") or user.get("idx_login", "") - user_name = Truncator(escape(user_name_raw)).chars( - presentation.name_truncation, truncate=TRUNCATION_INDICATOR - ) + user_name = truncate(escape(user_name_raw), presentation.name_truncation) - bio = Truncator(escape(user.get("idx_bio", "") or "")).chars( - presentation.summary_truncation, truncate=TRUNCATION_INDICATOR - ) + bio = truncate(escape(user.get("idx_bio", "") or ""), presentation.summary_truncation) location = escape(user.get("idx_location", "")) company = escape(user.get("idx_company", "")) diff --git a/backend/apps/slack/events/member_joined_channel/contribute.py b/backend/apps/slack/events/member_joined_channel/contribute.py index eb864c9642..c91ccb6f31 100644 --- a/backend/apps/slack/events/member_joined_channel/contribute.py +++ b/backend/apps/slack/events/member_joined_channel/contribute.py @@ -3,11 +3,7 @@ from pathlib import Path from apps.common.utils import convert_to_snake_case, get_absolute_url -from apps.slack.constants import ( - OWASP_CONTRIBUTE_CHANNEL_ID, - OWASP_PROJECT_NEST_CHANNEL_ID, - OWASP_SPONSORSHIP_CHANNEL_ID, -) +from apps.slack.constants import OWASP_CONTRIBUTE_CHANNEL_ID from apps.slack.events.event import EventBase @@ -61,9 +57,6 @@ def get_context(self, event: dict) -> dict: return { **super().get_context(event), "ACTIVE_PROJECTS_COUNT": Project.active_projects_count(), - "CONTRIBUTE_CHANNEL_ID": OWASP_CONTRIBUTE_CHANNEL_ID, "CONTRIBUTE_PAGE_URL": get_absolute_url("/contribute"), "OPEN_ISSUES_COUNT": Issue.open_issues_count(), - "PROJECT_NEST_CHANNEL_ID": OWASP_PROJECT_NEST_CHANNEL_ID, - "SPONSORSHIP_CHANNEL_ID": OWASP_SPONSORSHIP_CHANNEL_ID, } diff --git a/backend/apps/slack/events/member_joined_channel/gsoc.py b/backend/apps/slack/events/member_joined_channel/gsoc.py index 38f010c934..8b840e6ae2 100644 --- a/backend/apps/slack/events/member_joined_channel/gsoc.py +++ b/backend/apps/slack/events/member_joined_channel/gsoc.py @@ -6,7 +6,9 @@ from django.utils import timezone from apps.common.utils import convert_to_snake_case -from apps.slack.constants import OWASP_GSOC_CHANNEL_ID +from apps.slack.constants import ( + OWASP_GSOC_CHANNEL_ID, +) from apps.slack.events.event import EventBase logger: logging.Logger = logging.getLogger(__name__) @@ -58,7 +60,5 @@ def get_context(self, event): """ return { **super().get_context(event), - "GSOC_CHANNEL_ID": OWASP_GSOC_CHANNEL_ID, - "GSOC_CHANNEL_NAME": "gsoc", "PREVIOUS_YEAR": timezone.now().year - 1, } diff --git a/backend/apps/slack/events/member_joined_channel/project_nest.py b/backend/apps/slack/events/member_joined_channel/project_nest.py index 495d7e4549..6f09329c04 100644 --- a/backend/apps/slack/events/member_joined_channel/project_nest.py +++ b/backend/apps/slack/events/member_joined_channel/project_nest.py @@ -39,19 +39,3 @@ def ephemeral_message_template_path(self) -> Path: f"{convert_to_snake_case(self.__class__.__name__)}/" "ephemeral_message.jinja" ) - - def get_context(self, event): - """Get the template context. - - Args: - event: The Slack event - - Returns: - dict: The template context. - - """ - return { - **super().get_context(event), - "PROJECT_NEST_CHANNEL_ID": OWASP_PROJECT_NEST_CHANNEL_ID, - "PROJECT_NEST_CHANNEL_NAME": "project-nest", - } diff --git a/backend/apps/slack/management/commands/slack_sync_messages.py b/backend/apps/slack/management/commands/slack_sync_messages.py index ac1613339a..2b0c48e136 100644 --- a/backend/apps/slack/management/commands/slack_sync_messages.py +++ b/backend/apps/slack/management/commands/slack_sync_messages.py @@ -406,12 +406,9 @@ def _fetch_messages( channel=conversation.slack_channel_id, cursor=cursor, limit=batch_size, - oldest=( - latest_message.ts - if (latest_message := conversation.latest_message) - else "0" - ), + oldest=conversation.latest_message.ts if conversation.latest_message else "0", ) + self._handle_slack_response(response, "conversations_history") messages = [ diff --git a/backend/apps/slack/models/member.py b/backend/apps/slack/models/member.py index 1246648748..7f2e36f11d 100644 --- a/backend/apps/slack/models/member.py +++ b/backend/apps/slack/models/member.py @@ -63,7 +63,7 @@ def bulk_save(members, fields=None): BulkSaveModel.bulk_save(Member, members, fields=fields) @staticmethod - def update_data(member_data, workspace, *, save=True) -> None: + def update_data(member_data, workspace, *, save=True) -> "Member": """Update instance based on Slack data.""" member_id = member_data["id"] try: diff --git a/backend/apps/slack/templates/events/member_joined_channel/contribute/direct_message.jinja b/backend/apps/slack/templates/events/member_joined_channel/contribute/direct_message.jinja index 8162ee35a4..985331726c 100644 --- a/backend/apps/slack/templates/events/member_joined_channel/contribute/direct_message.jinja +++ b/backend/apps/slack/templates/events/member_joined_channel/contribute/direct_message.jinja @@ -1,4 +1,4 @@ -Hello <@{{ USER_ID }}> and welcome to <{{ CONTRIBUTE_CHANNEL_ID }}> channel!{{ NL }} +Hello <@{{ USER_ID }}> and welcome to #contribute channel!{{ NL }} We're happy to have you here as part of the OWASP community! Your eagerness to contribute is what makes our community strong. With *{{ ACTIVE_PROJECTS_COUNT }} active OWASP projects*, there are countless opportunities diff --git a/backend/apps/slack/templates/events/member_joined_channel/contribute/ephemeral_message.jinja b/backend/apps/slack/templates/events/member_joined_channel/contribute/ephemeral_message.jinja index 604e3f5cc3..c5eb3cb394 100644 --- a/backend/apps/slack/templates/events/member_joined_channel/contribute/ephemeral_message.jinja +++ b/backend/apps/slack/templates/events/member_joined_channel/contribute/ephemeral_message.jinja @@ -30,8 +30,8 @@ of the quality of the proposals and the growing impact of the OWASP Nest project :bangbang: For projects that were not accepted this year we've launched a dedicated sponsorship program to support them. We're looking for high-quality contributors to help us with those project ideas. -Feel free to join <{{ SPONSORSHIP_CHANNEL_ID }}|sponsorship> channel to learn more about it.{{ NL }} +Feel free to join #sponsorship channel to learn more about it.{{ NL }} {{ SECTION_BREAK }} -Join the effort at <{{ PROJECT_NEST_CHANNEL_ID }}|project-nest> and help shape the future -- we're so looking forward to participating in GSoC 2026! +Join the effort at #project-nest and help shape the future -- we're so looking forward to participating in GSoC 2026! diff --git a/backend/apps/slack/templates/events/member_joined_channel/gsoc/direct_message.jinja b/backend/apps/slack/templates/events/member_joined_channel/gsoc/direct_message.jinja index 62ea2f3298..026655a29c 100644 --- a/backend/apps/slack/templates/events/member_joined_channel/gsoc/direct_message.jinja +++ b/backend/apps/slack/templates/events/member_joined_channel/gsoc/direct_message.jinja @@ -1,10 +1,10 @@ -Hello <@{{ USER_ID }}> and welcome to <#{{ GSOC_CHANNEL_ID }}|{{ GSOC_CHANNEL_NAME }}> channel! +Hello <@{{ USER_ID }}> and welcome to #gsoc channel! Here's how you can start your journey toward contributing to OWASP projects and making the most of Google Summer of Code. {{ NL }} 🚀 Getting Started with OWASP GSoC: {{ NL }} - • Join the <#{{ GSOC_CHANNEL_ID }}|{{ GSOC_CHANNEL_NAME }}> and #contribute channels if you haven't done it yet for suggestions and tips on how to get started. + • Join the #gsoc and #contribute channels if you haven't done it yet for suggestions and tips on how to get started. • Explore previous years GSoC projects by using corresponding tags, (e.g. gsoc{{ PREVIOUS_YEAR }}, gsoc{{ PREVIOUS_YEAR - 1 }}) on OWASP Nest as they are more likely to participate in GSoC again. • Run /contribute --start to get more information on how to contribute to OWASP. {{ NL }} diff --git a/backend/apps/slack/templates/events/member_joined_channel/gsoc/ephemeral_message.jinja b/backend/apps/slack/templates/events/member_joined_channel/gsoc/ephemeral_message.jinja index 604e3f5cc3..c5eb3cb394 100644 --- a/backend/apps/slack/templates/events/member_joined_channel/gsoc/ephemeral_message.jinja +++ b/backend/apps/slack/templates/events/member_joined_channel/gsoc/ephemeral_message.jinja @@ -30,8 +30,8 @@ of the quality of the proposals and the growing impact of the OWASP Nest project :bangbang: For projects that were not accepted this year we've launched a dedicated sponsorship program to support them. We're looking for high-quality contributors to help us with those project ideas. -Feel free to join <{{ SPONSORSHIP_CHANNEL_ID }}|sponsorship> channel to learn more about it.{{ NL }} +Feel free to join #sponsorship channel to learn more about it.{{ NL }} {{ SECTION_BREAK }} -Join the effort at <{{ PROJECT_NEST_CHANNEL_ID }}|project-nest> and help shape the future -- we're so looking forward to participating in GSoC 2026! +Join the effort at #project-nest and help shape the future -- we're so looking forward to participating in GSoC 2026! diff --git a/backend/apps/slack/templates/events/member_joined_channel/project_nest/ephemeral_message.jinja b/backend/apps/slack/templates/events/member_joined_channel/project_nest/ephemeral_message.jinja index 4202097ac0..250a3a7b90 100644 --- a/backend/apps/slack/templates/events/member_joined_channel/project_nest/ephemeral_message.jinja +++ b/backend/apps/slack/templates/events/member_joined_channel/project_nest/ephemeral_message.jinja @@ -1,4 +1,4 @@ -Welcome to <{{ PROJECT_NEST_CHANNEL_ID }}|{{ PROJECT_NEST_CHANNEL_NAME }}> <@{{ USER_ID }}>! +Welcome to #project-nest <@{{ USER_ID }}>! We're thrilled to have you join our growing, friendly, and open community of contributors, mentors, and project supporters. Whether you're here to learn, contribute, mentor, or just diff --git a/backend/data/nest.json.gz b/backend/data/nest.json.gz index 1b03123b4d..69f10d28f8 100644 Binary files a/backend/data/nest.json.gz and b/backend/data/nest.json.gz differ diff --git a/backend/docker/entrypoint.sh b/backend/entrypoint.sh similarity index 100% rename from backend/docker/entrypoint.sh rename to backend/entrypoint.sh diff --git a/backend/poetry.lock b/backend/poetry.lock index ec37596258..b5d49659cb 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -14,132 +14,132 @@ files = [ [[package]] name = "aiohttp" -version = "3.13.2" +version = "3.13.3" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "aiohttp-3.13.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2372b15a5f62ed37789a6b383ff7344fc5b9f243999b0cd9b629d8bc5f5b4155"}, - {file = "aiohttp-3.13.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e7f8659a48995edee7229522984bd1009c1213929c769c2daa80b40fe49a180c"}, - {file = "aiohttp-3.13.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:939ced4a7add92296b0ad38892ce62b98c619288a081170695c6babe4f50e636"}, - {file = "aiohttp-3.13.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6315fb6977f1d0dd41a107c527fee2ed5ab0550b7d885bc15fee20ccb17891da"}, - {file = "aiohttp-3.13.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6e7352512f763f760baaed2637055c49134fd1d35b37c2dedfac35bfe5cf8725"}, - {file = "aiohttp-3.13.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e09a0a06348a2dd73e7213353c90d709502d9786219f69b731f6caa0efeb46f5"}, - {file = "aiohttp-3.13.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a09a6d073fb5789456545bdee2474d14395792faa0527887f2f4ec1a486a59d3"}, - {file = "aiohttp-3.13.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b59d13c443f8e049d9e94099c7e412e34610f1f49be0f230ec656a10692a5802"}, - {file = "aiohttp-3.13.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:20db2d67985d71ca033443a1ba2001c4b5693fe09b0e29f6d9358a99d4d62a8a"}, - {file = "aiohttp-3.13.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:960c2fc686ba27b535f9fd2b52d87ecd7e4fd1cf877f6a5cba8afb5b4a8bd204"}, - {file = "aiohttp-3.13.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6c00dbcf5f0d88796151e264a8eab23de2997c9303dd7c0bf622e23b24d3ce22"}, - {file = "aiohttp-3.13.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fed38a5edb7945f4d1bcabe2fcd05db4f6ec7e0e82560088b754f7e08d93772d"}, - {file = "aiohttp-3.13.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:b395bbca716c38bef3c764f187860e88c724b342c26275bc03e906142fc5964f"}, - {file = "aiohttp-3.13.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:204ffff2426c25dfda401ba08da85f9c59525cdc42bda26660463dd1cbcfec6f"}, - {file = "aiohttp-3.13.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:05c4dd3c48fb5f15db31f57eb35374cb0c09afdde532e7fb70a75aede0ed30f6"}, - {file = "aiohttp-3.13.2-cp310-cp310-win32.whl", hash = "sha256:e574a7d61cf10351d734bcddabbe15ede0eaa8a02070d85446875dc11189a251"}, - {file = "aiohttp-3.13.2-cp310-cp310-win_amd64.whl", hash = "sha256:364f55663085d658b8462a1c3f17b2b84a5c2e1ba858e1b79bff7b2e24ad1514"}, - {file = "aiohttp-3.13.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4647d02df098f6434bafd7f32ad14942f05a9caa06c7016fdcc816f343997dd0"}, - {file = "aiohttp-3.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e3403f24bcb9c3b29113611c3c16a2a447c3953ecf86b79775e7be06f7ae7ccb"}, - {file = "aiohttp-3.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:43dff14e35aba17e3d6d5ba628858fb8cb51e30f44724a2d2f0c75be492c55e9"}, - {file = "aiohttp-3.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2a9ea08e8c58bb17655630198833109227dea914cd20be660f52215f6de5613"}, - {file = "aiohttp-3.13.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53b07472f235eb80e826ad038c9d106c2f653584753f3ddab907c83f49eedead"}, - {file = "aiohttp-3.13.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e736c93e9c274fce6419af4aac199984d866e55f8a4cec9114671d0ea9688780"}, - {file = "aiohttp-3.13.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ff5e771f5dcbc81c64898c597a434f7682f2259e0cd666932a913d53d1341d1a"}, - {file = "aiohttp-3.13.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3b6fb0c207cc661fa0bf8c66d8d9b657331ccc814f4719468af61034b478592"}, - {file = "aiohttp-3.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:97a0895a8e840ab3520e2288db7cace3a1981300d48babeb50e7425609e2e0ab"}, - {file = "aiohttp-3.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9e8f8afb552297aca127c90cb840e9a1d4bfd6a10d7d8f2d9176e1acc69bad30"}, - {file = "aiohttp-3.13.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ed2f9c7216e53c3df02264f25d824b079cc5914f9e2deba94155190ef648ee40"}, - {file = "aiohttp-3.13.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:99c5280a329d5fa18ef30fd10c793a190d996567667908bef8a7f81f8202b948"}, - {file = "aiohttp-3.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ca6ffef405fc9c09a746cb5d019c1672cd7f402542e379afc66b370833170cf"}, - {file = "aiohttp-3.13.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:47f438b1a28e926c37632bff3c44df7d27c9b57aaf4e34b1def3c07111fdb782"}, - {file = "aiohttp-3.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9acda8604a57bb60544e4646a4615c1866ee6c04a8edef9b8ee6fd1d8fa2ddc8"}, - {file = "aiohttp-3.13.2-cp311-cp311-win32.whl", hash = "sha256:868e195e39b24aaa930b063c08bb0c17924899c16c672a28a65afded9c46c6ec"}, - {file = "aiohttp-3.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:7fd19df530c292542636c2a9a85854fab93474396a52f1695e799186bbd7f24c"}, - {file = "aiohttp-3.13.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b1e56bab2e12b2b9ed300218c351ee2a3d8c8fdab5b1ec6193e11a817767e47b"}, - {file = "aiohttp-3.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:364e25edaabd3d37b1db1f0cbcee8c73c9a3727bfa262b83e5e4cf3489a2a9dc"}, - {file = "aiohttp-3.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c5c94825f744694c4b8db20b71dba9a257cd2ba8e010a803042123f3a25d50d7"}, - {file = "aiohttp-3.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba2715d842ffa787be87cbfce150d5e88c87a98e0b62e0f5aa489169a393dbbb"}, - {file = "aiohttp-3.13.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:585542825c4bc662221fb257889e011a5aa00f1ae4d75d1d246a5225289183e3"}, - {file = "aiohttp-3.13.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39d02cb6025fe1aabca329c5632f48c9532a3dabccd859e7e2f110668972331f"}, - {file = "aiohttp-3.13.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e67446b19e014d37342f7195f592a2a948141d15a312fe0e700c2fd2f03124f6"}, - {file = "aiohttp-3.13.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4356474ad6333e41ccefd39eae869ba15a6c5299c9c01dfdcfdd5c107be4363e"}, - {file = "aiohttp-3.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeacf451c99b4525f700f078becff32c32ec327b10dcf31306a8a52d78166de7"}, - {file = "aiohttp-3.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8a9b889aeabd7a4e9af0b7f4ab5ad94d42e7ff679aaec6d0db21e3b639ad58d"}, - {file = "aiohttp-3.13.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:fa89cb11bc71a63b69568d5b8a25c3ca25b6d54c15f907ca1c130d72f320b76b"}, - {file = "aiohttp-3.13.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8aa7c807df234f693fed0ecd507192fc97692e61fee5702cdc11155d2e5cadc8"}, - {file = "aiohttp-3.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9eb3e33fdbe43f88c3c75fa608c25e7c47bbd80f48d012763cb67c47f39a7e16"}, - {file = "aiohttp-3.13.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9434bc0d80076138ea986833156c5a48c9c7a8abb0c96039ddbb4afc93184169"}, - {file = "aiohttp-3.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff15c147b2ad66da1f2cbb0622313f2242d8e6e8f9b79b5206c84523a4473248"}, - {file = "aiohttp-3.13.2-cp312-cp312-win32.whl", hash = "sha256:27e569eb9d9e95dbd55c0fc3ec3a9335defbf1d8bc1d20171a49f3c4c607b93e"}, - {file = "aiohttp-3.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:8709a0f05d59a71f33fd05c17fc11fcb8c30140506e13c2f5e8ee1b8964e1b45"}, - {file = "aiohttp-3.13.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7519bdc7dfc1940d201651b52bf5e03f5503bda45ad6eacf64dda98be5b2b6be"}, - {file = "aiohttp-3.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:088912a78b4d4f547a1f19c099d5a506df17eacec3c6f4375e2831ec1d995742"}, - {file = "aiohttp-3.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5276807b9de9092af38ed23ce120539ab0ac955547b38563a9ba4f5b07b95293"}, - {file = "aiohttp-3.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1237c1375eaef0db4dcd7c2559f42e8af7b87ea7d295b118c60c36a6e61cb811"}, - {file = "aiohttp-3.13.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96581619c57419c3d7d78703d5b78c1e5e5fc0172d60f555bdebaced82ded19a"}, - {file = "aiohttp-3.13.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2713a95b47374169409d18103366de1050fe0ea73db358fc7a7acb2880422d4"}, - {file = "aiohttp-3.13.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:228a1cd556b3caca590e9511a89444925da87d35219a49ab5da0c36d2d943a6a"}, - {file = "aiohttp-3.13.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac6cde5fba8d7d8c6ac963dbb0256a9854e9fafff52fbcc58fdf819357892c3e"}, - {file = "aiohttp-3.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2bef8237544f4e42878c61cef4e2839fee6346dc60f5739f876a9c50be7fcdb"}, - {file = "aiohttp-3.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:16f15a4eac3bc2d76c45f7ebdd48a65d41b242eb6c31c2245463b40b34584ded"}, - {file = "aiohttp-3.13.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bb7fb776645af5cc58ab804c58d7eba545a97e047254a52ce89c157b5af6cd0b"}, - {file = "aiohttp-3.13.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1b4951125ec10c70802f2cb09736c895861cd39fd9dcb35107b4dc8ae6220b8"}, - {file = "aiohttp-3.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:550bf765101ae721ee1d37d8095f47b1f220650f85fe1af37a90ce75bab89d04"}, - {file = "aiohttp-3.13.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fe91b87fc295973096251e2d25a811388e7d8adf3bd2b97ef6ae78bc4ac6c476"}, - {file = "aiohttp-3.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0c8e31cfcc4592cb200160344b2fb6ae0f9e4effe06c644b5a125d4ae5ebe23"}, - {file = "aiohttp-3.13.2-cp313-cp313-win32.whl", hash = "sha256:0740f31a60848d6edb296a0df827473eede90c689b8f9f2a4cdde74889eb2254"}, - {file = "aiohttp-3.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:a88d13e7ca367394908f8a276b89d04a3652044612b9a408a0bb22a5ed976a1a"}, - {file = "aiohttp-3.13.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2475391c29230e063ef53a66669b7b691c9bfc3f1426a0f7bcdf1216bdbac38b"}, - {file = "aiohttp-3.13.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f33c8748abef4d8717bb20e8fb1b3e07c6adacb7fd6beaae971a764cf5f30d61"}, - {file = "aiohttp-3.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae32f24bbfb7dbb485a24b30b1149e2f200be94777232aeadba3eecece4d0aa4"}, - {file = "aiohttp-3.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d7f02042c1f009ffb70067326ef183a047425bb2ff3bc434ead4dd4a4a66a2b"}, - {file = "aiohttp-3.13.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93655083005d71cd6c072cdab54c886e6570ad2c4592139c3fb967bfc19e4694"}, - {file = "aiohttp-3.13.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0db1e24b852f5f664cd728db140cf11ea0e82450471232a394b3d1a540b0f906"}, - {file = "aiohttp-3.13.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b009194665bcd128e23eaddef362e745601afa4641930848af4c8559e88f18f9"}, - {file = "aiohttp-3.13.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c038a8fdc8103cd51dbd986ecdce141473ffd9775a7a8057a6ed9c3653478011"}, - {file = "aiohttp-3.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66bac29b95a00db411cd758fea0e4b9bdba6d549dfe333f9a945430f5f2cc5a6"}, - {file = "aiohttp-3.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4ebf9cfc9ba24a74cf0718f04aac2a3bbe745902cc7c5ebc55c0f3b5777ef213"}, - {file = "aiohttp-3.13.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a4b88ebe35ce54205c7074f7302bd08a4cb83256a3e0870c72d6f68a3aaf8e49"}, - {file = "aiohttp-3.13.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:98c4fb90bb82b70a4ed79ca35f656f4281885be076f3f970ce315402b53099ae"}, - {file = "aiohttp-3.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ec7534e63ae0f3759df3a1ed4fa6bc8f75082a924b590619c0dd2f76d7043caa"}, - {file = "aiohttp-3.13.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5b927cf9b935a13e33644cbed6c8c4b2d0f25b713d838743f8fe7191b33829c4"}, - {file = "aiohttp-3.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:88d6c017966a78c5265d996c19cdb79235be5e6412268d7e2ce7dee339471b7a"}, - {file = "aiohttp-3.13.2-cp314-cp314-win32.whl", hash = "sha256:f7c183e786e299b5d6c49fb43a769f8eb8e04a2726a2bd5887b98b5cc2d67940"}, - {file = "aiohttp-3.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe242cd381e0fb65758faf5ad96c2e460df6ee5b2de1072fe97e4127927e00b4"}, - {file = "aiohttp-3.13.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f10d9c0b0188fe85398c61147bbd2a657d616c876863bfeff43376e0e3134673"}, - {file = "aiohttp-3.13.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e7c952aefdf2460f4ae55c5e9c3e80aa72f706a6317e06020f80e96253b1accd"}, - {file = "aiohttp-3.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c20423ce14771d98353d2e25e83591fa75dfa90a3c1848f3d7c68243b4fbded3"}, - {file = "aiohttp-3.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e96eb1a34396e9430c19d8338d2ec33015e4a87ef2b4449db94c22412e25ccdf"}, - {file = "aiohttp-3.13.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23fb0783bc1a33640036465019d3bba069942616a6a2353c6907d7fe1ccdaf4e"}, - {file = "aiohttp-3.13.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1a9bea6244a1d05a4e57c295d69e159a5c50d8ef16aa390948ee873478d9a5"}, - {file = "aiohttp-3.13.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a3d54e822688b56e9f6b5816fb3de3a3a64660efac64e4c2dc435230ad23bad"}, - {file = "aiohttp-3.13.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a653d872afe9f33497215745da7a943d1dc15b728a9c8da1c3ac423af35178e"}, - {file = "aiohttp-3.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56d36e80d2003fa3fc0207fac644216d8532e9504a785ef9a8fd013f84a42c61"}, - {file = "aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:78cd586d8331fb8e241c2dd6b2f4061778cc69e150514b39a9e28dd050475661"}, - {file = "aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:20b10bbfbff766294fe99987f7bb3b74fdd2f1a2905f2562132641ad434dcf98"}, - {file = "aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9ec49dff7e2b3c85cdeaa412e9d438f0ecd71676fde61ec57027dd392f00c693"}, - {file = "aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:94f05348c4406450f9d73d38efb41d669ad6cd90c7ee194810d0eefbfa875a7a"}, - {file = "aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:fa4dcb605c6f82a80c7f95713c2b11c3b8e9893b3ebd2bc9bde93165ed6107be"}, - {file = "aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf00e5db968c3f67eccd2778574cf64d8b27d95b237770aa32400bd7a1ca4f6c"}, - {file = "aiohttp-3.13.2-cp314-cp314t-win32.whl", hash = "sha256:d23b5fe492b0805a50d3371e8a728a9134d8de5447dce4c885f5587294750734"}, - {file = "aiohttp-3.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:ff0a7b0a82a7ab905cbda74006318d1b12e37c797eb1b0d4eb3e316cf47f658f"}, - {file = "aiohttp-3.13.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7fbdf5ad6084f1940ce88933de34b62358d0f4a0b6ec097362dcd3e5a65a4989"}, - {file = "aiohttp-3.13.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7c3a50345635a02db61792c85bb86daffac05330f6473d524f1a4e3ef9d0046d"}, - {file = "aiohttp-3.13.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0e87dff73f46e969af38ab3f7cb75316a7c944e2e574ff7c933bc01b10def7f5"}, - {file = "aiohttp-3.13.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2adebd4577724dcae085665f294cc57c8701ddd4d26140504db622b8d566d7aa"}, - {file = "aiohttp-3.13.2-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e036a3a645fe92309ec34b918394bb377950cbb43039a97edae6c08db64b23e2"}, - {file = "aiohttp-3.13.2-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:23ad365e30108c422d0b4428cf271156dd56790f6dd50d770b8e360e6c5ab2e6"}, - {file = "aiohttp-3.13.2-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f9b2c2d4b9d958b1f9ae0c984ec1dd6b6689e15c75045be8ccb4011426268ca"}, - {file = "aiohttp-3.13.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a92cf4b9bea33e15ecbaa5c59921be0f23222608143d025c989924f7e3e0c07"}, - {file = "aiohttp-3.13.2-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:070599407f4954021509193404c4ac53153525a19531051661440644728ba9a7"}, - {file = "aiohttp-3.13.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:29562998ec66f988d49fb83c9b01694fa927186b781463f376c5845c121e4e0b"}, - {file = "aiohttp-3.13.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:4dd3db9d0f4ebca1d887d76f7cdbcd1116ac0d05a9221b9dad82c64a62578c4d"}, - {file = "aiohttp-3.13.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d7bc4b7f9c4921eba72677cd9fedd2308f4a4ca3e12fab58935295ad9ea98700"}, - {file = "aiohttp-3.13.2-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:dacd50501cd017f8cccb328da0c90823511d70d24a323196826d923aad865901"}, - {file = "aiohttp-3.13.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:8b2f1414f6a1e0683f212ec80e813f4abef94c739fd090b66c9adf9d2a05feac"}, - {file = "aiohttp-3.13.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04c3971421576ed24c191f610052bcb2f059e395bc2489dd99e397f9bc466329"}, - {file = "aiohttp-3.13.2-cp39-cp39-win32.whl", hash = "sha256:9f377d0a924e5cc94dc620bc6366fc3e889586a7f18b748901cf016c916e2084"}, - {file = "aiohttp-3.13.2-cp39-cp39-win_amd64.whl", hash = "sha256:9c705601e16c03466cb72011bd1af55d68fa65b045356d8f96c216e5f6db0fa5"}, - {file = "aiohttp-3.13.2.tar.gz", hash = "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca"}, + {file = "aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7"}, + {file = "aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821"}, + {file = "aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11"}, + {file = "aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd"}, + {file = "aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c"}, + {file = "aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b"}, + {file = "aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64"}, + {file = "aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29"}, + {file = "aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239"}, + {file = "aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f"}, + {file = "aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c"}, + {file = "aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168"}, + {file = "aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a"}, + {file = "aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046"}, + {file = "aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57"}, + {file = "aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c"}, + {file = "aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9"}, + {file = "aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591"}, + {file = "aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf"}, + {file = "aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e"}, + {file = "aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808"}, + {file = "aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415"}, + {file = "aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43"}, + {file = "aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1"}, + {file = "aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984"}, + {file = "aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c"}, + {file = "aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592"}, + {file = "aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa"}, + {file = "aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767"}, + {file = "aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344"}, + {file = "aiohttp-3.13.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31a83ea4aead760dfcb6962efb1d861db48c34379f2ff72db9ddddd4cda9ea2e"}, + {file = "aiohttp-3.13.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:988a8c5e317544fdf0d39871559e67b6341065b87fceac641108c2096d5506b7"}, + {file = "aiohttp-3.13.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9b174f267b5cfb9a7dba9ee6859cecd234e9a681841eb85068059bc867fb8f02"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:947c26539750deeaee933b000fb6517cc770bbd064bad6033f1cff4803881e43"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9ebf57d09e131f5323464bd347135a88622d1c0976e88ce15b670e7ad57e4bd6"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4ae5b5a0e1926e504c81c5b84353e7a5516d8778fbbff00429fe7b05bb25cbce"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ba0eea45eb5cc3172dbfc497c066f19c41bac70963ea1a67d51fc92e4cf9a80"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bae5c2ed2eae26cc382020edad80d01f36cb8e746da40b292e68fec40421dc6a"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a60e60746623925eab7d25823329941aee7242d559baa119ca2b253c88a7bd6"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e50a2e1404f063427c9d027378472316201a2290959a295169bcf25992d04558"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:9a9dc347e5a3dc7dfdbc1f82da0ef29e388ddb2ed281bfce9dd8248a313e62b7"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b46020d11d23fe16551466c77823df9cc2f2c1e63cc965daf67fa5eec6ca1877"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:69c56fbc1993fa17043e24a546959c0178fe2b5782405ad4559e6c13975c15e3"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:b99281b0704c103d4e11e72a76f1b543d4946fea7dd10767e7e1b5f00d4e5704"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:40c5e40ecc29ba010656c18052b877a1c28f84344825efa106705e835c28530f"}, + {file = "aiohttp-3.13.3-cp39-cp39-win32.whl", hash = "sha256:56339a36b9f1fc708260c76c87e593e2afb30d26de9ae1eb445b5e051b98a7a1"}, + {file = "aiohttp-3.13.3-cp39-cp39-win_amd64.whl", hash = "sha256:c6b8568a3bb5819a0ad087f16d40e5a3fb6099f39ea1d5625a3edc1e923fc538"}, + {file = "aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88"}, ] [package.dependencies] @@ -152,7 +152,7 @@ propcache = ">=0.2.0" yarl = ">=1.17.0,<2.0" [package.extras] -speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +speedups = ["Brotli (>=1.2) ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "brotlicffi (>=1.2) ; platform_python_implementation != \"CPython\""] [[package]] name = "aiosignal" @@ -171,14 +171,14 @@ frozenlist = ">=1.1.0" [[package]] name = "algoliasearch" -version = "4.33.0" +version = "4.35.2" description = "A fully-featured and blazing-fast Python API client to interact with Algolia." optional = false python-versions = ">=3.8.1" groups = ["main"] files = [ - {file = "algoliasearch-4.33.0-py3-none-any.whl", hash = "sha256:b0ffa950a25087ebe0aa099a68baeb197c4d346591c5f8d81d29ccdfad46e0bf"}, - {file = "algoliasearch-4.33.0.tar.gz", hash = "sha256:ea4f8ada3cdad80f4628baa7afdc97aa311708ef31c1a9c556ef5d19d217b5fa"}, + {file = "algoliasearch-4.35.2-py3-none-any.whl", hash = "sha256:bb079506dd6527d26640929e4d35fafb64338437444dc23efb2ebbf98061e977"}, + {file = "algoliasearch-4.35.2.tar.gz", hash = "sha256:6108a4195dbf07a3762a026907e486228692e8026b9db568c9093c280235fd5d"}, ] [package.dependencies] @@ -218,22 +218,21 @@ files = [ [[package]] name = "anyio" -version = "4.11.0" +version = "4.12.0" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, - {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, + {file = "anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb"}, + {file = "anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0"}, ] [package.dependencies] idna = ">=2.8" -sniffio = ">=1.1" [package.extras] -trio = ["trio (>=0.31.0)"] +trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] [[package]] name = "argcomplete" @@ -291,34 +290,34 @@ files = [ [[package]] name = "boto3" -version = "1.41.2" +version = "1.42.21" description = "The AWS SDK for Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "boto3-1.41.2-py3-none-any.whl", hash = "sha256:edcde82fdae4201aa690e3683f8e5b1a846cf1bbf79d03db4fa8a2f6f46dba9c"}, - {file = "boto3-1.41.2.tar.gz", hash = "sha256:7054fbc61cadab383f40ea6d725013ba6c8f569641dddb14c0055e790280ad6c"}, + {file = "boto3-1.42.21-py3-none-any.whl", hash = "sha256:1885f252d715a5810bb4e0c5bbebfa8e9018b025febf5be3d58540626e7b43d2"}, + {file = "boto3-1.42.21.tar.gz", hash = "sha256:9b92943d253bc837323079fe88460e741cb2eb80abaebcb558b2446bdb4049d6"}, ] [package.dependencies] -botocore = ">=1.41.2,<1.42.0" +botocore = ">=1.42.21,<1.43.0" jmespath = ">=0.7.1,<2.0.0" -s3transfer = ">=0.15.0,<0.16.0" +s3transfer = ">=0.16.0,<0.17.0" [package.extras] crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.41.2" +version = "1.42.21" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "botocore-1.41.2-py3-none-any.whl", hash = "sha256:154052dfaa7292212f01c8fab822c76cd10a15a7e164e4c45e4634eb40214b90"}, - {file = "botocore-1.41.2.tar.gz", hash = "sha256:49a3e8f4c1a1759a687941fef8b36efd7bafcf63c1ef74aa75d6497eb4887c9c"}, + {file = "botocore-1.42.21-py3-none-any.whl", hash = "sha256:6b59973a3ba8c3cfd5123f2656fef2339beee9f6483b8bc12bb00c5453ea2c6d"}, + {file = "botocore-1.42.21.tar.gz", hash = "sha256:db8f99d186156da42feb4fd2098017383d9b155097290cc53da7258f6e652c39"}, ] [package.dependencies] @@ -327,18 +326,18 @@ python-dateutil = ">=2.1,<3.0.0" urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} [package.extras] -crt = ["awscrt (==0.28.4)"] +crt = ["awscrt (==0.29.2)"] [[package]] name = "certifi" -version = "2025.11.12" +version = "2026.1.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, - {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, + {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, + {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, ] [[package]] @@ -621,104 +620,104 @@ markers = {main = "platform_system == \"Windows\"", test = "sys_platform == \"wi [[package]] name = "coverage" -version = "7.12.0" +version = "7.13.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" groups = ["test"] files = [ - {file = "coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b"}, - {file = "coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c"}, - {file = "coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832"}, - {file = "coverage-7.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa"}, - {file = "coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73"}, - {file = "coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb"}, - {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e"}, - {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777"}, - {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553"}, - {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d"}, - {file = "coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef"}, - {file = "coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022"}, - {file = "coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f"}, - {file = "coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3"}, - {file = "coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e"}, - {file = "coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7"}, - {file = "coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245"}, - {file = "coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b"}, - {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64"}, - {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742"}, - {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c"}, - {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984"}, - {file = "coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6"}, - {file = "coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4"}, - {file = "coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc"}, - {file = "coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647"}, - {file = "coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736"}, - {file = "coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60"}, - {file = "coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8"}, - {file = "coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f"}, - {file = "coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70"}, - {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0"}, - {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068"}, - {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b"}, - {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937"}, - {file = "coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa"}, - {file = "coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a"}, - {file = "coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c"}, - {file = "coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941"}, - {file = "coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a"}, - {file = "coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d"}, - {file = "coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211"}, - {file = "coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d"}, - {file = "coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c"}, - {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9"}, - {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0"}, - {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508"}, - {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc"}, - {file = "coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8"}, - {file = "coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07"}, - {file = "coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc"}, - {file = "coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87"}, - {file = "coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6"}, - {file = "coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7"}, - {file = "coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560"}, - {file = "coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12"}, - {file = "coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296"}, - {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507"}, - {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d"}, - {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2"}, - {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455"}, - {file = "coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d"}, - {file = "coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c"}, - {file = "coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d"}, - {file = "coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92"}, - {file = "coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360"}, - {file = "coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac"}, - {file = "coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d"}, - {file = "coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c"}, - {file = "coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434"}, - {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc"}, - {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc"}, - {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e"}, - {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17"}, - {file = "coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933"}, - {file = "coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe"}, - {file = "coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d"}, - {file = "coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d"}, - {file = "coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03"}, - {file = "coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9"}, - {file = "coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6"}, - {file = "coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339"}, - {file = "coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e"}, - {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13"}, - {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f"}, - {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1"}, - {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b"}, - {file = "coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a"}, - {file = "coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291"}, - {file = "coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384"}, - {file = "coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a"}, - {file = "coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c"}, + {file = "coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147"}, + {file = "coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29"}, + {file = "coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f"}, + {file = "coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1"}, + {file = "coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88"}, + {file = "coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba"}, + {file = "coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19"}, + {file = "coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a"}, + {file = "coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c"}, + {file = "coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3"}, + {file = "coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c"}, + {file = "coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7"}, + {file = "coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6"}, + {file = "coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c"}, + {file = "coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78"}, + {file = "coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784"}, + {file = "coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461"}, + {file = "coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500"}, + {file = "coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9"}, + {file = "coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc"}, + {file = "coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53"}, + {file = "coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842"}, + {file = "coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2"}, + {file = "coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09"}, + {file = "coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894"}, + {file = "coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9"}, + {file = "coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5"}, + {file = "coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a"}, + {file = "coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0"}, + {file = "coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a"}, + {file = "coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416"}, + {file = "coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f"}, + {file = "coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79"}, + {file = "coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4"}, + {file = "coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573"}, + {file = "coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd"}, ] [package.extras] @@ -740,6 +739,21 @@ files = [ python-dateutil = "*" pytz = ">2021.1" +[[package]] +name = "cross-web" +version = "0.4.0" +description = "A library for working with web frameworks" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "cross_web-0.4.0-py3-none-any.whl", hash = "sha256:0c675bd26e91428cab31e3e927929b42da94aa96da92974e57c78f9a732d0e9b"}, + {file = "cross_web-0.4.0.tar.gz", hash = "sha256:4ae65619ddfcd06d6803432c0366342d7e8aeba10194b4e144d73a662e75370c"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.0" + [[package]] name = "cryptography" version = "46.0.3" @@ -876,24 +890,24 @@ files = [ [[package]] name = "django" -version = "5.2.8" +version = "6.0" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false -python-versions = ">=3.10" +python-versions = ">=3.12" groups = ["main"] files = [ - {file = "django-5.2.8-py3-none-any.whl", hash = "sha256:37e687f7bd73ddf043e2b6b97cfe02fcbb11f2dbb3adccc6a2b18c6daa054d7f"}, - {file = "django-5.2.8.tar.gz", hash = "sha256:23254866a5bb9a2cfa6004e8b809ec6246eba4b58a7589bc2772f1bcc8456c7f"}, + {file = "django-6.0-py3-none-any.whl", hash = "sha256:1cc2c7344303bbfb7ba5070487c17f7fc0b7174bbb0a38cebf03c675f5f19b6d"}, + {file = "django-6.0.tar.gz", hash = "sha256:7b0c1f50c0759bbe6331c6a39c89ae022a84672674aeda908784617ef47d8e26"}, ] [package.dependencies] -asgiref = ">=3.8.1" -sqlparse = ">=0.3.1" +asgiref = ">=3.9.1" +sqlparse = ">=0.5.0" tzdata = {version = "*", markers = "sys_platform == \"win32\""} [package.extras] -argon2 = ["argon2-cffi (>=19.1.0)"] -bcrypt = ["bcrypt"] +argon2 = ["argon2-cffi (>=23.1.0)"] +bcrypt = ["bcrypt (>=4.1.1)"] [[package]] name = "django-configurations" @@ -935,18 +949,18 @@ django = ">=4.2" [[package]] name = "django-ninja" -version = "1.5.0" +version = "1.5.2" description = "Django Ninja - Fast Django REST framework" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "django_ninja-1.5.0-py3-none-any.whl", hash = "sha256:e305fc42588406a202d4479263b501813ec863a3a4e11cb091fb905ecdf9bc62"}, - {file = "django_ninja-1.5.0.tar.gz", hash = "sha256:181bc8266a684be8c4cdd8f555d8ccbbc72e9fad86a5928ddccdd13faf73a1c5"}, + {file = "django_ninja-1.5.2-py3-none-any.whl", hash = "sha256:5faf09cf6e64298e822305c3f4b933608b4fce30643e36cc58f67660f20f4cac"}, + {file = "django_ninja-1.5.2.tar.gz", hash = "sha256:1554bebc28e9bbc8412f49bae4698937781b54d828c84af30c40dfced407b8cb"}, ] [package.dependencies] -Django = ">=3.1,<6.0" +Django = ">=3.1,<6.1" pydantic = ">=2.0,<3.0.0" [package.extras] @@ -975,14 +989,14 @@ hiredis = ["redis[hiredis] (>=4.0.2)"] [[package]] name = "django-rq" -version = "3.2.0" +version = "3.2.2" description = "An app that provides django integration for RQ (Redis Queue)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "django_rq-3.2.0-py3-none-any.whl", hash = "sha256:a59aa137679b3aed69519be7886ab0417fb144cf38abe5cdcad4c5f36a774542"}, - {file = "django_rq-3.2.0.tar.gz", hash = "sha256:e1bf7a69ecac87ddf4a22938d12587df7b5335b1dc0c9e0c9a6e0859822f5377"}, + {file = "django_rq-3.2.2-py3-none-any.whl", hash = "sha256:f3808d014a0943774b9425fd1f68366cef23945635b2cf333afcf97af7a3b67d"}, + {file = "django_rq-3.2.2.tar.gz", hash = "sha256:2eaac4e05092895ec09b6da978e5ae9fafc917574934bb2db1bebf1b3395c049"}, ] [package.dependencies] @@ -1122,14 +1136,14 @@ testing = ["hatch", "pre-commit", "pytest", "tox"] [[package]] name = "filelock" -version = "3.20.0" +version = "3.20.2" description = "A platform independent file lock." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2"}, - {file = "filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4"}, + {file = "filelock-3.20.2-py3-none-any.whl", hash = "sha256:fbba7237d6ea277175a32c54bb71ef814a8546d8601269e1bfc388de333974e8"}, + {file = "filelock-3.20.2.tar.gz", hash = "sha256:a2241ff4ddde2a7cebddf78e39832509cb045d18ec1a09d7248d6bfc6bfbbe64"}, ] [[package]] @@ -1322,67 +1336,61 @@ files = [ [[package]] name = "greenlet" -version = "3.2.4" +version = "3.3.0" description = "Lightweight in-process concurrent programming" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\"" files = [ - {file = "greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c"}, - {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590"}, - {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c"}, - {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b"}, - {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31"}, - {file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"}, - {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"}, - {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"}, - {file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"}, - {file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"}, - {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"}, - {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3"}, - {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633"}, - {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079"}, - {file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"}, - {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"}, - {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"}, - {file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"}, - {file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"}, - {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"}, - {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968"}, - {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9"}, - {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6"}, - {file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"}, - {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"}, - {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"}, - {file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"}, - {file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"}, - {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"}, - {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc"}, - {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a"}, - {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504"}, - {file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"}, - {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"}, - {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"}, - {file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"}, - {file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"}, - {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"}, - {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5"}, - {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"}, - {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"}, - {file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"}, - {file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"}, - {file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"}, - {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"}, - {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58"}, - {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4"}, - {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433"}, - {file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"}, - {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"}, - {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"}, - {file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"}, - {file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"}, - {file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"}, + {file = "greenlet-3.3.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6f8496d434d5cb2dce025773ba5597f71f5410ae499d5dd9533e0653258cdb3d"}, + {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b96dc7eef78fd404e022e165ec55327f935b9b52ff355b067eb4a0267fc1cffb"}, + {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73631cd5cccbcfe63e3f9492aaa664d278fda0ce5c3d43aeda8e77317e38efbd"}, + {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b299a0cb979f5d7197442dccc3aee67fce53500cd88951b7e6c35575701c980b"}, + {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dee147740789a4632cace364816046e43310b59ff8fb79833ab043aefa72fd5"}, + {file = "greenlet-3.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:39b28e339fc3c348427560494e28d8a6f3561c8d2bcf7d706e1c624ed8d822b9"}, + {file = "greenlet-3.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3c374782c2935cc63b2a27ba8708471de4ad1abaa862ffdb1ef45a643ddbb7d"}, + {file = "greenlet-3.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:b49e7ed51876b459bd645d83db257f0180e345d3f768a35a85437a24d5a49082"}, + {file = "greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e"}, + {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62"}, + {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32"}, + {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45"}, + {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948"}, + {file = "greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794"}, + {file = "greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5"}, + {file = "greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71"}, + {file = "greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb"}, + {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3"}, + {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655"}, + {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7"}, + {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b"}, + {file = "greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53"}, + {file = "greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614"}, + {file = "greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39"}, + {file = "greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739"}, + {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808"}, + {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54"}, + {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492"}, + {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527"}, + {file = "greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39"}, + {file = "greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8"}, + {file = "greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38"}, + {file = "greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f"}, + {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365"}, + {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3"}, + {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45"}, + {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955"}, + {file = "greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55"}, + {file = "greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc"}, + {file = "greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170"}, + {file = "greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931"}, + {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388"}, + {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3"}, + {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221"}, + {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b"}, + {file = "greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd"}, + {file = "greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9"}, + {file = "greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb"}, ] [package.extras] @@ -1496,14 +1504,14 @@ files = [ [[package]] name = "humanize" -version = "4.14.0" +version = "4.15.0" description = "Python humanize utilities" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "humanize-4.14.0-py3-none-any.whl", hash = "sha256:d57701248d040ad456092820e6fde56c930f17749956ac47f4f655c0c547bfff"}, - {file = "humanize-4.14.0.tar.gz", hash = "sha256:2fa092705ea640d605c435b1ca82b2866a1b601cdf96f076d70b79a855eba90d"}, + {file = "humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769"}, + {file = "humanize-4.15.0.tar.gz", hash = "sha256:1dd098483eb1c7ee8e32eb2e99ad1910baefa4b75c3aff3a82f4d78688993b10"}, ] [package.extras] @@ -1711,19 +1719,16 @@ six = ">=1.13.0" [[package]] name = "json5" -version = "0.12.1" +version = "0.13.0" description = "A Python implementation of the JSON5 data format." optional = false python-versions = ">=3.8.0" groups = ["dev"] files = [ - {file = "json5-0.12.1-py3-none-any.whl", hash = "sha256:d9c9b3bc34a5f54d43c35e11ef7cb87d8bdd098c6ace87117a7b7e83e705c1d5"}, - {file = "json5-0.12.1.tar.gz", hash = "sha256:b2743e77b3242f8d03c143dd975a6ec7c52e2f2afe76ed934e53503dd4ad4990"}, + {file = "json5-0.13.0-py3-none-any.whl", hash = "sha256:9a08e1dd65f6a4d4c6fa82d216cf2477349ec2346a38fd70cc11d2557499fbcc"}, + {file = "json5-0.13.0.tar.gz", hash = "sha256:b1edf8d487721c0bf64d83c28e91280781f6e21f4a797d3261c7c828d4c165bf"}, ] -[package.extras] -dev = ["build (==1.2.2.post1)", "coverage (==7.5.4) ; python_version < \"3.9\"", "coverage (==7.8.0) ; python_version >= \"3.9\"", "mypy (==1.14.1) ; python_version < \"3.9\"", "mypy (==1.15.0) ; python_version >= \"3.9\"", "pip (==25.0.1)", "pylint (==3.2.7) ; python_version < \"3.9\"", "pylint (==3.3.6) ; python_version >= \"3.9\"", "ruff (==0.11.2)", "twine (==6.1.0)", "uv (==0.6.11)"] - [[package]] name = "jsonpatch" version = "1.33" @@ -1874,14 +1879,14 @@ tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" [[package]] name = "langchain-core" -version = "0.3.80" +version = "0.3.81" description = "Building applications with LLMs through composability" optional = false python-versions = "<4.0.0,>=3.9.0" groups = ["main"] files = [ - {file = "langchain_core-0.3.80-py3-none-any.whl", hash = "sha256:2141e3838d100d17dce2359f561ec0df52c526bae0de6d4f469f8026c5747456"}, - {file = "langchain_core-0.3.80.tar.gz", hash = "sha256:29636b82513ab49e834764d023c4d18554d3d719a185d37b019d0a8ae948c6bb"}, + {file = "langchain_core-0.3.81-py3-none-any.whl", hash = "sha256:d0f34c88254d78ccb1b9a038f860d13dea90186045026fd8fc3e1265eed73a4e"}, + {file = "langchain_core-0.3.81.tar.gz", hash = "sha256:0473e7cffbba06017393f2f1ee116da098c298e4ec478ada91d321f42dff921c"}, ] [package.dependencies] @@ -1962,14 +1967,14 @@ langgraph-checkpoint = ">=2.1.0,<4.0.0" [[package]] name = "langgraph-sdk" -version = "0.2.9" +version = "0.2.15" description = "SDK for interacting with LangGraph API" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "langgraph_sdk-0.2.9-py3-none-any.whl", hash = "sha256:fbf302edadbf0fb343596f91c597794e936ef68eebc0d3e1d358b6f9f72a1429"}, - {file = "langgraph_sdk-0.2.9.tar.gz", hash = "sha256:b3bd04c6be4fa382996cd2be8fbc1e7cc94857d2bc6b6f4599a7f2a245975303"}, + {file = "langgraph_sdk-0.2.15-py3-none-any.whl", hash = "sha256:746566a5d89aa47160eccc17d71682a78771c754126f6c235a68353d61ed7462"}, + {file = "langgraph_sdk-0.2.15.tar.gz", hash = "sha256:8faaafe2c1193b89f782dd66c591060cd67862aa6aaf283749b7846f331d5334"}, ] [package.dependencies] @@ -1978,23 +1983,24 @@ orjson = ">=3.10.1" [[package]] name = "langsmith" -version = "0.4.46" +version = "0.6.0" description = "Client library to connect to the LangSmith Observability and Evaluation Platform." optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "langsmith-0.4.46-py3-none-any.whl", hash = "sha256:783c16ef108c42a16ec2d8bc68067b969f3652e2fe82ca1289007baf947e4500"}, - {file = "langsmith-0.4.46.tar.gz", hash = "sha256:0b73d47ebd0a27ea10edec1717f36f6a865cb9ffc7f4e6a419e2cea1ab3c5b1e"}, + {file = "langsmith-0.6.0-py3-none-any.whl", hash = "sha256:f7570175aed705b1f4c4dae724c07980a737b8b565252444d11394dda9931e8c"}, + {file = "langsmith-0.6.0.tar.gz", hash = "sha256:b60f1785aed4dac5e01f24db01aa18fa1af258bad4531e045e739438daa3f8c2"}, ] [package.dependencies] httpx = ">=0.23.0,<1" orjson = {version = ">=3.9.14", markers = "platform_python_implementation != \"PyPy\""} packaging = ">=23.2" -pydantic = ">=1,<3" +pydantic = ">=2,<3" requests = ">=2.0.0" requests-toolbelt = ">=1.0.0" +uuid-utils = ">=0.12.0,<1.0" zstandard = ">=0.23.0" [package.extras] @@ -2005,21 +2011,6 @@ otel = ["opentelemetry-api (>=1.30.0)", "opentelemetry-exporter-otlp-proto-http pytest = ["pytest (>=7.0.0)", "rich (>=13.9.4)", "vcrpy (>=7.0.0)"] vcr = ["vcrpy (>=7.0.0)"] -[[package]] -name = "lia-web" -version = "0.2.3" -description = "A library for working with web frameworks" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "lia_web-0.2.3-py3-none-any.whl", hash = "sha256:237c779c943cd4341527fc0adfcc3d8068f992ee051f4ef059b8474ee087f641"}, - {file = "lia_web-0.2.3.tar.gz", hash = "sha256:ccc9d24cdc200806ea96a20b22fb68f4759e6becdb901bd36024df7921e848d7"}, -] - -[package.dependencies] -typing-extensions = ">=4.14.0" - [[package]] name = "lxml" version = "6.0.2" @@ -2293,14 +2284,14 @@ files = [ [[package]] name = "marshmallow" -version = "3.26.1" +version = "3.26.2" description = "A lightweight library for converting complex datatypes to and from native Python datatypes." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c"}, - {file = "marshmallow-3.26.1.tar.gz", hash = "sha256:e6d8affb6cb61d39d26402096dc0aee12d5a26d490a121f118d2e81dc0719dc6"}, + {file = "marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73"}, + {file = "marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57"}, ] [package.dependencies] @@ -2481,110 +2472,108 @@ files = [ [[package]] name = "nodeenv" -version = "1.9.1" +version = "1.10.0" description = "Node.js virtual environment builder" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["dev"] files = [ - {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, - {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, + {file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"}, + {file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"}, ] [[package]] name = "numpy" -version = "2.3.5" +version = "2.4.0" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.11" groups = ["main"] files = [ - {file = "numpy-2.3.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de5672f4a7b200c15a4127042170a694d4df43c992948f5e1af57f0174beed10"}, - {file = "numpy-2.3.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:acfd89508504a19ed06ef963ad544ec6664518c863436306153e13e94605c218"}, - {file = "numpy-2.3.5-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ffe22d2b05504f786c867c8395de703937f934272eb67586817b46188b4ded6d"}, - {file = "numpy-2.3.5-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:872a5cf366aec6bb1147336480fef14c9164b154aeb6542327de4970282cd2f5"}, - {file = "numpy-2.3.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3095bdb8dd297e5920b010e96134ed91d852d81d490e787beca7e35ae1d89cf7"}, - {file = "numpy-2.3.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cba086a43d54ca804ce711b2a940b16e452807acebe7852ff327f1ecd49b0d4"}, - {file = "numpy-2.3.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6cf9b429b21df6b99f4dee7a1218b8b7ffbbe7df8764dc0bd60ce8a0708fed1e"}, - {file = "numpy-2.3.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:396084a36abdb603546b119d96528c2f6263921c50df3c8fd7cb28873a237748"}, - {file = "numpy-2.3.5-cp311-cp311-win32.whl", hash = "sha256:b0c7088a73aef3d687c4deef8452a3ac7c1be4e29ed8bf3b366c8111128ac60c"}, - {file = "numpy-2.3.5-cp311-cp311-win_amd64.whl", hash = "sha256:a414504bef8945eae5f2d7cb7be2d4af77c5d1cb5e20b296c2c25b61dff2900c"}, - {file = "numpy-2.3.5-cp311-cp311-win_arm64.whl", hash = "sha256:0cd00b7b36e35398fa2d16af7b907b65304ef8bb4817a550e06e5012929830fa"}, - {file = "numpy-2.3.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:74ae7b798248fe62021dbf3c914245ad45d1a6b0cb4a29ecb4b31d0bfbc4cc3e"}, - {file = "numpy-2.3.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee3888d9ff7c14604052b2ca5535a30216aa0a58e948cdd3eeb8d3415f638769"}, - {file = "numpy-2.3.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:612a95a17655e213502f60cfb9bf9408efdc9eb1d5f50535cc6eb365d11b42b5"}, - {file = "numpy-2.3.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3101e5177d114a593d79dd79658650fe28b5a0d8abeb8ce6f437c0e6df5be1a4"}, - {file = "numpy-2.3.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b973c57ff8e184109db042c842423ff4f60446239bd585a5131cc47f06f789d"}, - {file = "numpy-2.3.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d8163f43acde9a73c2a33605353a4f1bc4798745a8b1d73183b28e5b435ae28"}, - {file = "numpy-2.3.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:51c1e14eb1e154ebd80e860722f9e6ed6ec89714ad2db2d3aa33c31d7c12179b"}, - {file = "numpy-2.3.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b46b4ec24f7293f23adcd2d146960559aaf8020213de8ad1909dba6c013bf89c"}, - {file = "numpy-2.3.5-cp312-cp312-win32.whl", hash = "sha256:3997b5b3c9a771e157f9aae01dd579ee35ad7109be18db0e85dbdbe1de06e952"}, - {file = "numpy-2.3.5-cp312-cp312-win_amd64.whl", hash = "sha256:86945f2ee6d10cdfd67bcb4069c1662dd711f7e2a4343db5cecec06b87cf31aa"}, - {file = "numpy-2.3.5-cp312-cp312-win_arm64.whl", hash = "sha256:f28620fe26bee16243be2b7b874da327312240a7cdc38b769a697578d2100013"}, - {file = "numpy-2.3.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d0f23b44f57077c1ede8c5f26b30f706498b4862d3ff0a7298b8411dd2f043ff"}, - {file = "numpy-2.3.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa5bc7c5d59d831d9773d1170acac7893ce3a5e130540605770ade83280e7188"}, - {file = "numpy-2.3.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ccc933afd4d20aad3c00bcef049cb40049f7f196e0397f1109dba6fed63267b0"}, - {file = "numpy-2.3.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afaffc4393205524af9dfa400fa250143a6c3bc646c08c9f5e25a9f4b4d6a903"}, - {file = "numpy-2.3.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c75442b2209b8470d6d5d8b1c25714270686f14c749028d2199c54e29f20b4d"}, - {file = "numpy-2.3.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e06aa0af8c0f05104d56450d6093ee639e15f24ecf62d417329d06e522e017"}, - {file = "numpy-2.3.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ed89927b86296067b4f81f108a2271d8926467a8868e554eaf370fc27fa3ccaf"}, - {file = "numpy-2.3.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51c55fe3451421f3a6ef9a9c1439e82101c57a2c9eab9feb196a62b1a10b58ce"}, - {file = "numpy-2.3.5-cp313-cp313-win32.whl", hash = "sha256:1978155dd49972084bd6ef388d66ab70f0c323ddee6f693d539376498720fb7e"}, - {file = "numpy-2.3.5-cp313-cp313-win_amd64.whl", hash = "sha256:00dc4e846108a382c5869e77c6ed514394bdeb3403461d25a829711041217d5b"}, - {file = "numpy-2.3.5-cp313-cp313-win_arm64.whl", hash = "sha256:0472f11f6ec23a74a906a00b48a4dcf3849209696dff7c189714511268d103ae"}, - {file = "numpy-2.3.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:414802f3b97f3c1eef41e530aaba3b3c1620649871d8cb38c6eaff034c2e16bd"}, - {file = "numpy-2.3.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5ee6609ac3604fa7780e30a03e5e241a7956f8e2fcfe547d51e3afa5247ac47f"}, - {file = "numpy-2.3.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:86d835afea1eaa143012a2d7a3f45a3adce2d7adc8b4961f0b362214d800846a"}, - {file = "numpy-2.3.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:30bc11310e8153ca664b14c5f1b73e94bd0503681fcf136a163de856f3a50139"}, - {file = "numpy-2.3.5-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1062fde1dcf469571705945b0f221b73928f34a20c904ffb45db101907c3454e"}, - {file = "numpy-2.3.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce581db493ea1a96c0556360ede6607496e8bf9b3a8efa66e06477267bc831e9"}, - {file = "numpy-2.3.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:cc8920d2ec5fa99875b670bb86ddeb21e295cb07aa331810d9e486e0b969d946"}, - {file = "numpy-2.3.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9ee2197ef8c4f0dfe405d835f3b6a14f5fee7782b5de51ba06fb65fc9b36e9f1"}, - {file = "numpy-2.3.5-cp313-cp313t-win32.whl", hash = "sha256:70b37199913c1bd300ff6e2693316c6f869c7ee16378faf10e4f5e3275b299c3"}, - {file = "numpy-2.3.5-cp313-cp313t-win_amd64.whl", hash = "sha256:b501b5fa195cc9e24fe102f21ec0a44dffc231d2af79950b451e0d99cea02234"}, - {file = "numpy-2.3.5-cp313-cp313t-win_arm64.whl", hash = "sha256:a80afd79f45f3c4a7d341f13acbe058d1ca8ac017c165d3fa0d3de6bc1a079d7"}, - {file = "numpy-2.3.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:bf06bc2af43fa8d32d30fae16ad965663e966b1a3202ed407b84c989c3221e82"}, - {file = "numpy-2.3.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:052e8c42e0c49d2575621c158934920524f6c5da05a1d3b9bab5d8e259e045f0"}, - {file = "numpy-2.3.5-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:1ed1ec893cff7040a02c8aa1c8611b94d395590d553f6b53629a4461dc7f7b63"}, - {file = "numpy-2.3.5-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2dcd0808a421a482a080f89859a18beb0b3d1e905b81e617a188bd80422d62e9"}, - {file = "numpy-2.3.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727fd05b57df37dc0bcf1a27767a3d9a78cbbc92822445f32cc3436ba797337b"}, - {file = "numpy-2.3.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fffe29a1ef00883599d1dc2c51aa2e5d80afe49523c261a74933df395c15c520"}, - {file = "numpy-2.3.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f7f0e05112916223d3f438f293abf0727e1181b5983f413dfa2fefc4098245c"}, - {file = "numpy-2.3.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2e2eb32ddb9ccb817d620ac1d8dae7c3f641c1e5f55f531a33e8ab97960a75b8"}, - {file = "numpy-2.3.5-cp314-cp314-win32.whl", hash = "sha256:66f85ce62c70b843bab1fb14a05d5737741e74e28c7b8b5a064de10142fad248"}, - {file = "numpy-2.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:e6a0bc88393d65807d751a614207b7129a310ca4fe76a74e5c7da5fa5671417e"}, - {file = "numpy-2.3.5-cp314-cp314-win_arm64.whl", hash = "sha256:aeffcab3d4b43712bb7a60b65f6044d444e75e563ff6180af8f98dd4b905dfd2"}, - {file = "numpy-2.3.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17531366a2e3a9e30762c000f2c43a9aaa05728712e25c11ce1dbe700c53ad41"}, - {file = "numpy-2.3.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d21644de1b609825ede2f48be98dfde4656aefc713654eeee280e37cadc4e0ad"}, - {file = "numpy-2.3.5-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c804e3a5aba5460c73955c955bdbd5c08c354954e9270a2c1565f62e866bdc39"}, - {file = "numpy-2.3.5-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:cc0a57f895b96ec78969c34f682c602bf8da1a0270b09bc65673df2e7638ec20"}, - {file = "numpy-2.3.5-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:900218e456384ea676e24ea6a0417f030a3b07306d29d7ad843957b40a9d8d52"}, - {file = "numpy-2.3.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09a1bea522b25109bf8e6f3027bd810f7c1085c64a0c7ce050c1676ad0ba010b"}, - {file = "numpy-2.3.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04822c00b5fd0323c8166d66c701dc31b7fbd252c100acd708c48f763968d6a3"}, - {file = "numpy-2.3.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d6889ec4ec662a1a37eb4b4fb26b6100841804dac55bd9df579e326cdc146227"}, - {file = "numpy-2.3.5-cp314-cp314t-win32.whl", hash = "sha256:93eebbcf1aafdf7e2ddd44c2923e2672e1010bddc014138b229e49725b4d6be5"}, - {file = "numpy-2.3.5-cp314-cp314t-win_amd64.whl", hash = "sha256:c8a9958e88b65c3b27e22ca2a076311636850b612d6bbfb76e8d156aacde2aaf"}, - {file = "numpy-2.3.5-cp314-cp314t-win_arm64.whl", hash = "sha256:6203fdf9f3dc5bdaed7319ad8698e685c7a3be10819f41d32a0723e611733b42"}, - {file = "numpy-2.3.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f0963b55cdd70fad460fa4c1341f12f976bb26cb66021a5580329bd498988310"}, - {file = "numpy-2.3.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f4255143f5160d0de972d28c8f9665d882b5f61309d8362fdd3e103cf7bf010c"}, - {file = "numpy-2.3.5-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:a4b9159734b326535f4dd01d947f919c6eefd2d9827466a696c44ced82dfbc18"}, - {file = "numpy-2.3.5-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2feae0d2c91d46e59fcd62784a3a83b3fb677fead592ce51b5a6fbb4f95965ff"}, - {file = "numpy-2.3.5-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffac52f28a7849ad7576293c0cb7b9f08304e8f7d738a8cb8a90ec4c55a998eb"}, - {file = "numpy-2.3.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63c0e9e7eea69588479ebf4a8a270d5ac22763cc5854e9a7eae952a3908103f7"}, - {file = "numpy-2.3.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f16417ec91f12f814b10bafe79ef77e70113a2f5f7018640e7425ff979253425"}, - {file = "numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0"}, + {file = "numpy-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:316b2f2584682318539f0bcaca5a496ce9ca78c88066579ebd11fd06f8e4741e"}, + {file = "numpy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2718c1de8504121714234b6f8241d0019450353276c88b9453c9c3d92e101db"}, + {file = "numpy-2.4.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:21555da4ec4a0c942520ead42c3b0dc9477441e085c42b0fbdd6a084869a6f6b"}, + {file = "numpy-2.4.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:413aa561266a4be2d06cd2b9665e89d9f54c543f418773076a76adcf2af08bc7"}, + {file = "numpy-2.4.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0feafc9e03128074689183031181fac0897ff169692d8492066e949041096548"}, + {file = "numpy-2.4.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8fdfed3deaf1928fb7667d96e0567cdf58c2b370ea2ee7e586aa383ec2cb346"}, + {file = "numpy-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e06a922a469cae9a57100864caf4f8a97a1026513793969f8ba5b63137a35d25"}, + {file = "numpy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:927ccf5cd17c48f801f4ed43a7e5673a2724bd2171460be3e3894e6e332ef83a"}, + {file = "numpy-2.4.0-cp311-cp311-win32.whl", hash = "sha256:882567b7ae57c1b1a0250208cc21a7976d8cbcc49d5a322e607e6f09c9e0bd53"}, + {file = "numpy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:8b986403023c8f3bf8f487c2e6186afda156174d31c175f747d8934dfddf3479"}, + {file = "numpy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:3f3096405acc48887458bbf9f6814d43785ac7ba2a57ea6442b581dedbc60ce6"}, + {file = "numpy-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a8b6bb8369abefb8bd1801b054ad50e02b3275c8614dc6e5b0373c305291037"}, + {file = "numpy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e284ca13d5a8367e43734148622caf0b261b275673823593e3e3634a6490f83"}, + {file = "numpy-2.4.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:49ff32b09f5aa0cd30a20c2b39db3e669c845589f2b7fc910365210887e39344"}, + {file = "numpy-2.4.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:36cbfb13c152b1c7c184ddac43765db8ad672567e7bafff2cc755a09917ed2e6"}, + {file = "numpy-2.4.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35ddc8f4914466e6fc954c76527aa91aa763682a4f6d73249ef20b418fe6effb"}, + {file = "numpy-2.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc578891de1db95b2a35001b695451767b580bb45753717498213c5ff3c41d63"}, + {file = "numpy-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98e81648e0b36e325ab67e46b5400a7a6d4a22b8a7c8e8bbfe20e7db7906bf95"}, + {file = "numpy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d57b5046c120561ba8fa8e4030fbb8b822f3063910fa901ffadf16e2b7128ad6"}, + {file = "numpy-2.4.0-cp312-cp312-win32.whl", hash = "sha256:92190db305a6f48734d3982f2c60fa30d6b5ee9bff10f2887b930d7b40119f4c"}, + {file = "numpy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:680060061adb2d74ce352628cb798cfdec399068aa7f07ba9fb818b2b3305f98"}, + {file = "numpy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:39699233bc72dd482da1415dcb06076e32f60eddc796a796c5fb6c5efce94667"}, + {file = "numpy-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a152d86a3ae00ba5f47b3acf3b827509fd0b6cb7d3259665e63dafbad22a75ea"}, + {file = "numpy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39b19251dec4de8ff8496cd0806cbe27bf0684f765abb1f4809554de93785f2d"}, + {file = "numpy-2.4.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:009bd0ea12d3c784b6639a8457537016ce5172109e585338e11334f6a7bb88ee"}, + {file = "numpy-2.4.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5fe44e277225fd3dff6882d86d3d447205d43532c3627313d17e754fb3905a0e"}, + {file = "numpy-2.4.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f935c4493eda9069851058fa0d9e39dbf6286be690066509305e52912714dbb2"}, + {file = "numpy-2.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cfa5f29a695cb7438965e6c3e8d06e0416060cf0d709c1b1c1653a939bf5c2a"}, + {file = "numpy-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba0cb30acd3ef11c94dc27fbfba68940652492bc107075e7ffe23057f9425681"}, + {file = "numpy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60e8c196cd82cbbd4f130b5290007e13e6de3eca79f0d4d38014769d96a7c475"}, + {file = "numpy-2.4.0-cp313-cp313-win32.whl", hash = "sha256:5f48cb3e88fbc294dc90e215d86fbaf1c852c63dbdb6c3a3e63f45c4b57f7344"}, + {file = "numpy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:a899699294f28f7be8992853c0c60741f16ff199205e2e6cdca155762cbaa59d"}, + {file = "numpy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9198f447e1dc5647d07c9a6bbe2063cc0132728cc7175b39dbc796da5b54920d"}, + {file = "numpy-2.4.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74623f2ab5cc3f7c886add4f735d1031a1d2be4a4ae63c0546cfd74e7a31ddf6"}, + {file = "numpy-2.4.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:0804a8e4ab070d1d35496e65ffd3cf8114c136a2b81f61dfab0de4b218aacfd5"}, + {file = "numpy-2.4.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:02a2038eb27f9443a8b266a66911e926566b5a6ffd1a689b588f7f35b81e7dc3"}, + {file = "numpy-2.4.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1889b3a3f47a7b5bee16bc25a2145bd7cb91897f815ce3499db64c7458b6d91d"}, + {file = "numpy-2.4.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85eef4cb5625c47ee6425c58a3502555e10f45ee973da878ac8248ad58c136f3"}, + {file = "numpy-2.4.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6dc8b7e2f4eb184b37655195f421836cfae6f58197b67e3ffc501f1333d993fa"}, + {file = "numpy-2.4.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:44aba2f0cafd287871a495fb3163408b0bd25bbce135c6f621534a07f4f7875c"}, + {file = "numpy-2.4.0-cp313-cp313t-win32.whl", hash = "sha256:20c115517513831860c573996e395707aa9fb691eb179200125c250e895fcd93"}, + {file = "numpy-2.4.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b48e35f4ab6f6a7597c46e301126ceba4c44cd3280e3750f85db48b082624fa4"}, + {file = "numpy-2.4.0-cp313-cp313t-win_arm64.whl", hash = "sha256:4d1cfce39e511069b11e67cd0bd78ceff31443b7c9e5c04db73c7a19f572967c"}, + {file = "numpy-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c95eb6db2884917d86cde0b4d4cf31adf485c8ec36bf8696dd66fa70de96f36b"}, + {file = "numpy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:65167da969cd1ec3a1df31cb221ca3a19a8aaa25370ecb17d428415e93c1935e"}, + {file = "numpy-2.4.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3de19cfecd1465d0dcf8a5b5ea8b3155b42ed0b639dba4b71e323d74f2a3be5e"}, + {file = "numpy-2.4.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6c05483c3136ac4c91b4e81903cb53a8707d316f488124d0398499a4f8e8ef51"}, + {file = "numpy-2.4.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36667db4d6c1cea79c8930ab72fadfb4060feb4bfe724141cd4bd064d2e5f8ce"}, + {file = "numpy-2.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a818668b674047fd88c4cddada7ab8f1c298812783e8328e956b78dc4807f9f"}, + {file = "numpy-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ee32359fb7543b7b7bd0b2f46294db27e29e7bbdf70541e81b190836cd83ded"}, + {file = "numpy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e493962256a38f58283de033d8af176c5c91c084ea30f15834f7545451c42059"}, + {file = "numpy-2.4.0-cp314-cp314-win32.whl", hash = "sha256:6bbaebf0d11567fa8926215ae731e1d58e6ec28a8a25235b8a47405d301332db"}, + {file = "numpy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d857f55e7fdf7c38ab96c4558c95b97d1c685be6b05c249f5fdafcbd6f9899e"}, + {file = "numpy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:bb50ce5fb202a26fd5404620e7ef820ad1ab3558b444cb0b55beb7ef66cd2d63"}, + {file = "numpy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:355354388cba60f2132df297e2d53053d4063f79077b67b481d21276d61fc4df"}, + {file = "numpy-2.4.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:1d8f9fde5f6dc1b6fc34df8162f3b3079365468703fee7f31d4e0cc8c63baed9"}, + {file = "numpy-2.4.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e0434aa22c821f44eeb4c650b81c7fbdd8c0122c6c4b5a576a76d5a35625ecd9"}, + {file = "numpy-2.4.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40483b2f2d3ba7aad426443767ff5632ec3156ef09742b96913787d13c336471"}, + {file = "numpy-2.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6a7664ddd9746e20b7325351fe1a8408d0a2bf9c63b5e898290ddc8f09544"}, + {file = "numpy-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ecb0019d44f4cdb50b676c5d0cb4b1eae8e15d1ed3d3e6639f986fc92b2ec52c"}, + {file = "numpy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d0ffd9e2e4441c96a9c91ec1783285d80bf835b677853fc2770a89d50c1e48ac"}, + {file = "numpy-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:77f0d13fa87036d7553bf81f0e1fe3ce68d14c9976c9851744e4d3e91127e95f"}, + {file = "numpy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b1f5b45829ac1848893f0ddf5cb326110604d6df96cdc255b0bf9edd154104d4"}, + {file = "numpy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:23a3e9d1a6f360267e8fbb38ba5db355a6a7e9be71d7fce7ab3125e88bb646c8"}, + {file = "numpy-2.4.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b54c83f1c0c0f1d748dca0af516062b8829d53d1f0c402be24b4257a9c48ada6"}, + {file = "numpy-2.4.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:aabb081ca0ec5d39591fc33018cd4b3f96e1a2dd6756282029986d00a785fba4"}, + {file = "numpy-2.4.0-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:8eafe7c36c8430b7794edeab3087dec7bf31d634d92f2af9949434b9d1964cba"}, + {file = "numpy-2.4.0-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2f585f52b2baf07ff3356158d9268ea095e221371f1074fadea2f42544d58b4d"}, + {file = "numpy-2.4.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32ed06d0fe9cae27d8fb5f400c63ccee72370599c75e683a6358dd3a4fb50aaf"}, + {file = "numpy-2.4.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:57c540ed8fb1f05cb997c6761cd56db72395b0d6985e90571ff660452ade4f98"}, + {file = "numpy-2.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a39fb973a726e63223287adc6dafe444ce75af952d711e400f3bf2b36ef55a7b"}, + {file = "numpy-2.4.0.tar.gz", hash = "sha256:6e504f7b16118198f138ef31ba24d985b124c2c469fe8467007cf30fd992f934"}, ] [[package]] name = "openai" -version = "2.8.1" +version = "2.14.0" description = "The official Python library for the openai API" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "openai-2.8.1-py3-none-any.whl", hash = "sha256:c6c3b5a04994734386e8dad3c00a393f56d3b68a27cd2e8acae91a59e4122463"}, - {file = "openai-2.8.1.tar.gz", hash = "sha256:cb1b79eef6e809f6da326a7ef6038719e35aa944c42d081807bfa1be8060f15f"}, + {file = "openai-2.14.0-py3-none-any.whl", hash = "sha256:7ea40aca4ffc4c4a776e77679021b47eec1160e341f42ae086ba949c9dcc9183"}, + {file = "openai-2.14.0.tar.gz", hash = "sha256:419357bedde9402d23bf8f2ee372fca1985a73348debba94bddff06f19459952"}, ] [package.dependencies] @@ -2605,169 +2594,169 @@ voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"] [[package]] name = "orjson" -version = "3.11.4" +version = "3.11.5" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "orjson-3.11.4-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e3aa2118a3ece0d25489cbe48498de8a5d580e42e8d9979f65bf47900a15aba1"}, - {file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a69ab657a4e6733133a3dca82768f2f8b884043714e8d2b9ba9f52b6efef5c44"}, - {file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3740bffd9816fc0326ddc406098a3a8f387e42223f5f455f2a02a9f834ead80c"}, - {file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65fd2f5730b1bf7f350c6dc896173d3460d235c4be007af73986d7cd9a2acd23"}, - {file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fdc3ae730541086158d549c97852e2eea6820665d4faf0f41bf99df41bc11ea"}, - {file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e10b4d65901da88845516ce9f7f9736f9638d19a1d483b3883dc0182e6e5edba"}, - {file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb6a03a678085f64b97f9d4a9ae69376ce91a3a9e9b56a82b1580d8e1d501aff"}, - {file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2c82e4f0b1c712477317434761fbc28b044c838b6b1240d895607441412371ac"}, - {file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d58c166a18f44cc9e2bad03a327dc2d1a3d2e85b847133cfbafd6bfc6719bd79"}, - {file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94f206766bf1ea30e1382e4890f763bd1eefddc580e08fec1ccdc20ddd95c827"}, - {file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:41bf25fb39a34cf8edb4398818523277ee7096689db352036a9e8437f2f3ee6b"}, - {file = "orjson-3.11.4-cp310-cp310-win32.whl", hash = "sha256:fa9627eba4e82f99ca6d29bc967f09aba446ee2b5a1ea728949ede73d313f5d3"}, - {file = "orjson-3.11.4-cp310-cp310-win_amd64.whl", hash = "sha256:23ef7abc7fca96632d8174ac115e668c1e931b8fe4dde586e92a500bf1914dcc"}, - {file = "orjson-3.11.4-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5e59d23cd93ada23ec59a96f215139753fbfe3a4d989549bcb390f8c00370b39"}, - {file = "orjson-3.11.4-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5c3aedecfc1beb988c27c79d52ebefab93b6c3921dbec361167e6559aba2d36d"}, - {file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da9e5301f1c2caa2a9a4a303480d79c9ad73560b2e7761de742ab39fe59d9175"}, - {file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8873812c164a90a79f65368f8f96817e59e35d0cc02786a5356f0e2abed78040"}, - {file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5d7feb0741ebb15204e748f26c9638e6665a5fa93c37a2c73d64f1669b0ddc63"}, - {file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ee5487fefee21e6910da4c2ee9eef005bee568a0879834df86f888d2ffbdd9"}, - {file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d40d46f348c0321df01507f92b95a377240c4ec31985225a6668f10e2676f9a"}, - {file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95713e5fc8af84d8edc75b785d2386f653b63d62b16d681687746734b4dfc0be"}, - {file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad73ede24f9083614d6c4ca9a85fe70e33be7bf047ec586ee2363bc7418fe4d7"}, - {file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:842289889de515421f3f224ef9c1f1efb199a32d76d8d2ca2706fa8afe749549"}, - {file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3b2427ed5791619851c52a1261b45c233930977e7de8cf36de05636c708fa905"}, - {file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c36e524af1d29982e9b190573677ea02781456b2e537d5840e4538a5ec41907"}, - {file = "orjson-3.11.4-cp311-cp311-win32.whl", hash = "sha256:87255b88756eab4a68ec61837ca754e5d10fa8bc47dc57f75cedfeaec358d54c"}, - {file = "orjson-3.11.4-cp311-cp311-win_amd64.whl", hash = "sha256:e2d5d5d798aba9a0e1fede8d853fa899ce2cb930ec0857365f700dffc2c7af6a"}, - {file = "orjson-3.11.4-cp311-cp311-win_arm64.whl", hash = "sha256:6bb6bb41b14c95d4f2702bce9975fda4516f1db48e500102fc4d8119032ff045"}, - {file = "orjson-3.11.4-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d4371de39319d05d3f482f372720b841c841b52f5385bd99c61ed69d55d9ab50"}, - {file = "orjson-3.11.4-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e41fd3b3cac850eaae78232f37325ed7d7436e11c471246b87b2cd294ec94853"}, - {file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600e0e9ca042878c7fdf189cf1b028fe2c1418cc9195f6cb9824eb6ed99cb938"}, - {file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7bbf9b333f1568ef5da42bc96e18bf30fd7f8d54e9ae066d711056add508e415"}, - {file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4806363144bb6e7297b8e95870e78d30a649fdc4e23fc84daa80c8ebd366ce44"}, - {file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad355e8308493f527d41154e9053b86a5be892b3b359a5c6d5d95cda23601cb2"}, - {file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a7517482667fb9f0ff1b2f16fe5829296ed7a655d04d68cd9711a4d8a4e708"}, - {file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97eb5942c7395a171cbfecc4ef6701fc3c403e762194683772df4c54cfbb2210"}, - {file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:149d95d5e018bdd822e3f38c103b1a7c91f88d38a88aada5c4e9b3a73a244241"}, - {file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:624f3951181eb46fc47dea3d221554e98784c823e7069edb5dbd0dc826ac909b"}, - {file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:03bfa548cf35e3f8b3a96c4e8e41f753c686ff3d8e182ce275b1751deddab58c"}, - {file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:525021896afef44a68148f6ed8a8bf8375553d6066c7f48537657f64823565b9"}, - {file = "orjson-3.11.4-cp312-cp312-win32.whl", hash = "sha256:b58430396687ce0f7d9eeb3dd47761ca7d8fda8e9eb92b3077a7a353a75efefa"}, - {file = "orjson-3.11.4-cp312-cp312-win_amd64.whl", hash = "sha256:c6dbf422894e1e3c80a177133c0dda260f81428f9de16d61041949f6a2e5c140"}, - {file = "orjson-3.11.4-cp312-cp312-win_arm64.whl", hash = "sha256:d38d2bc06d6415852224fcc9c0bfa834c25431e466dc319f0edd56cca81aa96e"}, - {file = "orjson-3.11.4-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2d6737d0e616a6e053c8b4acc9eccea6b6cce078533666f32d140e4f85002534"}, - {file = "orjson-3.11.4-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:afb14052690aa328cc118a8e09f07c651d301a72e44920b887c519b313d892ff"}, - {file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38aa9e65c591febb1b0aed8da4d469eba239d434c218562df179885c94e1a3ad"}, - {file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f2cf4dfaf9163b0728d061bebc1e08631875c51cd30bf47cb9e3293bfbd7dcd5"}, - {file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89216ff3dfdde0e4070932e126320a1752c9d9a758d6a32ec54b3b9334991a6a"}, - {file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9daa26ca8e97fae0ce8aa5d80606ef8f7914e9b129b6b5df9104266f764ce436"}, - {file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c8b2769dc31883c44a9cd126560327767f848eb95f99c36c9932f51090bfce9"}, - {file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1469d254b9884f984026bd9b0fa5bbab477a4bfe558bba6848086f6d43eb5e73"}, - {file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:68e44722541983614e37117209a194e8c3ad07838ccb3127d96863c95ec7f1e0"}, - {file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e7805fda9672c12be2f22ae124dcd7b03928d6c197544fe12174b86553f3196"}, - {file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:04b69c14615fb4434ab867bf6f38b2d649f6f300af30a6705397e895f7aec67a"}, - {file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:639c3735b8ae7f970066930e58cf0ed39a852d417c24acd4a25fc0b3da3c39a6"}, - {file = "orjson-3.11.4-cp313-cp313-win32.whl", hash = "sha256:6c13879c0d2964335491463302a6ca5ad98105fc5db3565499dcb80b1b4bd839"}, - {file = "orjson-3.11.4-cp313-cp313-win_amd64.whl", hash = "sha256:09bf242a4af98732db9f9a1ec57ca2604848e16f132e3f72edfd3c5c96de009a"}, - {file = "orjson-3.11.4-cp313-cp313-win_arm64.whl", hash = "sha256:a85f0adf63319d6c1ba06fb0dbf997fced64a01179cf17939a6caca662bf92de"}, - {file = "orjson-3.11.4-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:42d43a1f552be1a112af0b21c10a5f553983c2a0938d2bbb8ecd8bc9fb572803"}, - {file = "orjson-3.11.4-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:26a20f3fbc6c7ff2cb8e89c4c5897762c9d88cf37330c6a117312365d6781d54"}, - {file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e3f20be9048941c7ffa8fc523ccbd17f82e24df1549d1d1fe9317712d19938e"}, - {file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aac364c758dc87a52e68e349924d7e4ded348dedff553889e4d9f22f74785316"}, - {file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d5c54a6d76e3d741dcc3f2707f8eeb9ba2a791d3adbf18f900219b62942803b1"}, - {file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f28485bdca8617b79d44627f5fb04336897041dfd9fa66d383a49d09d86798bc"}, - {file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bfc2a484cad3585e4ba61985a6062a4c2ed5c7925db6d39f1fa267c9d166487f"}, - {file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e34dbd508cb91c54f9c9788923daca129fe5b55c5b4eebe713bf5ed3791280cf"}, - {file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b13c478fa413d4b4ee606ec8e11c3b2e52683a640b006bb586b3041c2ca5f606"}, - {file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:724ca721ecc8a831b319dcd72cfa370cc380db0bf94537f08f7edd0a7d4e1780"}, - {file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:977c393f2e44845ce1b540e19a786e9643221b3323dae190668a98672d43fb23"}, - {file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e539e382cf46edec157ad66b0b0872a90d829a6b71f17cb633d6c160a223155"}, - {file = "orjson-3.11.4-cp314-cp314-win32.whl", hash = "sha256:d63076d625babab9db5e7836118bdfa086e60f37d8a174194ae720161eb12394"}, - {file = "orjson-3.11.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a54d6635fa3aaa438ae32e8570b9f0de36f3f6562c308d2a2a452e8b0592db1"}, - {file = "orjson-3.11.4-cp314-cp314-win_arm64.whl", hash = "sha256:78b999999039db3cf58f6d230f524f04f75f129ba3d1ca2ed121f8657e575d3d"}, - {file = "orjson-3.11.4-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:405261b0a8c62bcbd8e2931c26fdc08714faf7025f45531541e2b29e544b545b"}, - {file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af02ff34059ee9199a3546f123a6ab4c86caf1708c79042caf0820dc290a6d4f"}, - {file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b2eba969ea4203c177c7b38b36c69519e6067ee68c34dc37081fac74c796e10"}, - {file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0baa0ea43cfa5b008a28d3c07705cf3ada40e5d347f0f44994a64b1b7b4b5350"}, - {file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80fd082f5dcc0e94657c144f1b2a3a6479c44ad50be216cf0c244e567f5eae19"}, - {file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e3704d35e47d5bee811fb1cbd8599f0b4009b14d451c4c57be5a7e25eb89a13"}, - {file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:caa447f2b5356779d914658519c874cf3b7629e99e63391ed519c28c8aea4919"}, - {file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bba5118143373a86f91dadb8df41d9457498226698ebdf8e11cbb54d5b0e802d"}, - {file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:622463ab81d19ef3e06868b576551587de8e4d518892d1afab71e0fbc1f9cffc"}, - {file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3e0a700c4b82144b72946b6629968df9762552ee1344bfdb767fecdd634fbd5a"}, - {file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6e18a5c15e764e5f3fc569b47872450b4bcea24f2a6354c0a0e95ad21045d5a9"}, - {file = "orjson-3.11.4-cp39-cp39-win32.whl", hash = "sha256:fb1c37c71cad991ef4d89c7a634b5ffb4447dbd7ae3ae13e8f5ee7f1775e7ab1"}, - {file = "orjson-3.11.4-cp39-cp39-win_amd64.whl", hash = "sha256:e2985ce8b8c42d00492d0ed79f2bd2b6460d00f2fa671dfde4bf2e02f49bf5c6"}, - {file = "orjson-3.11.4.tar.gz", hash = "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d"}, + {file = "orjson-3.11.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:df9eadb2a6386d5ea2bfd81309c505e125cfc9ba2b1b99a97e60985b0b3665d1"}, + {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc70da619744467d8f1f49a8cadae5ec7bbe054e5232d95f92ed8737f8c5870"}, + {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:073aab025294c2f6fc0807201c76fdaed86f8fc4be52c440fb78fbb759a1ac09"}, + {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:835f26fa24ba0bb8c53ae2a9328d1706135b74ec653ed933869b74b6909e63fd"}, + {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667c132f1f3651c14522a119e4dd631fad98761fa960c55e8e7430bb2a1ba4ac"}, + {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42e8961196af655bb5e63ce6c60d25e8798cd4dfbc04f4203457fa3869322c2e"}, + {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75412ca06e20904c19170f8a24486c4e6c7887dea591ba18a1ab572f1300ee9f"}, + {file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6af8680328c69e15324b5af3ae38abbfcf9cbec37b5346ebfd52339c3d7e8a18"}, + {file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a86fe4ff4ea523eac8f4b57fdac319faf037d3c1be12405e6a7e86b3fbc4756a"}, + {file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e607b49b1a106ee2086633167033afbd63f76f2999e9236f638b06b112b24ea7"}, + {file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7339f41c244d0eea251637727f016b3d20050636695bc78345cce9029b189401"}, + {file = "orjson-3.11.5-cp310-cp310-win32.whl", hash = "sha256:8be318da8413cdbbce77b8c5fac8d13f6eb0f0db41b30bb598631412619572e8"}, + {file = "orjson-3.11.5-cp310-cp310-win_amd64.whl", hash = "sha256:b9f86d69ae822cabc2a0f6c099b43e8733dda788405cba2665595b7e8dd8d167"}, + {file = "orjson-3.11.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9c8494625ad60a923af6b2b0bd74107146efe9b55099e20d7740d995f338fcd8"}, + {file = "orjson-3.11.5-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:7bb2ce0b82bc9fd1168a513ddae7a857994b780b2945a8c51db4ab1c4b751ebc"}, + {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67394d3becd50b954c4ecd24ac90b5051ee7c903d167459f93e77fc6f5b4c968"}, + {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:298d2451f375e5f17b897794bcc3e7b821c0f32b4788b9bcae47ada24d7f3cf7"}, + {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa5e4244063db8e1d87e0f54c3f7522f14b2dc937e65d5241ef0076a096409fd"}, + {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1db2088b490761976c1b2e956d5d4e6409f3732e9d79cfa69f876c5248d1baf9"}, + {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2ed66358f32c24e10ceea518e16eb3549e34f33a9d51f99ce23b0251776a1ef"}, + {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2021afda46c1ed64d74b555065dbd4c2558d510d8cec5ea6a53001b3e5e82a9"}, + {file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b42ffbed9128e547a1647a3e50bc88ab28ae9daa61713962e0d3dd35e820c125"}, + {file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8d5f16195bb671a5dd3d1dbea758918bada8f6cc27de72bd64adfbd748770814"}, + {file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c0e5d9f7a0227df2927d343a6e3859bebf9208b427c79bd31949abcc2fa32fa5"}, + {file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23d04c4543e78f724c4dfe656b3791b5f98e4c9253e13b2636f1af5d90e4a880"}, + {file = "orjson-3.11.5-cp311-cp311-win32.whl", hash = "sha256:c404603df4865f8e0afe981aa3c4b62b406e6d06049564d58934860b62b7f91d"}, + {file = "orjson-3.11.5-cp311-cp311-win_amd64.whl", hash = "sha256:9645ef655735a74da4990c24ffbd6894828fbfa117bc97c1edd98c282ecb52e1"}, + {file = "orjson-3.11.5-cp311-cp311-win_arm64.whl", hash = "sha256:1cbf2735722623fcdee8e712cbaaab9e372bbcb0c7924ad711b261c2eccf4a5c"}, + {file = "orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d"}, + {file = "orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626"}, + {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f"}, + {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85"}, + {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9"}, + {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626"}, + {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa"}, + {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477"}, + {file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e"}, + {file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69"}, + {file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3"}, + {file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca"}, + {file = "orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98"}, + {file = "orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875"}, + {file = "orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe"}, + {file = "orjson-3.11.5-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3b01799262081a4c47c035dd77c1301d40f568f77cc7ec1bb7db5d63b0a01629"}, + {file = "orjson-3.11.5-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:61de247948108484779f57a9f406e4c84d636fa5a59e411e6352484985e8a7c3"}, + {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894aea2e63d4f24a7f04a1908307c738d0dce992e9249e744b8f4e8dd9197f39"}, + {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddc21521598dbe369d83d4d40338e23d4101dad21dae0e79fa20465dbace019f"}, + {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cce16ae2f5fb2c53c3eafdd1706cb7b6530a67cc1c17abe8ec747f5cd7c0c51"}, + {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e46c762d9f0e1cfb4ccc8515de7f349abbc95b59cb5a2bd68df5973fdef913f8"}, + {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7345c759276b798ccd6d77a87136029e71e66a8bbf2d2755cbdde1d82e78706"}, + {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75bc2e59e6a2ac1dd28901d07115abdebc4563b5b07dd612bf64260a201b1c7f"}, + {file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54aae9b654554c3b4edd61896b978568c6daa16af96fa4681c9b5babd469f863"}, + {file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4bdd8d164a871c4ec773f9de0f6fe8769c2d6727879c37a9666ba4183b7f8228"}, + {file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a261fef929bcf98a60713bf5e95ad067cea16ae345d9a35034e73c3990e927d2"}, + {file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c028a394c766693c5c9909dec76b24f37e6a1b91999e8d0c0d5feecbe93c3e05"}, + {file = "orjson-3.11.5-cp313-cp313-win32.whl", hash = "sha256:2cc79aaad1dfabe1bd2d50ee09814a1253164b3da4c00a78c458d82d04b3bdef"}, + {file = "orjson-3.11.5-cp313-cp313-win_amd64.whl", hash = "sha256:ff7877d376add4e16b274e35a3f58b7f37b362abf4aa31863dadacdd20e3a583"}, + {file = "orjson-3.11.5-cp313-cp313-win_arm64.whl", hash = "sha256:59ac72ea775c88b163ba8d21b0177628bd015c5dd060647bbab6e22da3aad287"}, + {file = "orjson-3.11.5-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e446a8ea0a4c366ceafc7d97067bfd55292969143b57e3c846d87fc701e797a0"}, + {file = "orjson-3.11.5-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:53deb5addae9c22bbe3739298f5f2196afa881ea75944e7720681c7080909a81"}, + {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd00d49d6063d2b8791da5d4f9d20539c5951f965e45ccf4e96d33505ce68f"}, + {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fd15f9fc8c203aeceff4fda211157fad114dde66e92e24097b3647a08f4ee9e"}, + {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9df95000fbe6777bf9820ae82ab7578e8662051bb5f83d71a28992f539d2cda7"}, + {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a8d676748fca47ade5bc3da7430ed7767afe51b2f8100e3cd65e151c0eaceb"}, + {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa0f513be38b40234c77975e68805506cad5d57b3dfd8fe3baa7f4f4051e15b4"}, + {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1863e75b92891f553b7922ce4ee10ed06db061e104f2b7815de80cdcb135ad"}, + {file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4be86b58e9ea262617b8ca6251a2f0d63cc132a6da4b5fcc8e0a4128782c829"}, + {file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b923c1c13fa02084eb38c9c065afd860a5cff58026813319a06949c3af5732ac"}, + {file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1b6bd351202b2cd987f35a13b5e16471cf4d952b42a73c391cc537974c43ef6d"}, + {file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb150d529637d541e6af06bbe3d02f5498d628b7f98267ff87647584293ab439"}, + {file = "orjson-3.11.5-cp314-cp314-win32.whl", hash = "sha256:9cc1e55c884921434a84a0c3dd2699eb9f92e7b441d7f53f3941079ec6ce7499"}, + {file = "orjson-3.11.5-cp314-cp314-win_amd64.whl", hash = "sha256:a4f3cb2d874e03bc7767c8f88adaa1a9a05cecea3712649c3b58589ec7317310"}, + {file = "orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5"}, + {file = "orjson-3.11.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1b280e2d2d284a6713b0cfec7b08918ebe57df23e3f76b27586197afca3cb1e9"}, + {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c8d8a112b274fae8c5f0f01954cb0480137072c271f3f4958127b010dfefaec"}, + {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f0a2ae6f09ac7bd47d2d5a5305c1d9ed08ac057cda55bb0a49fa506f0d2da00"}, + {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0d87bd1896faac0d10b4f849016db81a63e4ec5df38757ffae84d45ab38aa71"}, + {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:801a821e8e6099b8c459ac7540b3c32dba6013437c57fdcaec205b169754f38c"}, + {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a0f6ac618c98c74b7fbc8c0172ba86f9e01dbf9f62aa0b1776c2231a7bffe5"}, + {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fea7339bdd22e6f1060c55ac31b6a755d86a5b2ad3657f2669ec243f8e3b2bdb"}, + {file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4dad582bc93cef8f26513e12771e76385a7e6187fd713157e971c784112aad56"}, + {file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:0522003e9f7fba91982e83a97fec0708f5a714c96c4209db7104e6b9d132f111"}, + {file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:7403851e430a478440ecc1258bcbacbfbd8175f9ac1e39031a7121dd0de05ff8"}, + {file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5f691263425d3177977c8d1dd896cde7b98d93cbf390b2544a090675e83a6a0a"}, + {file = "orjson-3.11.5-cp39-cp39-win32.whl", hash = "sha256:61026196a1c4b968e1b1e540563e277843082e9e97d78afa03eb89315af531f1"}, + {file = "orjson-3.11.5-cp39-cp39-win_amd64.whl", hash = "sha256:09b94b947ac08586af635ef922d69dc9bc63321527a3a04647f4986a73f4bd30"}, + {file = "orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5"}, ] [[package]] name = "ormsgpack" -version = "1.12.0" -description = "" +version = "1.12.1" +description = "Fast, correct Python msgpack library supporting dataclasses, datetimes, and numpy" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "ormsgpack-1.12.0-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e08904c232358b94a682ccfbb680bc47d3fd5c424bb7dccb65974dd20c95e8e1"}, - {file = "ormsgpack-1.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9ed7a4b0037d69c8ba7e670e03ee65ae8d5c5114a409e73c5770d7fb5e4b895"}, - {file = "ormsgpack-1.12.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:db2928525b684f3f2af0367aef7ae8d20cde37fc5349c700017129d493a755aa"}, - {file = "ormsgpack-1.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45f911d9c5b23d11e49ff03fc8f9566745a2b1a7d9033733a1c0a2fa9301cd60"}, - {file = "ormsgpack-1.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:98c54ae6fd682b2aceb264505af9b2255f3df9d84e6e4369bc44d2110f1f311d"}, - {file = "ormsgpack-1.12.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:857ab987c3502de08258cc4baf0e87267cb2c80931601084e13df3c355b1ab9d"}, - {file = "ormsgpack-1.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:27579d45dc502ee736238e1024559cb0a01aa72a3b68827448b8edf6a2dcdc9c"}, - {file = "ormsgpack-1.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:c78379d054760875540cf2e81f28da1bb78d09fda3eabdbeb6c53b3e297158cb"}, - {file = "ormsgpack-1.12.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c40d86d77391b18dd34de5295e3de2b8ad818bcab9c9def4121c8ec5c9714ae4"}, - {file = "ormsgpack-1.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:777b7fab364dc0f200bb382a98a385c8222ffa6a2333d627d763797326202c86"}, - {file = "ormsgpack-1.12.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5b5089ad9dd5b3d3013b245a55e4abaea2f8ad70f4a78e1b002127b02340004"}, - {file = "ormsgpack-1.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:deaf0c87cace7bc08fbf68c5cc66605b593df6427e9f4de235b2da358787e008"}, - {file = "ormsgpack-1.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f62d476fe28bc5675d9aff30341bfa9f41d7de332c5b63fbbe9aaf6bb7ec74d4"}, - {file = "ormsgpack-1.12.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ded7810095b887e28434f32f5a345d354e88cf851bab3c5435aeb86a718618d2"}, - {file = "ormsgpack-1.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f72a1dea0c4ae7c4101dcfbe8133f274a9d769d0b87fe5188db4fab07ffabaee"}, - {file = "ormsgpack-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:8f479bfef847255d7d0b12c7a198f6a21490155da2da3062e082ba370893d4a1"}, - {file = "ormsgpack-1.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:3583ca410e4502144b2594170542e4bbef7b15643fd1208703ae820f11029036"}, - {file = "ormsgpack-1.12.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e0c1e08b64d99076fee155276097489b82cc56e8d5951c03c721a65a32f44494"}, - {file = "ormsgpack-1.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fd43bcb299131690b8e0677af172020b2ada8e625169034b42ac0c13adf84aa"}, - {file = "ormsgpack-1.12.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f0149d595341e22ead340bf281b2995c4cc7dc8d522a6b5f575fe17aa407604"}, - {file = "ormsgpack-1.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f19a1b27d169deb553c80fd10b589fc2be1fc14cee779fae79fcaf40db04de2b"}, - {file = "ormsgpack-1.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6f28896942d655064940dfe06118b7ce1e3468d051483148bf02c99ec157483a"}, - {file = "ormsgpack-1.12.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9396efcfa48b4abbc06e44c5dbc3c4574a8381a80cb4cd01eea15d28b38c554e"}, - {file = "ormsgpack-1.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96586ed537a5fb386a162c4f9f7d8e6f76e07b38a990d50c73f11131e00ff040"}, - {file = "ormsgpack-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:e70387112fb3870e4844de090014212cdcf1342f5022047aecca01ec7de05d7a"}, - {file = "ormsgpack-1.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:d71290a23de5d4829610c42665d816c661ecad8979883f3f06b2e3ab9639962e"}, - {file = "ormsgpack-1.12.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:766f2f3b512d85cd375b26a8b1329b99843560b50b93d3880718e634ad4a5de5"}, - {file = "ormsgpack-1.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84b285b1f3f185aad7da45641b873b30acfd13084cf829cf668c4c6480a81583"}, - {file = "ormsgpack-1.12.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e23604fc79fe110292cb365f4c8232e64e63a34f470538be320feae3921f271b"}, - {file = "ormsgpack-1.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc32b156c113a0fae2975051417d8d9a7a5247c34b2d7239410c46b75ce9348a"}, - {file = "ormsgpack-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:94ac500dd10c20fa8b8a23bc55606250bfe711bf9716828d9f3d44dfd1f25668"}, - {file = "ormsgpack-1.12.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:c5201ff7ec24f721f813a182885a17064cffdbe46b2412685a52e6374a872c8f"}, - {file = "ormsgpack-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a9740bb3839c9368aacae1cbcfc474ee6976458f41cc135372b7255d5206c953"}, - {file = "ormsgpack-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ed37f29772432048b58174e920a1d4c4cde0404a5d448d3d8bbcc95d86a6918"}, - {file = "ormsgpack-1.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:b03994bbec5d6d42e03d6604e327863f885bde67aa61e06107ce1fa5bdd3e71d"}, - {file = "ormsgpack-1.12.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0f3981ba3cba80656012090337e548e597799e14b41e3d0b595ab5ab05a23d7f"}, - {file = "ormsgpack-1.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:901f6f55184d6776dbd5183cbce14caf05bf7f467eef52faf9b094686980bf71"}, - {file = "ormsgpack-1.12.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e13b15412571422b711b40f45e3fe6d993ea3314b5e97d1a853fe99226c5effc"}, - {file = "ormsgpack-1.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91fa8a452553a62e5fb3fbab471e7faf7b3bec3c87a2f355ebf3d7aab290fe4f"}, - {file = "ormsgpack-1.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74ec101f69624695eec4ce7c953192d97748254abe78fb01b591f06d529e1952"}, - {file = "ormsgpack-1.12.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9bbf7896580848326c1f9bd7531f264e561f98db7e08e15aa75963d83832c717"}, - {file = "ormsgpack-1.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7567917da613b8f8d591c1674e411fd3404bea41ef2b9a0e0a1e049c0f9406d7"}, - {file = "ormsgpack-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e418256c5d8622b8bc92861936f7c6a0131355e7bcad88a42102ae8227f8a1c"}, - {file = "ormsgpack-1.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:433ace29aa02713554f714c62a4e4dcad0c9e32674ba4f66742c91a4c3b1b969"}, - {file = "ormsgpack-1.12.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e57164be4ca34b64e210ec515059193280ac84df4d6f31a6fcbfb2fc8436de55"}, - {file = "ormsgpack-1.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:904f96289deaa92fc6440b122edc27c5bdc28234edd63717f6d853d88c823a83"}, - {file = "ormsgpack-1.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b291d086e524a1062d57d1b7b5a8bcaaf29caebf0212fec12fd86240bd33633"}, - {file = "ormsgpack-1.12.0.tar.gz", hash = "sha256:94be818fdbb0285945839b88763b269987787cb2f7ef280cad5d6ec815b7e608"}, + {file = "ormsgpack-1.12.1-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:62e3614cab63fa5aa42f5f0ca3cd12899f0bfc5eb8a5a0ebab09d571c89d427d"}, + {file = "ormsgpack-1.12.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86d9fbf85c05c69c33c229d2eba7c8c3500a56596cd8348131c918acd040d6af"}, + {file = "ormsgpack-1.12.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8d246e66f09d8e0f96e770829149ee83206e90ed12f5987998bb7be84aec99fe"}, + {file = "ormsgpack-1.12.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfc2c830a1ed2d00de713d08c9e62efa699e8fd29beafa626aaebe466f583ebb"}, + {file = "ormsgpack-1.12.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bc892757d8f9eea5208268a527cf93c98409802f6a9f7c8d71a7b8f9ba5cb944"}, + {file = "ormsgpack-1.12.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:0de1dbcf11ea739ac4a882b43d5c2055e6d99ce64e8d6502e25d6d881700c017"}, + {file = "ormsgpack-1.12.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d5065dfb9ec4db93241c60847624d9aeef4ccb449c26a018c216b55c69be83c0"}, + {file = "ormsgpack-1.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:7d17103c4726181d7000c61b751c881f1b6f401d146df12da028fc730227df19"}, + {file = "ormsgpack-1.12.1-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:4038f59ae0e19dac5e5d9aae4ec17ff84a79e046342ee73ccdecf3547ecf0d34"}, + {file = "ormsgpack-1.12.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16c63b0c5a3eec467e4bb33a14dabba076b7d934dff62898297b5c0b5f7c3cb3"}, + {file = "ormsgpack-1.12.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:74fd6a8e037eb310dda865298e8d122540af00fe5658ec18b97a1d34f4012e4d"}, + {file = "ormsgpack-1.12.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58ad60308e233dd824a1859eabb5fe092e123e885eafa4ad5789322329c80fb5"}, + {file = "ormsgpack-1.12.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:35127464c941c1219acbe1a220e48d55e7933373d12257202f4042f7044b4c90"}, + {file = "ormsgpack-1.12.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c48d1c50794692d1e6e3f8c3bb65f5c3acfaae9347e506484a65d60b3d91fb50"}, + {file = "ormsgpack-1.12.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b512b2ad6feaaefdc26e05431ed2843e42483041e354e167c53401afaa83d919"}, + {file = "ormsgpack-1.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:93f30db95e101a9616323bfc50807ad00e7f6197cea2216d2d24af42afc77d88"}, + {file = "ormsgpack-1.12.1-cp311-cp311-win_arm64.whl", hash = "sha256:d75b5fa14f6abffce2c392ee03b4731199d8a964c81ee8645c4c79af0e80fd50"}, + {file = "ormsgpack-1.12.1-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:4d7fb0e1b6fbc701d75269f7405a4f79230a6ce0063fb1092e4f6577e312f86d"}, + {file = "ormsgpack-1.12.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43a9353e2db5b024c91a47d864ef15eaa62d81824cfc7740fed4cef7db738694"}, + {file = "ormsgpack-1.12.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc8fe866b7706fc25af0adf1f600bc06ece5b15ca44e34641327198b821e5c3c"}, + {file = "ormsgpack-1.12.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:813755b5f598a78242042e05dfd1ada4e769e94b98c9ab82554550f97ff4d641"}, + {file = "ormsgpack-1.12.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8eea2a13536fae45d78f93f2cc846c9765c7160c85f19cfefecc20873c137cdd"}, + {file = "ormsgpack-1.12.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7a02ebda1a863cbc604740e76faca8eee1add322db2dcbe6cf32669fffdff65c"}, + {file = "ormsgpack-1.12.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3c0bd63897c439931cdf29348e5e6e8c330d529830e848d10767615c0f3d1b82"}, + {file = "ormsgpack-1.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:362f2e812f8d7035dc25a009171e09d7cc97cb30d3c9e75a16aeae00ca3c1dcf"}, + {file = "ormsgpack-1.12.1-cp312-cp312-win_arm64.whl", hash = "sha256:6190281e381db2ed0045052208f47a995ccf61eed48f1215ae3cce3fbccd59c5"}, + {file = "ormsgpack-1.12.1-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:9663d6b3ecc917c063d61a99169ce196a80f3852e541ae404206836749459279"}, + {file = "ormsgpack-1.12.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32e85cfbaf01a94a92520e7fe7851cfcfe21a5698299c28ab86194895f9b9233"}, + {file = "ormsgpack-1.12.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dabfd2c24b59c7c69870a5ecee480dfae914a42a0c2e7c9d971cf531e2ba471a"}, + {file = "ormsgpack-1.12.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51bbf2b64afeded34ccd8e25402e4bca038757913931fa0d693078d75563f6f9"}, + {file = "ormsgpack-1.12.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9959a71dde1bd0ced84af17facc06a8afada495a34e9cb1bad8e9b20d4c59cef"}, + {file = "ormsgpack-1.12.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:e9be0e3b62d758f21f5b20e0e06b3a240ec546c4a327bf771f5825462aa74714"}, + {file = "ormsgpack-1.12.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a29d49ab7fdd77ea787818e60cb4ef491708105b9c4c9b0f919201625eb036b5"}, + {file = "ormsgpack-1.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:c418390b47a1d367e803f6c187f77e4d67c7ae07ba962e3a4a019001f4b0291a"}, + {file = "ormsgpack-1.12.1-cp313-cp313-win_arm64.whl", hash = "sha256:cfa22c91cffc10a7fbd43729baff2de7d9c28cef2509085a704168ae31f02568"}, + {file = "ormsgpack-1.12.1-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b93c91efb1a70751a1902a5b43b27bd8fd38e0ca0365cf2cde2716423c15c3a6"}, + {file = "ormsgpack-1.12.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf0ea0389167b5fa8d2933dd3f33e887ec4ba68f89c25214d7eec4afd746d22"}, + {file = "ormsgpack-1.12.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4c29af837f35af3375070689e781161e7cf019eb2f7cd641734ae45cd001c0d"}, + {file = "ormsgpack-1.12.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:336fc65aa0fe65896a3dabaae31e332a0a98b4a00ad7b0afde21a7505fd23ff3"}, + {file = "ormsgpack-1.12.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:940f60aabfefe71dd6b82cb33f4ff10b2e7f5fcfa5f103cdb0a23b6aae4c713c"}, + {file = "ormsgpack-1.12.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:596ad9e1b6d4c95595c54aaf49b1392609ca68f562ce06f4f74a5bc4053bcda4"}, + {file = "ormsgpack-1.12.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:575210e8fcbc7b0375026ba040a5eef223e9f66a4453d9623fc23282ae09c3c8"}, + {file = "ormsgpack-1.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:647daa3718572280893456be44c60aea6690b7f2edc54c55648ee66e8f06550f"}, + {file = "ormsgpack-1.12.1-cp314-cp314-win_arm64.whl", hash = "sha256:a8b3ab762a6deaf1b6490ab46dda0c51528cf8037e0246c40875c6fe9e37b699"}, + {file = "ormsgpack-1.12.1-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:12087214e436c1f6c28491949571abea759a63111908c4f7266586d78144d7a8"}, + {file = "ormsgpack-1.12.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e6d54c14cf86ef13f10ccade94d1e7de146aa9b17d371e18b16e95f329393b7"}, + {file = "ormsgpack-1.12.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f3584d07882b7ea2a1a589f795a3af97fe4c2932b739408e6d1d9d286cad862"}, + {file = "ormsgpack-1.12.1.tar.gz", hash = "sha256:a3877fde1e4f27a39f92681a0aab6385af3a41d0c25375d33590ae20410ea2ac"}, ] [[package]] name = "owasp-schema" -version = "0.1.43" +version = "0.1.51" description = "A collection of OWASP schemas" optional = false python-versions = "<4.0,>=3.13" groups = ["main"] files = [ - {file = "owasp_schema-0.1.43-py3-none-any.whl", hash = "sha256:8200bac18422435cde4f5a2c7a4ed1a159c7e7adc4a4797cdb30a6d0fe3e4694"}, - {file = "owasp_schema-0.1.43.tar.gz", hash = "sha256:ad7d2fdd3242852ac14851a8597d6c5967f52cad7a9dc28a6f992740fcc88490"}, + {file = "owasp_schema-0.1.51-py3-none-any.whl", hash = "sha256:c0799bd73249bf7d2c9ec7a9793b36657448f4a668bfc8b6002086d057079d8e"}, + {file = "owasp_schema-0.1.51.tar.gz", hash = "sha256:7321066d2ae1f6126d249f069890c87dd76310b3f65e7b0670ec63d3dc899dc5"}, ] [package.dependencies] @@ -2801,14 +2790,14 @@ files = [ [[package]] name = "pgvector" -version = "0.4.1" +version = "0.4.2" description = "pgvector support for Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pgvector-0.4.1-py3-none-any.whl", hash = "sha256:34bb4e99e1b13d08a2fe82dda9f860f15ddcd0166fbb25bffe15821cbfeb7362"}, - {file = "pgvector-0.4.1.tar.gz", hash = "sha256:83d3a1c044ff0c2f1e95d13dfb625beb0b65506cfec0941bfe81fd0ad44f4003"}, + {file = "pgvector-0.4.2-py3-none-any.whl", hash = "sha256:549d45f7a18593783d5eec609ea1684a724ba8405c4cb182a0b2b08aeff04e08"}, + {file = "pgvector-0.4.2.tar.gz", hash = "sha256:322cac0c1dc5d41c9ecf782bd9991b7966685dee3a00bc873631391ed949513a"}, ] [package.dependencies] @@ -2816,103 +2805,103 @@ numpy = "*" [[package]] name = "pillow" -version = "12.0.0" +version = "12.1.0" description = "Python Imaging Library (fork)" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b"}, - {file = "pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1"}, - {file = "pillow-12.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363"}, - {file = "pillow-12.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca"}, - {file = "pillow-12.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e"}, - {file = "pillow-12.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782"}, - {file = "pillow-12.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10"}, - {file = "pillow-12.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa"}, - {file = "pillow-12.0.0-cp310-cp310-win32.whl", hash = "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275"}, - {file = "pillow-12.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d"}, - {file = "pillow-12.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7"}, - {file = "pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc"}, - {file = "pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257"}, - {file = "pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642"}, - {file = "pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3"}, - {file = "pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c"}, - {file = "pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227"}, - {file = "pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b"}, - {file = "pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e"}, - {file = "pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739"}, - {file = "pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e"}, - {file = "pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d"}, - {file = "pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371"}, - {file = "pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082"}, - {file = "pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f"}, - {file = "pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d"}, - {file = "pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953"}, - {file = "pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8"}, - {file = "pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79"}, - {file = "pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba"}, - {file = "pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0"}, - {file = "pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a"}, - {file = "pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad"}, - {file = "pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643"}, - {file = "pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4"}, - {file = "pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399"}, - {file = "pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5"}, - {file = "pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b"}, - {file = "pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3"}, - {file = "pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07"}, - {file = "pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e"}, - {file = "pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344"}, - {file = "pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27"}, - {file = "pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79"}, - {file = "pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098"}, - {file = "pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905"}, - {file = "pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a"}, - {file = "pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3"}, - {file = "pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced"}, - {file = "pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b"}, - {file = "pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d"}, - {file = "pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a"}, - {file = "pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe"}, - {file = "pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee"}, - {file = "pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef"}, - {file = "pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9"}, - {file = "pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b"}, - {file = "pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47"}, - {file = "pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9"}, - {file = "pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2"}, - {file = "pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a"}, - {file = "pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b"}, - {file = "pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad"}, - {file = "pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01"}, - {file = "pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c"}, - {file = "pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e"}, - {file = "pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e"}, - {file = "pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9"}, - {file = "pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab"}, - {file = "pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b"}, - {file = "pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b"}, - {file = "pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0"}, - {file = "pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6"}, - {file = "pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6"}, - {file = "pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1"}, - {file = "pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e"}, - {file = "pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca"}, - {file = "pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925"}, - {file = "pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8"}, - {file = "pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4"}, - {file = "pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52"}, - {file = "pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a"}, - {file = "pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5"}, - {file = "pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353"}, + {file = "pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd"}, + {file = "pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0"}, + {file = "pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8"}, + {file = "pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1"}, + {file = "pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda"}, + {file = "pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7"}, + {file = "pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a"}, + {file = "pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef"}, + {file = "pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09"}, + {file = "pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91"}, + {file = "pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea"}, + {file = "pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3"}, + {file = "pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0"}, + {file = "pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451"}, + {file = "pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e"}, + {file = "pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84"}, + {file = "pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0"}, + {file = "pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b"}, + {file = "pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18"}, + {file = "pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64"}, + {file = "pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75"}, + {file = "pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304"}, + {file = "pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b"}, + {file = "pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551"}, + {file = "pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208"}, + {file = "pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5"}, + {file = "pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661"}, + {file = "pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17"}, + {file = "pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670"}, + {file = "pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616"}, + {file = "pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7"}, + {file = "pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d"}, + {file = "pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c"}, + {file = "pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1"}, + {file = "pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179"}, + {file = "pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0"}, + {file = "pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587"}, + {file = "pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac"}, + {file = "pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b"}, + {file = "pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea"}, + {file = "pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c"}, + {file = "pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc"}, + {file = "pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644"}, + {file = "pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c"}, + {file = "pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171"}, + {file = "pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a"}, + {file = "pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45"}, + {file = "pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d"}, + {file = "pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0"}, + {file = "pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554"}, + {file = "pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e"}, + {file = "pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82"}, + {file = "pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4"}, + {file = "pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0"}, + {file = "pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b"}, + {file = "pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65"}, + {file = "pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0"}, + {file = "pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8"}, + {file = "pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91"}, + {file = "pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796"}, + {file = "pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd"}, + {file = "pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13"}, + {file = "pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e"}, + {file = "pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643"}, + {file = "pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5"}, + {file = "pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de"}, + {file = "pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9"}, + {file = "pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a"}, + {file = "pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a"}, + {file = "pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030"}, + {file = "pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94"}, + {file = "pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4"}, + {file = "pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2"}, + {file = "pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61"}, + {file = "pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51"}, + {file = "pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc"}, + {file = "pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14"}, + {file = "pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8"}, + {file = "pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924"}, + {file = "pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef"}, + {file = "pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988"}, + {file = "pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6"}, + {file = "pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19"}, + {file = "pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9"}, ] [package.extras] @@ -2948,14 +2937,14 @@ files = [ [[package]] name = "platformdirs" -version = "4.5.0" +version = "4.5.1" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"}, - {file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"}, + {file = "platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"}, + {file = "platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda"}, ] [package.extras] @@ -2981,14 +2970,14 @@ testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "4.5.0" +version = "4.5.1" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1"}, - {file = "pre_commit-4.5.0.tar.gz", hash = "sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b"}, + {file = "pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77"}, + {file = "pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61"}, ] [package.dependencies] @@ -3210,14 +3199,14 @@ files = [ [[package]] name = "pydantic" -version = "2.12.4" +version = "2.12.5" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e"}, - {file = "pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac"}, + {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, + {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, ] [package.dependencies] @@ -3445,39 +3434,36 @@ tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] [[package]] name = "pynacl" -version = "1.6.1" +version = "1.6.2" description = "Python binding to the Networking and Cryptography (NaCl) library" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "pynacl-1.6.1-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:7d7c09749450c385301a3c20dca967a525152ae4608c0a096fe8464bfc3df93d"}, - {file = "pynacl-1.6.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc734c1696ffd49b40f7c1779c89ba908157c57345cf626be2e0719488a076d3"}, - {file = "pynacl-1.6.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3cd787ec1f5c155dc8ecf39b1333cfef41415dc96d392f1ce288b4fe970df489"}, - {file = "pynacl-1.6.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b35d93ab2df03ecb3aa506be0d3c73609a51449ae0855c2e89c7ed44abde40b"}, - {file = "pynacl-1.6.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dece79aecbb8f4640a1adbb81e4aa3bfb0e98e99834884a80eb3f33c7c30e708"}, - {file = "pynacl-1.6.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c2228054f04bf32d558fb89bb99f163a8197d5a9bf4efa13069a7fa8d4b93fc3"}, - {file = "pynacl-1.6.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:2b12f1b97346f177affcdfdc78875ff42637cb40dcf79484a97dae3448083a78"}, - {file = "pynacl-1.6.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e735c3a1bdfde3834503baf1a6d74d4a143920281cb724ba29fb84c9f49b9c48"}, - {file = "pynacl-1.6.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3384a454adf5d716a9fadcb5eb2e3e72cd49302d1374a60edc531c9957a9b014"}, - {file = "pynacl-1.6.1-cp314-cp314t-win32.whl", hash = "sha256:d8615ee34d01c8e0ab3f302dcdd7b32e2bcf698ba5f4809e7cc407c8cdea7717"}, - {file = "pynacl-1.6.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5f5b35c1a266f8a9ad22525049280a600b19edd1f785bccd01ae838437dcf935"}, - {file = "pynacl-1.6.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d984c91fe3494793b2a1fb1e91429539c6c28e9ec8209d26d25041ec599ccf63"}, - {file = "pynacl-1.6.1-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:a6f9fd6d6639b1e81115c7f8ff16b8dedba1e8098d2756275d63d208b0e32021"}, - {file = "pynacl-1.6.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e49a3f3d0da9f79c1bec2aa013261ab9fa651c7da045d376bd306cf7c1792993"}, - {file = "pynacl-1.6.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7713f8977b5d25f54a811ec9efa2738ac592e846dd6e8a4d3f7578346a841078"}, - {file = "pynacl-1.6.1-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a3becafc1ee2e5ea7f9abc642f56b82dcf5be69b961e782a96ea52b55d8a9fc"}, - {file = "pynacl-1.6.1-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ce50d19f1566c391fedc8dc2f2f5be265ae214112ebe55315e41d1f36a7f0a9"}, - {file = "pynacl-1.6.1-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:543f869140f67d42b9b8d47f922552d7a967e6c116aad028c9bfc5f3f3b3a7b7"}, - {file = "pynacl-1.6.1-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a2bb472458c7ca959aeeff8401b8efef329b0fc44a89d3775cffe8fad3398ad8"}, - {file = "pynacl-1.6.1-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:3206fa98737fdc66d59b8782cecc3d37d30aeec4593d1c8c145825a345bba0f0"}, - {file = "pynacl-1.6.1-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:53543b4f3d8acb344f75fd4d49f75e6572fce139f4bfb4815a9282296ff9f4c0"}, - {file = "pynacl-1.6.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:319de653ef84c4f04e045eb250e6101d23132372b0a61a7acf91bac0fda8e58c"}, - {file = "pynacl-1.6.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:262a8de6bba4aee8a66f5edf62c214b06647461c9b6b641f8cd0cb1e3b3196fe"}, - {file = "pynacl-1.6.1-cp38-abi3-win32.whl", hash = "sha256:9fd1a4eb03caf8a2fe27b515a998d26923adb9ddb68db78e35ca2875a3830dde"}, - {file = "pynacl-1.6.1-cp38-abi3-win_amd64.whl", hash = "sha256:a569a4069a7855f963940040f35e87d8bc084cb2d6347428d5ad20550a0a1a21"}, - {file = "pynacl-1.6.1-cp38-abi3-win_arm64.whl", hash = "sha256:5953e8b8cfadb10889a6e7bd0f53041a745d1b3d30111386a1bb37af171e6daf"}, - {file = "pynacl-1.6.1.tar.gz", hash = "sha256:8d361dac0309f2b6ad33b349a56cd163c98430d409fa503b10b70b3ad66eaa1d"}, + {file = "pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14"}, + {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444"}, + {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b"}, + {file = "pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145"}, + {file = "pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590"}, + {file = "pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2"}, + {file = "pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6"}, + {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e"}, + {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577"}, + {file = "pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa"}, + {file = "pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0"}, + {file = "pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c"}, ] [package.dependencies] @@ -3489,14 +3475,14 @@ tests = ["hypothesis (>=3.27.0)", "pytest (>=7.4.0)", "pytest-cov (>=2.10.1)", " [[package]] name = "pyparsing" -version = "3.2.5" +version = "3.3.1" description = "pyparsing - Classes and methods to define and execute parsing grammars" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e"}, - {file = "pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6"}, + {file = "pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82"}, + {file = "pyparsing-3.3.1.tar.gz", hash = "sha256:47fad0f17ac1e2cad3de3b458570fbc9b03560aa029ed5e16ee5554da9a2251c"}, ] [package.extras] @@ -3504,14 +3490,14 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" -version = "9.0.1" +version = "9.0.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.10" groups = ["test"] files = [ - {file = "pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad"}, - {file = "pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8"}, + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, ] [package.dependencies] @@ -4002,14 +3988,14 @@ files = [ [[package]] name = "reportlab" -version = "4.4.5" +version = "4.4.7" description = "The Reportlab Toolkit" optional = false python-versions = "<4,>=3.9" groups = ["main"] files = [ - {file = "reportlab-4.4.5-py3-none-any.whl", hash = "sha256:849773d7cd5dde2072fedbac18c8bc909506c8befba8f088ba7b09243c6684cc"}, - {file = "reportlab-4.4.5.tar.gz", hash = "sha256:0457d642aa76df7b36b0235349904c58d8f9c606a872456ed04436aafadc1510"}, + {file = "reportlab-4.4.7-py3-none-any.whl", hash = "sha256:8fa05cbf468e0e76745caf2029a4770276edb3c8e86a0b71e0398926baf50673"}, + {file = "reportlab-4.4.7.tar.gz", hash = "sha256:41e8287af965e5996764933f3e75e7f363c3b6f252ba172f9429e81658d7b170"}, ] [package.dependencies] @@ -4062,127 +4048,127 @@ requests = ">=2.0.1,<3.0.0" [[package]] name = "rpds-py" -version = "0.29.0" +version = "0.30.0" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "rpds_py-0.29.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:4ae4b88c6617e1b9e5038ab3fccd7bac0842fdda2b703117b2aa99bc85379113"}, - {file = "rpds_py-0.29.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7d9128ec9d8cecda6f044001fde4fb71ea7c24325336612ef8179091eb9596b9"}, - {file = "rpds_py-0.29.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d37812c3da8e06f2bb35b3cf10e4a7b68e776a706c13058997238762b4e07f4f"}, - {file = "rpds_py-0.29.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:66786c3fb1d8de416a7fa8e1cb1ec6ba0a745b2b0eee42f9b7daa26f1a495545"}, - {file = "rpds_py-0.29.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58f5c77f1af888b5fd1876c9a0d9858f6f88a39c9dd7c073a88e57e577da66d"}, - {file = "rpds_py-0.29.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:799156ef1f3529ed82c36eb012b5d7a4cf4b6ef556dd7cc192148991d07206ae"}, - {file = "rpds_py-0.29.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:453783477aa4f2d9104c4b59b08c871431647cb7af51b549bbf2d9eb9c827756"}, - {file = "rpds_py-0.29.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:24a7231493e3c4a4b30138b50cca089a598e52c34cf60b2f35cebf62f274fdea"}, - {file = "rpds_py-0.29.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7033c1010b1f57bb44d8067e8c25aa6fa2e944dbf46ccc8c92b25043839c3fd2"}, - {file = "rpds_py-0.29.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0248b19405422573621172ab8e3a1f29141362d13d9f72bafa2e28ea0cdca5a2"}, - {file = "rpds_py-0.29.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f9f436aee28d13b9ad2c764fc273e0457e37c2e61529a07b928346b219fcde3b"}, - {file = "rpds_py-0.29.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24a16cb7163933906c62c272de20ea3c228e4542c8c45c1d7dc2b9913e17369a"}, - {file = "rpds_py-0.29.0-cp310-cp310-win32.whl", hash = "sha256:1a409b0310a566bfd1be82119891fefbdce615ccc8aa558aff7835c27988cbef"}, - {file = "rpds_py-0.29.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5523b0009e7c3c1263471b69d8da1c7d41b3ecb4cb62ef72be206b92040a950"}, - {file = "rpds_py-0.29.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9b9c764a11fd637e0322a488560533112837f5334ffeb48b1be20f6d98a7b437"}, - {file = "rpds_py-0.29.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fd2164d73812026ce970d44c3ebd51e019d2a26a4425a5dcbdfa93a34abc383"}, - {file = "rpds_py-0.29.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a097b7f7f7274164566ae90a221fd725363c0e9d243e2e9ed43d195ccc5495c"}, - {file = "rpds_py-0.29.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cdc0490374e31cedefefaa1520d5fe38e82fde8748cbc926e7284574c714d6b"}, - {file = "rpds_py-0.29.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89ca2e673ddd5bde9b386da9a0aac0cab0e76f40c8f0aaf0d6311b6bbf2aa311"}, - {file = "rpds_py-0.29.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5d9da3ff5af1ca1249b1adb8ef0573b94c76e6ae880ba1852f033bf429d4588"}, - {file = "rpds_py-0.29.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8238d1d310283e87376c12f658b61e1ee23a14c0e54c7c0ce953efdbdc72deed"}, - {file = "rpds_py-0.29.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2d6fb2ad1c36f91c4646989811e84b1ea5e0c3cf9690b826b6e32b7965853a63"}, - {file = "rpds_py-0.29.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:534dc9df211387547267ccdb42253aa30527482acb38dd9b21c5c115d66a96d2"}, - {file = "rpds_py-0.29.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d456e64724a075441e4ed648d7f154dc62e9aabff29bcdf723d0c00e9e1d352f"}, - {file = "rpds_py-0.29.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a738f2da2f565989401bd6fd0b15990a4d1523c6d7fe83f300b7e7d17212feca"}, - {file = "rpds_py-0.29.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a110e14508fd26fd2e472bb541f37c209409876ba601cf57e739e87d8a53cf95"}, - {file = "rpds_py-0.29.0-cp311-cp311-win32.whl", hash = "sha256:923248a56dd8d158389a28934f6f69ebf89f218ef96a6b216a9be6861804d3f4"}, - {file = "rpds_py-0.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:539eb77eb043afcc45314d1be09ea6d6cafb3addc73e0547c171c6d636957f60"}, - {file = "rpds_py-0.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:bdb67151ea81fcf02d8f494703fb728d4d34d24556cbff5f417d74f6f5792e7c"}, - {file = "rpds_py-0.29.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0891cfd8db43e085c0ab93ab7e9b0c8fee84780d436d3b266b113e51e79f954"}, - {file = "rpds_py-0.29.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3897924d3f9a0361472d884051f9a2460358f9a45b1d85a39a158d2f8f1ad71c"}, - {file = "rpds_py-0.29.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a21deb8e0d1571508c6491ce5ea5e25669b1dd4adf1c9d64b6314842f708b5d"}, - {file = "rpds_py-0.29.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9efe71687d6427737a0a2de9ca1c0a216510e6cd08925c44162be23ed7bed2d5"}, - {file = "rpds_py-0.29.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:40f65470919dc189c833e86b2c4bd21bd355f98436a2cef9e0a9a92aebc8e57e"}, - {file = "rpds_py-0.29.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:def48ff59f181130f1a2cb7c517d16328efac3ec03951cca40c1dc2049747e83"}, - {file = "rpds_py-0.29.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad7bd570be92695d89285a4b373006930715b78d96449f686af422debb4d3949"}, - {file = "rpds_py-0.29.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:5a572911cd053137bbff8e3a52d31c5d2dba51d3a67ad902629c70185f3f2181"}, - {file = "rpds_py-0.29.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d583d4403bcbf10cffc3ab5cee23d7643fcc960dff85973fd3c2d6c86e8dbb0c"}, - {file = "rpds_py-0.29.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:070befbb868f257d24c3bb350dbd6e2f645e83731f31264b19d7231dd5c396c7"}, - {file = "rpds_py-0.29.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fc935f6b20b0c9f919a8ff024739174522abd331978f750a74bb68abd117bd19"}, - {file = "rpds_py-0.29.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8c5a8ecaa44ce2d8d9d20a68a2483a74c07f05d72e94a4dff88906c8807e77b0"}, - {file = "rpds_py-0.29.0-cp312-cp312-win32.whl", hash = "sha256:ba5e1aeaf8dd6d8f6caba1f5539cddda87d511331714b7b5fc908b6cfc3636b7"}, - {file = "rpds_py-0.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:b5f6134faf54b3cb83375db0f113506f8b7770785be1f95a631e7e2892101977"}, - {file = "rpds_py-0.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:b016eddf00dca7944721bf0cd85b6af7f6c4efaf83ee0b37c4133bd39757a8c7"}, - {file = "rpds_py-0.29.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1585648d0760b88292eecab5181f5651111a69d90eff35d6b78aa32998886a61"}, - {file = "rpds_py-0.29.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:521807963971a23996ddaf764c682b3e46459b3c58ccd79fefbe16718db43154"}, - {file = "rpds_py-0.29.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8896986efaa243ab713c69e6491a4138410f0fe36f2f4c71e18bd5501e8014"}, - {file = "rpds_py-0.29.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1d24564a700ef41480a984c5ebed62b74e6ce5860429b98b1fede76049e953e6"}, - {file = "rpds_py-0.29.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6596b93c010d386ae46c9fba9bfc9fc5965fa8228edeac51576299182c2e31c"}, - {file = "rpds_py-0.29.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5cc58aac218826d054c7da7f95821eba94125d88be673ff44267bb89d12a5866"}, - {file = "rpds_py-0.29.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de73e40ebc04dd5d9556f50180395322193a78ec247e637e741c1b954810f295"}, - {file = "rpds_py-0.29.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:295ce5ac7f0cf69a651ea75c8f76d02a31f98e5698e82a50a5f4d4982fbbae3b"}, - {file = "rpds_py-0.29.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ea59b23ea931d494459c8338056fe7d93458c0bf3ecc061cd03916505369d55"}, - {file = "rpds_py-0.29.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f49d41559cebd608042fdcf54ba597a4a7555b49ad5c1c0c03e0af82692661cd"}, - {file = "rpds_py-0.29.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:05a2bd42768ea988294ca328206efbcc66e220d2d9b7836ee5712c07ad6340ea"}, - {file = "rpds_py-0.29.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33ca7bdfedd83339ca55da3a5e1527ee5870d4b8369456b5777b197756f3ca22"}, - {file = "rpds_py-0.29.0-cp313-cp313-win32.whl", hash = "sha256:20c51ae86a0bb9accc9ad4e6cdeec58d5ebb7f1b09dd4466331fc65e1766aae7"}, - {file = "rpds_py-0.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:6410e66f02803600edb0b1889541f4b5cc298a5ccda0ad789cc50ef23b54813e"}, - {file = "rpds_py-0.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:56838e1cd9174dc23c5691ee29f1d1be9eab357f27efef6bded1328b23e1ced2"}, - {file = "rpds_py-0.29.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:37d94eadf764d16b9a04307f2ab1d7af6dc28774bbe0535c9323101e14877b4c"}, - {file = "rpds_py-0.29.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d472cf73efe5726a067dce63eebe8215b14beabea7c12606fd9994267b3cfe2b"}, - {file = "rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72fdfd5ff8992e4636621826371e3ac5f3e3b8323e9d0e48378e9c13c3dac9d0"}, - {file = "rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2549d833abdf8275c901313b9e8ff8fba57e50f6a495035a2a4e30621a2f7cc4"}, - {file = "rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4448dad428f28a6a767c3e3b80cde3446a22a0efbddaa2360f4bb4dc836d0688"}, - {file = "rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:115f48170fd4296a33938d8c11f697f5f26e0472e43d28f35624764173a60e4d"}, - {file = "rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e5bb73ffc029820f4348e9b66b3027493ae00bca6629129cd433fd7a76308ee"}, - {file = "rpds_py-0.29.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b1581fcde18fcdf42ea2403a16a6b646f8eb1e58d7f90a0ce693da441f76942e"}, - {file = "rpds_py-0.29.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16e9da2bda9eb17ea318b4c335ec9ac1818e88922cbe03a5743ea0da9ecf74fb"}, - {file = "rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:28fd300326dd21198f311534bdb6d7e989dd09b3418b3a91d54a0f384c700967"}, - {file = "rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2aba991e041d031c7939e1358f583ae405a7bf04804ca806b97a5c0e0af1ea5e"}, - {file = "rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f437026dbbc3f08c99cc41a5b2570c6e1a1ddbe48ab19a9b814254128d4ea7a"}, - {file = "rpds_py-0.29.0-cp313-cp313t-win32.whl", hash = "sha256:6e97846e9800a5d0fe7be4d008f0c93d0feeb2700da7b1f7528dabafb31dfadb"}, - {file = "rpds_py-0.29.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f49196aec7c4b406495f60e6f947ad71f317a765f956d74bbd83996b9edc0352"}, - {file = "rpds_py-0.29.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:394d27e4453d3b4d82bb85665dc1fcf4b0badc30fc84282defed71643b50e1a1"}, - {file = "rpds_py-0.29.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55d827b2ae95425d3be9bc9a5838b6c29d664924f98146557f7715e331d06df8"}, - {file = "rpds_py-0.29.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc31a07ed352e5462d3ee1b22e89285f4ce97d5266f6d1169da1142e78045626"}, - {file = "rpds_py-0.29.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4695dd224212f6105db7ea62197144230b808d6b2bba52238906a2762f1d1e7"}, - {file = "rpds_py-0.29.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcae1770b401167f8b9e1e3f566562e6966ffa9ce63639916248a9e25fa8a244"}, - {file = "rpds_py-0.29.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90f30d15f45048448b8da21c41703b31c61119c06c216a1bf8c245812a0f0c17"}, - {file = "rpds_py-0.29.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44a91e0ab77bdc0004b43261a4b8cd6d6b451e8d443754cfda830002b5745b32"}, - {file = "rpds_py-0.29.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4aa195e5804d32c682e453b34474f411ca108e4291c6a0f824ebdc30a91c973c"}, - {file = "rpds_py-0.29.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7971bdb7bf4ee0f7e6f67fa4c7fbc6019d9850cc977d126904392d363f6f8318"}, - {file = "rpds_py-0.29.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8ae33ad9ce580c7a47452c3b3f7d8a9095ef6208e0a0c7e4e2384f9fc5bf8212"}, - {file = "rpds_py-0.29.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c661132ab2fb4eeede2ef69670fd60da5235209874d001a98f1542f31f2a8a94"}, - {file = "rpds_py-0.29.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb78b3a0d31ac1bde132c67015a809948db751cb4e92cdb3f0b242e430b6ed0d"}, - {file = "rpds_py-0.29.0-cp314-cp314-win32.whl", hash = "sha256:f475f103488312e9bd4000bc890a95955a07b2d0b6e8884aef4be56132adbbf1"}, - {file = "rpds_py-0.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:b9cf2359a4fca87cfb6801fae83a76aedf66ee1254a7a151f1341632acf67f1b"}, - {file = "rpds_py-0.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:9ba8028597e824854f0f1733d8b964e914ae3003b22a10c2c664cb6927e0feb9"}, - {file = "rpds_py-0.29.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e71136fd0612556b35c575dc2726ae04a1669e6a6c378f2240312cf5d1a2ab10"}, - {file = "rpds_py-0.29.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:76fe96632d53f3bf0ea31ede2f53bbe3540cc2736d4aec3b3801b0458499ef3a"}, - {file = "rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9459a33f077130dbb2c7c3cea72ee9932271fb3126404ba2a2661e4fe9eb7b79"}, - {file = "rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9546cfdd5d45e562cc0444b6dddc191e625c62e866bf567a2c69487c7ad28a"}, - {file = "rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12597d11d97b8f7e376c88929a6e17acb980e234547c92992f9f7c058f1a7310"}, - {file = "rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28de03cf48b8a9e6ec10318f2197b83946ed91e2891f651a109611be4106ac4b"}, - {file = "rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7951c964069039acc9d67a8ff1f0a7f34845ae180ca542b17dc1456b1f1808"}, - {file = "rpds_py-0.29.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:c07d107b7316088f1ac0177a7661ca0c6670d443f6fe72e836069025e6266761"}, - {file = "rpds_py-0.29.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de2345af363d25696969befc0c1688a6cb5e8b1d32b515ef84fc245c6cddba3"}, - {file = "rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:00e56b12d2199ca96068057e1ae7f9998ab6e99cda82431afafd32f3ec98cca9"}, - {file = "rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3919a3bbecee589300ed25000b6944174e07cd20db70552159207b3f4bbb45b8"}, - {file = "rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7fa2ccc312bbd91e43aa5e0869e46bc03278a3dddb8d58833150a18b0f0283a"}, - {file = "rpds_py-0.29.0-cp314-cp314t-win32.whl", hash = "sha256:97c817863ffc397f1e6a6e9d2d89fe5408c0a9922dac0329672fb0f35c867ea5"}, - {file = "rpds_py-0.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2023473f444752f0f82a58dfcbee040d0a1b3d1b3c2ec40e884bd25db6d117d2"}, - {file = "rpds_py-0.29.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:acd82a9e39082dc5f4492d15a6b6c8599aa21db5c35aaf7d6889aea16502c07d"}, - {file = "rpds_py-0.29.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:715b67eac317bf1c7657508170a3e011a1ea6ccb1c9d5f296e20ba14196be6b3"}, - {file = "rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3b1b87a237cb2dba4db18bcfaaa44ba4cd5936b91121b62292ff21df577fc43"}, - {file = "rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c3c3e8101bb06e337c88eb0c0ede3187131f19d97d43ea0e1c5407ea74c0cbf"}, - {file = "rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8e54d6e61f3ecd3abe032065ce83ea63417a24f437e4a3d73d2f85ce7b7cfe"}, - {file = "rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fbd4e9aebf110473a420dea85a238b254cf8a15acb04b22a5a6b5ce8925b760"}, - {file = "rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80fdf53d36e6c72819993e35d1ebeeb8e8fc688d0c6c2b391b55e335b3afba5a"}, - {file = "rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:ea7173df5d86f625f8dde6d5929629ad811ed8decda3b60ae603903839ac9ac0"}, - {file = "rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:76054d540061eda273274f3d13a21a4abdde90e13eaefdc205db37c05230efce"}, - {file = "rpds_py-0.29.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:9f84c549746a5be3bc7415830747a3a0312573afc9f95785eb35228bb17742ec"}, - {file = "rpds_py-0.29.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:0ea962671af5cb9a260489e311fa22b2e97103e3f9f0caaea6f81390af96a9ed"}, - {file = "rpds_py-0.29.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:f7728653900035fb7b8d06e1e5900545d8088efc9d5d4545782da7df03ec803f"}, - {file = "rpds_py-0.29.0.tar.gz", hash = "sha256:fe55fe686908f50154d1dc599232016e50c243b438c3b7432f24e2895b0e5359"}, + {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, + {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139"}, + {file = "rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464"}, + {file = "rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85"}, + {file = "rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394"}, + {file = "rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95"}, + {file = "rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53"}, + {file = "rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e"}, + {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"}, ] [[package]] @@ -4204,43 +4190,43 @@ redis = ">=3.5,<6 || >6" [[package]] name = "ruff" -version = "0.14.6" +version = "0.14.10" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.14.6-py3-none-linux_armv6l.whl", hash = "sha256:d724ac2f1c240dbd01a2ae98db5d1d9a5e1d9e96eba999d1c48e30062df578a3"}, - {file = "ruff-0.14.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9f7539ea257aa4d07b7ce87aed580e485c40143f2473ff2f2b75aee003186004"}, - {file = "ruff-0.14.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f6007e55b90a2a7e93083ba48a9f23c3158c433591c33ee2e99a49b889c6332"}, - {file = "ruff-0.14.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8e7b9d73d8728b68f632aa8e824ef041d068d231d8dbc7808532d3629a6bef"}, - {file = "ruff-0.14.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d50d45d4553a3ebcbd33e7c5e0fe6ca4aafd9a9122492de357205c2c48f00775"}, - {file = "ruff-0.14.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:118548dd121f8a21bfa8ab2c5b80e5b4aed67ead4b7567790962554f38e598ce"}, - {file = "ruff-0.14.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:57256efafbfefcb8748df9d1d766062f62b20150691021f8ab79e2d919f7c11f"}, - {file = "ruff-0.14.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff18134841e5c68f8e5df1999a64429a02d5549036b394fafbe410f886e1989d"}, - {file = "ruff-0.14.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c4b7ec1e66a105d5c27bd57fa93203637d66a26d10ca9809dc7fc18ec58440"}, - {file = "ruff-0.14.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:167843a6f78680746d7e226f255d920aeed5e4ad9c03258094a2d49d3028b105"}, - {file = "ruff-0.14.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:16a33af621c9c523b1ae006b1b99b159bf5ac7e4b1f20b85b2572455018e0821"}, - {file = "ruff-0.14.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1432ab6e1ae2dc565a7eea707d3b03a0c234ef401482a6f1621bc1f427c2ff55"}, - {file = "ruff-0.14.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4c55cfbbe7abb61eb914bfd20683d14cdfb38a6d56c6c66efa55ec6570ee4e71"}, - {file = "ruff-0.14.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:efea3c0f21901a685fff4befda6d61a1bf4cb43de16da87e8226a281d614350b"}, - {file = "ruff-0.14.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:344d97172576d75dc6afc0e9243376dbe1668559c72de1864439c4fc95f78185"}, - {file = "ruff-0.14.6-py3-none-win32.whl", hash = "sha256:00169c0c8b85396516fdd9ce3446c7ca20c2a8f90a77aa945ba6b8f2bfe99e85"}, - {file = "ruff-0.14.6-py3-none-win_amd64.whl", hash = "sha256:390e6480c5e3659f8a4c8d6a0373027820419ac14fa0d2713bd8e6c3e125b8b9"}, - {file = "ruff-0.14.6-py3-none-win_arm64.whl", hash = "sha256:d43c81fbeae52cfa8728d8766bbf46ee4298c888072105815b392da70ca836b2"}, - {file = "ruff-0.14.6.tar.gz", hash = "sha256:6f0c742ca6a7783a736b867a263b9a7a80a45ce9bee391eeda296895f1b4e1cc"}, + {file = "ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49"}, + {file = "ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f"}, + {file = "ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d"}, + {file = "ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77"}, + {file = "ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a"}, + {file = "ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f"}, + {file = "ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935"}, + {file = "ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e"}, + {file = "ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d"}, + {file = "ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f"}, + {file = "ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f"}, + {file = "ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d"}, + {file = "ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405"}, + {file = "ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60"}, + {file = "ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830"}, + {file = "ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6"}, + {file = "ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154"}, + {file = "ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6"}, + {file = "ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4"}, ] [[package]] name = "s3transfer" -version = "0.15.0" +version = "0.16.0" description = "An Amazon S3 Transfer Manager" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "s3transfer-0.15.0-py3-none-any.whl", hash = "sha256:6f8bf5caa31a0865c4081186689db1b2534cef721d104eb26101de4b9d6a5852"}, - {file = "s3transfer-0.15.0.tar.gz", hash = "sha256:d36fac8d0e3603eff9b5bfa4282c7ce6feb0301a633566153cbd0b93d11d8379"}, + {file = "s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe"}, + {file = "s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920"}, ] [package.dependencies] @@ -4251,14 +4237,14 @@ crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] [[package]] name = "sentry-sdk" -version = "2.45.0" +version = "2.48.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "sentry_sdk-2.45.0-py2.py3-none-any.whl", hash = "sha256:86c8ab05dc3e8666aece77a5c747b45b25aa1d5f35f06cde250608f495d50f23"}, - {file = "sentry_sdk-2.45.0.tar.gz", hash = "sha256:e9bbfe69d5f6742f48bad22452beffb525bbc5b797d817c7f1b1f7d210cdd271"}, + {file = "sentry_sdk-2.48.0-py2.py3-none-any.whl", hash = "sha256:6b12ac256769d41825d9b7518444e57fa35b5642df4c7c5e322af4d2c8721172"}, + {file = "sentry_sdk-2.48.0.tar.gz", hash = "sha256:5213190977ff7fdff8a58b722fb807f8d5524a80488626ebeda1b5676c0c1473"}, ] [package.dependencies] @@ -4390,69 +4376,64 @@ files = [ [[package]] name = "sqlalchemy" -version = "2.0.44" +version = "2.0.45" description = "Database Abstraction Library" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "SQLAlchemy-2.0.44-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:471733aabb2e4848d609141a9e9d56a427c0a038f4abf65dd19d7a21fd563632"}, - {file = "SQLAlchemy-2.0.44-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48bf7d383a35e668b984c805470518b635d48b95a3c57cb03f37eaa3551b5f9f"}, - {file = "SQLAlchemy-2.0.44-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bf4bb6b3d6228fcf3a71b50231199fb94d2dd2611b66d33be0578ea3e6c2726"}, - {file = "SQLAlchemy-2.0.44-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:e998cf7c29473bd077704cea3577d23123094311f59bdc4af551923b168332b1"}, - {file = "SQLAlchemy-2.0.44-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:ebac3f0b5732014a126b43c2b7567f2f0e0afea7d9119a3378bde46d3dcad88e"}, - {file = "SQLAlchemy-2.0.44-cp37-cp37m-win32.whl", hash = "sha256:3255d821ee91bdf824795e936642bbf43a4c7cedf5d1aed8d24524e66843aa74"}, - {file = "SQLAlchemy-2.0.44-cp37-cp37m-win_amd64.whl", hash = "sha256:78e6c137ba35476adb5432103ae1534f2f5295605201d946a4198a0dea4b38e7"}, - {file = "sqlalchemy-2.0.44-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c77f3080674fc529b1bd99489378c7f63fcb4ba7f8322b79732e0258f0ea3ce"}, - {file = "sqlalchemy-2.0.44-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c26ef74ba842d61635b0152763d057c8d48215d5be9bb8b7604116a059e9985"}, - {file = "sqlalchemy-2.0.44-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4a172b31785e2f00780eccab00bc240ccdbfdb8345f1e6063175b3ff12ad1b0"}, - {file = "sqlalchemy-2.0.44-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9480c0740aabd8cb29c329b422fb65358049840b34aba0adf63162371d2a96e"}, - {file = "sqlalchemy-2.0.44-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:17835885016b9e4d0135720160db3095dc78c583e7b902b6be799fb21035e749"}, - {file = "sqlalchemy-2.0.44-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cbe4f85f50c656d753890f39468fcd8190c5f08282caf19219f684225bfd5fd2"}, - {file = "sqlalchemy-2.0.44-cp310-cp310-win32.whl", hash = "sha256:2fcc4901a86ed81dc76703f3b93ff881e08761c63263c46991081fd7f034b165"}, - {file = "sqlalchemy-2.0.44-cp310-cp310-win_amd64.whl", hash = "sha256:9919e77403a483ab81e3423151e8ffc9dd992c20d2603bf17e4a8161111e55f5"}, - {file = "sqlalchemy-2.0.44-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0fe3917059c7ab2ee3f35e77757062b1bea10a0b6ca633c58391e3f3c6c488dd"}, - {file = "sqlalchemy-2.0.44-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:de4387a354ff230bc979b46b2207af841dc8bf29847b6c7dbe60af186d97aefa"}, - {file = "sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3678a0fb72c8a6a29422b2732fe423db3ce119c34421b5f9955873eb9b62c1e"}, - {file = "sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cf6872a23601672d61a68f390e44703442639a12ee9dd5a88bbce52a695e46e"}, - {file = "sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:329aa42d1be9929603f406186630135be1e7a42569540577ba2c69952b7cf399"}, - {file = "sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:70e03833faca7166e6a9927fbee7c27e6ecde436774cd0b24bbcc96353bce06b"}, - {file = "sqlalchemy-2.0.44-cp311-cp311-win32.whl", hash = "sha256:253e2f29843fb303eca6b2fc645aca91fa7aa0aa70b38b6950da92d44ff267f3"}, - {file = "sqlalchemy-2.0.44-cp311-cp311-win_amd64.whl", hash = "sha256:7a8694107eb4308a13b425ca8c0e67112f8134c846b6e1f722698708741215d5"}, - {file = "sqlalchemy-2.0.44-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250"}, - {file = "sqlalchemy-2.0.44-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29"}, - {file = "sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44"}, - {file = "sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1"}, - {file = "sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7"}, - {file = "sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d"}, - {file = "sqlalchemy-2.0.44-cp312-cp312-win32.whl", hash = "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4"}, - {file = "sqlalchemy-2.0.44-cp312-cp312-win_amd64.whl", hash = "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e"}, - {file = "sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1"}, - {file = "sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45"}, - {file = "sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976"}, - {file = "sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c"}, - {file = "sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d"}, - {file = "sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40"}, - {file = "sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73"}, - {file = "sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e"}, - {file = "sqlalchemy-2.0.44-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2fc44e5965ea46909a416fff0af48a219faefd5773ab79e5f8a5fcd5d62b2667"}, - {file = "sqlalchemy-2.0.44-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:dc8b3850d2a601ca2320d081874033684e246d28e1c5e89db0864077cfc8f5a9"}, - {file = "sqlalchemy-2.0.44-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d733dec0614bb8f4bcb7c8af88172b974f685a31dc3a65cca0527e3120de5606"}, - {file = "sqlalchemy-2.0.44-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22be14009339b8bc16d6b9dc8780bacaba3402aa7581658e246114abbd2236e3"}, - {file = "sqlalchemy-2.0.44-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:357bade0e46064f88f2c3a99808233e67b0051cdddf82992379559322dfeb183"}, - {file = "sqlalchemy-2.0.44-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4848395d932e93c1595e59a8672aa7400e8922c39bb9b0668ed99ac6fa867822"}, - {file = "sqlalchemy-2.0.44-cp38-cp38-win32.whl", hash = "sha256:2f19644f27c76f07e10603580a47278abb2a70311136a7f8fd27dc2e096b9013"}, - {file = "sqlalchemy-2.0.44-cp38-cp38-win_amd64.whl", hash = "sha256:1df4763760d1de0dfc8192cc96d8aa293eb1a44f8f7a5fbe74caf1b551905c5e"}, - {file = "sqlalchemy-2.0.44-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f7027414f2b88992877573ab780c19ecb54d3a536bef3397933573d6b5068be4"}, - {file = "sqlalchemy-2.0.44-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3fe166c7d00912e8c10d3a9a0ce105569a31a3d0db1a6e82c4e0f4bf16d5eca9"}, - {file = "sqlalchemy-2.0.44-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3caef1ff89b1caefc28f0368b3bde21a7e3e630c2eddac16abd9e47bd27cc36a"}, - {file = "sqlalchemy-2.0.44-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc2856d24afa44295735e72f3c75d6ee7fdd4336d8d3a8f3d44de7aa6b766df2"}, - {file = "sqlalchemy-2.0.44-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:11bac86b0deada30b6b5f93382712ff0e911fe8d31cb9bf46e6b149ae175eff0"}, - {file = "sqlalchemy-2.0.44-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4d18cd0e9a0f37c9f4088e50e3839fcb69a380a0ec957408e0b57cff08ee0a26"}, - {file = "sqlalchemy-2.0.44-cp39-cp39-win32.whl", hash = "sha256:9e9018544ab07614d591a26c1bd4293ddf40752cc435caf69196740516af7100"}, - {file = "sqlalchemy-2.0.44-cp39-cp39-win_amd64.whl", hash = "sha256:8e0e4e66fd80f277a8c3de016a81a554e76ccf6b8d881ee0b53200305a8433f6"}, - {file = "sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05"}, - {file = "sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22"}, + {file = "sqlalchemy-2.0.45-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c64772786d9eee72d4d3784c28f0a636af5b0a29f3fe26ff11f55efe90c0bd85"}, + {file = "sqlalchemy-2.0.45-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ae64ebf7657395824a19bca98ab10eb9a3ecb026bf09524014f1bb81cb598d4"}, + {file = "sqlalchemy-2.0.45-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f02325709d1b1a1489f23a39b318e175a171497374149eae74d612634b234c0"}, + {file = "sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2c3684fca8a05f0ac1d9a21c1f4a266983a7ea9180efb80ffeb03861ecd01a0"}, + {file = "sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040f6f0545b3b7da6b9317fc3e922c9a98fc7243b2a1b39f78390fc0942f7826"}, + {file = "sqlalchemy-2.0.45-cp310-cp310-win32.whl", hash = "sha256:830d434d609fe7bfa47c425c445a8b37929f140a7a44cdaf77f6d34df3a7296a"}, + {file = "sqlalchemy-2.0.45-cp310-cp310-win_amd64.whl", hash = "sha256:0209d9753671b0da74da2cfbb9ecf9c02f72a759e4b018b3ab35f244c91842c7"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d62e47f5d8a50099b17e2bfc1b0c7d7ecd8ba6b46b1507b58cc4f05eefc3bb1c"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-win32.whl", hash = "sha256:3c5f76216e7b85770d5bb5130ddd11ee89f4d52b11783674a662c7dd57018177"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl", hash = "sha256:a15b98adb7f277316f2c276c090259129ee4afca783495e212048daf846654b2"}, + {file = "sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f"}, + {file = "sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d"}, + {file = "sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4"}, + {file = "sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6"}, + {file = "sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953"}, + {file = "sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1"}, + {file = "sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf"}, + {file = "sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e"}, + {file = "sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b"}, + {file = "sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8"}, + {file = "sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a"}, + {file = "sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee"}, + {file = "sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6"}, + {file = "sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a"}, + {file = "sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774"}, + {file = "sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce"}, + {file = "sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33"}, + {file = "sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74"}, + {file = "sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f"}, + {file = "sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177"}, + {file = "sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b"}, + {file = "sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5964f832431b7cdfaaa22a660b4c7eb1dfcd6ed41375f67fd3e3440fd95cb3cc"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee580ab50e748208754ae8980cec79ec205983d8cf8b3f7c39067f3d9f2c8e22"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13e27397a7810163440c6bfed6b3fe46f1bfb2486eb540315a819abd2c004128"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ed3635353e55d28e7f4a95c8eda98a5cdc0a0b40b528433fbd41a9ae88f55b3d"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:db6834900338fb13a9123307f0c2cbb1f890a8656fcd5e5448ae3ad5bbe8d312"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-win32.whl", hash = "sha256:1d8b4a7a8c9b537509d56d5cd10ecdcfbb95912d72480c8861524efecc6a3fff"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-win_amd64.whl", hash = "sha256:ebd300afd2b62679203435f596b2601adafe546cb7282d5a0cd3ed99e423720f"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d29b2b99d527dbc66dd87c3c3248a5dd789d974a507f4653c969999fc7c1191b"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59a8b8bd9c6bedf81ad07c8bd5543eedca55fe9b8780b2b628d495ba55f8db1e"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd93c6f5d65f254ceabe97548c709e073d6da9883343adaa51bf1a913ce93f8e"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6d0beadc2535157070c9c17ecf25ecec31e13c229a8f69196d7590bde8082bf1"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e057f928ffe9c9b246a55b469c133b98a426297e1772ad24ce9f0c47d123bd5b"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-win32.whl", hash = "sha256:c1c2091b1489435ff85728fafeb990f073e64f6f5e81d5cd53059773e8521eb6"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-win_amd64.whl", hash = "sha256:56ead1f8dfb91a54a28cd1d072c74b3d635bcffbd25e50786533b822d4f2cde2"}, + {file = "sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0"}, + {file = "sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88"}, ] [package.dependencies] @@ -4486,39 +4467,39 @@ sqlcipher = ["sqlcipher3_binary"] [[package]] name = "sqlparse" -version = "0.5.3" +version = "0.5.5" description = "A non-validating SQL parser." optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"}, - {file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"}, + {file = "sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba"}, + {file = "sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e"}, ] [package.extras] -dev = ["build", "hatch"] +dev = ["build"] doc = ["sphinx"] [[package]] name = "strawberry-graphql" -version = "0.286.1" +version = "0.288.2" description = "A library for creating GraphQL APIs" optional = false python-versions = "<4.0,>=3.10" groups = ["main"] files = [ - {file = "strawberry_graphql-0.286.1-py3-none-any.whl", hash = "sha256:854141963baf68dd7ede35742d697c57f70fb61d1cb1191c0b85e7fd4a1e816b"}, - {file = "strawberry_graphql-0.286.1.tar.gz", hash = "sha256:ba790d63911d9812f99bc287206e9941409a479417c7a04f039fb7e23109880e"}, + {file = "strawberry_graphql-0.288.2-py3-none-any.whl", hash = "sha256:ad72d7904582db333158568751bb6186a872380a8cc6671159d011d279382542"}, + {file = "strawberry_graphql-0.288.2.tar.gz", hash = "sha256:853dbab407e3f5099f3a27dbf37786535894a0fbf150df5dde145fc290db607e"}, ] [package.dependencies] asgiref = {version = ">=3.2,<4.0", optional = true, markers = "extra == \"django\""} +cross-web = ">=0.4.0" Django = {version = ">=3.2", optional = true, markers = "extra == \"django\""} graphql-core = ">=3.2.0,<3.4.0" -lia-web = ">=0.2.1" packaging = ">=23" -python-dateutil = ">=2.7,<3.0" +python-dateutil = ">=2.7" typing-extensions = ">=4.5.0" [package.extras] @@ -4528,7 +4509,7 @@ chalice = ["chalice (>=1.22,<2.0)"] channels = ["asgiref (>=3.2,<4.0)", "channels (>=3.0.5)"] cli = ["libcst", "pygments (>=2.3,<3.0)", "python-multipart (>=0.0.7)", "rich (>=12.0.0)", "starlette (>=0.18.0)", "typer (>=0.12.4)", "uvicorn (>=0.11.6)", "websockets (>=15.0.1,<16)"] debug = ["libcst", "rich (>=12.0.0)"] -debug-server = ["libcst", "pygments (>=2.3,<3.0)", "python-multipart (>=0.0.7)", "rich (>=12.0.0)", "starlette (>=0.18.0)", "typer (>=0.12.4)", "uvicorn (>=0.11.6)", "websockets (>=15.0.1,<16)"] +debug-server = ["libcst", "pygments (>=2.3)", "python-multipart (>=0.0.7)", "rich (>=12.0.0)", "starlette (>=0.18.0)", "typer (>=0.12.4)", "uvicorn (>=0.11.6)", "websockets (>=15.0.1,<16)"] django = ["Django (>=3.2)", "asgiref (>=3.2,<4.0)"] fastapi = ["fastapi (>=0.65.2)", "python-multipart (>=0.0.7)"] flask = ["flask (>=1.1)"] @@ -4541,20 +4522,20 @@ sanic = ["sanic (>=20.12.2)"] [[package]] name = "strawberry-graphql-django" -version = "0.67.2" +version = "0.73.0" description = "Strawberry GraphQL Django extension" optional = false python-versions = "<4.0,>=3.10" groups = ["main"] files = [ - {file = "strawberry_graphql_django-0.67.2-py3-none-any.whl", hash = "sha256:2d10b8156dd8b878f712e772a27bceea29965274883a9d4502c9e7fffbcf9cf3"}, - {file = "strawberry_graphql_django-0.67.2.tar.gz", hash = "sha256:c08581b59defa5348995ab04b58fb5d2930194447fb16717ff8906cdd4694b9d"}, + {file = "strawberry_graphql_django-0.73.0-py3-none-any.whl", hash = "sha256:f85cd9fc4a03ca4a430ce787c84cfc5e79e0b075e83d89ed8e9eaf76d035ce13"}, + {file = "strawberry_graphql_django-0.73.0.tar.gz", hash = "sha256:39f7205ca28a29763e3260548f7739ad6729a717d3f64b851208f57940c6360b"}, ] [package.dependencies] asgiref = ">=3.8" django = ">=4.2" -strawberry-graphql = ">=0.283.2" +strawberry-graphql = ">=0.288.0" [package.extras] debug-toolbar = ["django-debug-toolbar (>=6.0.0)"] @@ -4639,14 +4620,14 @@ telegram = ["requests"] [[package]] name = "troposphere" -version = "4.9.4" +version = "4.9.6" description = "AWS CloudFormation creation library" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "troposphere-4.9.4-py3-none-any.whl", hash = "sha256:45d1b600e5a0d0678416eaf5a48ee66f68d771e0c0e7ee695b6ebc8c93fb0e02"}, - {file = "troposphere-4.9.4.tar.gz", hash = "sha256:55af51da7a634960193ed054146cfa8656f5a8a7b0027aa7f200506e25058b08"}, + {file = "troposphere-4.9.6-py3-none-any.whl", hash = "sha256:8e878ba79dc2e95e3f9e9b8dcb21d1c43f2c2bdcc93d0b1c3d14dbedfc78d09d"}, + {file = "troposphere-4.9.6.tar.gz", hash = "sha256:0a1184f981d94eab96dd9831cd752d489e6f37b4fd24636fca0f397e9696b1bf"}, ] [package.dependencies] @@ -4700,34 +4681,66 @@ typing-extensions = ">=4.12.0" [[package]] name = "tzdata" -version = "2025.2" +version = "2025.3" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" groups = ["main"] markers = "sys_platform == \"win32\"" files = [ - {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, - {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, + {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, + {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, ] [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.2" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, - {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, + {file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"}, + {file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"}, ] [package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "uuid-utils" +version = "0.12.0" +description = "Drop-in replacement for Python UUID with bindings in Rust" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "uuid_utils-0.12.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:3b9b30707659292f207b98f294b0e081f6d77e1fbc760ba5b41331a39045f514"}, + {file = "uuid_utils-0.12.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:add3d820c7ec14ed37317375bea30249699c5d08ff4ae4dbee9fc9bce3bfbf65"}, + {file = "uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b8fce83ecb3b16af29c7809669056c4b6e7cc912cab8c6d07361645de12dd79"}, + {file = "uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec921769afcb905035d785582b0791d02304a7850fbd6ce924c1a8976380dfc6"}, + {file = "uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f3b060330f5899a92d5c723547dc6a95adef42433e9748f14c66859a7396664"}, + {file = "uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:908dfef7f0bfcf98d406e5dc570c25d2f2473e49b376de41792b6e96c1d5d291"}, + {file = "uuid_utils-0.12.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c6a24148926bd0ca63e8a2dabf4cc9dc329a62325b3ad6578ecd60fbf926506"}, + {file = "uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:64a91e632669f059ef605f1771d28490b1d310c26198e46f754e8846dddf12f4"}, + {file = "uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:93c082212470bb4603ca3975916c205a9d7ef1443c0acde8fbd1e0f5b36673c7"}, + {file = "uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:431b1fb7283ba974811b22abd365f2726f8f821ab33f0f715be389640e18d039"}, + {file = "uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd7838c40149100299fa37cbd8bab5ee382372e8e65a148002a37d380df7c8"}, + {file = "uuid_utils-0.12.0-cp39-abi3-win32.whl", hash = "sha256:487f17c0fee6cbc1d8b90fe811874174a9b1b5683bf2251549e302906a50fed3"}, + {file = "uuid_utils-0.12.0-cp39-abi3-win_amd64.whl", hash = "sha256:9598e7c9da40357ae8fffc5d6938b1a7017f09a1acbcc95e14af8c65d48c655a"}, + {file = "uuid_utils-0.12.0-cp39-abi3-win_arm64.whl", hash = "sha256:c9bea7c5b2aa6f57937ebebeee4d4ef2baad10f86f1b97b58a3f6f34c14b4e84"}, + {file = "uuid_utils-0.12.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e2209d361f2996966ab7114f49919eb6aaeabc6041672abbbbf4fdbb8ec1acc0"}, + {file = "uuid_utils-0.12.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d9636bcdbd6cfcad2b549c352b669412d0d1eb09be72044a2f13e498974863cd"}, + {file = "uuid_utils-0.12.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8cd8543a3419251fb78e703ce3b15fdfafe1b7c542cf40caf0775e01db7e7674"}, + {file = "uuid_utils-0.12.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e98db2d8977c052cb307ae1cb5cc37a21715e8d415dbc65863b039397495a013"}, + {file = "uuid_utils-0.12.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8f2bdf5e4ffeb259ef6d15edae92aed60a1d6f07cbfab465d836f6b12b48da8"}, + {file = "uuid_utils-0.12.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c3ec53c0cb15e1835870c139317cc5ec06e35aa22843e3ed7d9c74f23f23898"}, + {file = "uuid_utils-0.12.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:84e5c0eba209356f7f389946a3a47b2cc2effd711b3fc7c7f155ad9f7d45e8a3"}, + {file = "uuid_utils-0.12.0.tar.gz", hash = "sha256:252bd3d311b5d6b7f5dfce7a5857e27bb4458f222586bb439463231e5a9cbd64"}, +] [[package]] name = "validators" @@ -4767,18 +4780,18 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [[package]] name = "werkzeug" -version = "3.1.3" +version = "3.1.4" description = "The comprehensive WSGI web application library." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e"}, - {file = "werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"}, + {file = "werkzeug-3.1.4-py3-none-any.whl", hash = "sha256:2ad50fb9ed09cc3af22c54698351027ace879a0b60a3b5edf5730b2f7d876905"}, + {file = "werkzeug-3.1.4.tar.gz", hash = "sha256:cd3cd98b1b92dc3b7b3995038826c68097dcb16f9baa63abe35f20eafeb9fe5e"}, ] [package.dependencies] -MarkupSafe = ">=2.1.1" +markupsafe = ">=2.1.1" [package.extras] watchdog = ["watchdog (>=2.3)"] @@ -5240,4 +5253,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "66ecba4932adc6bc7400e78a8850198bb9aac836950d539485249a4d8783de5a" +content-hash = "5401f036ef6e58493aafd538a4d405d6e14bf61a7b517692bdb6071a84263074" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 873ba4945d..a38c0df65e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -14,7 +14,7 @@ packages = [ { include = "apps" } ] [tool.poetry.dependencies] algoliasearch = "^4.13.2" algoliasearch-django = "^4.0.0" -django = "^5.2" +django = "^6.0" django-configurations = "^2.5.1" django-cors-headers = "^4.7.0" django-ninja = "^1.4.3" @@ -32,7 +32,7 @@ langgraph = "^1.0.1" lxml = "^6.0.0" markdown = "^3.7" openai = "^2.0.1" -owasp-schema = "^0.1.36" +owasp-schema = "^0.1.46" pgvector = "^0.4.1" psycopg2-binary = "^2.9.9" pydantic = "^2.11.1" @@ -46,8 +46,8 @@ 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.286.0" } -strawberry-graphql-django = "^0.67.0" +strawberry-graphql = { extras = [ "django" ], version = "^0.288.1" } +strawberry-graphql-django = "^0.73.0" thefuzz = "^0.22.1" pyparsing = "^3.2.3" zappa = "^0.60.2" @@ -91,9 +91,10 @@ lint.per-file-ignores."**/__init__.py" = [ "F401", # https://docs.astral.sh/ruff/rules/unused-import/ ] lint.per-file-ignores."**/management/commands/*.py" = [ - "D101", # https://docs.astral.sh/ruff/rules/undocumented-public-class/ - "D102", # https://docs.astral.sh/ruff/rules/undocumented-public-method/ - "T201", # https://docs.astral.sh/ruff/rules/print/ + "D101", # https://docs.astral.sh/ruff/rules/undocumented-public-class/ + "D102", # https://docs.astral.sh/ruff/rules/undocumented-public-method/ + "SLF001", # https://docs.astral.sh/ruff/rules/private-member-access/ + "T201", # https://docs.astral.sh/ruff/rules/print/ ] lint.per-file-ignores."**/migrations/*.py" = [ "D100", # https://docs.astral.sh/ruff/rules/undocumented-public-module/ @@ -143,7 +144,6 @@ addopts = [ filterwarnings = [ "ignore::DeprecationWarning:pytest_cov", "ignore::DeprecationWarning:xdist", - "ignore::django.conf.RemovedInDjango60Warning:django", "ignore::pydantic.warnings.PydanticDeprecatedSince20", ] log_level = "INFO" diff --git a/backend/settings/base.py b/backend/settings/base.py index 6c97a2b234..b919df4c76 100644 --- a/backend/settings/base.py +++ b/backend/settings/base.py @@ -38,8 +38,9 @@ class Base(Configuration): "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", - "django.contrib.sessions", "django.contrib.messages", + "django.contrib.postgres", + "django.contrib.sessions", "django.contrib.staticfiles", ) @@ -129,6 +130,8 @@ class Base(Configuration): API_PAGE_SIZE = 100 API_CACHE_PREFIX = "api-response" API_CACHE_TIME_SECONDS = 86400 # 24 hours. + GRAPHQL_RESOLVER_CACHE_PREFIX = "graphql-resolver" + GRAPHQL_RESOLVER_CACHE_TIME_SECONDS = 86400 # 24 hours. NINJA_PAGINATION_CLASS = "apps.api.rest.v0.pagination.CustomPagination" NINJA_PAGINATION_PER_PAGE = API_PAGE_SIZE diff --git a/backend/settings/graphql.py b/backend/settings/graphql.py index d5d59d0464..edb830c1ff 100644 --- a/backend/settings/graphql.py +++ b/backend/settings/graphql.py @@ -4,6 +4,7 @@ from apps.api.internal.mutations import ApiMutations from apps.api.internal.queries import ApiKeyQueries +from apps.common.extensions import CacheExtension from apps.github.api.internal.queries import GithubQuery from apps.mentorship.api.internal.mutations import ( ModuleMutation, @@ -40,4 +41,4 @@ class Query( """Schema queries.""" -schema = strawberry.Schema(mutation=Mutation, query=Query) +schema = strawberry.Schema(mutation=Mutation, query=Query, extensions=[CacheExtension]) diff --git a/backend/tests/apps/api/rest/v0/chapter_test.py b/backend/tests/apps/api/rest/v0/chapter_test.py index 0a29d4a53f..cc2969edf9 100644 --- a/backend/tests/apps/api/rest/v0/chapter_test.py +++ b/backend/tests/apps/api/rest/v0/chapter_test.py @@ -31,11 +31,24 @@ ], ) def test_chapter_serializer_validation(chapter_data): + class MockMember: + def __init__(self, login): + self.login = login + + class MockEntityMember: + def __init__(self, name, login=None): + self.member = MockMember(login) if login else None + self.member_name = name + class MockChapter: def __init__(self, data): for key, value in data.items(): setattr(self, key, value) self.nest_key = data["key"] + self.entity_leaders = [ + MockEntityMember("Alice", "alice"), + MockEntityMember("Bob"), + ] chapter = ChapterDetail.from_orm(MockChapter(chapter_data)) @@ -44,6 +57,11 @@ def __init__(self, data): assert chapter.key == chapter_data["key"] assert chapter.latitude == chapter_data["latitude"] assert chapter.longitude == chapter_data["longitude"] + assert len(chapter.leaders) == 2 + assert chapter.leaders[0].key == "alice" + assert chapter.leaders[0].name == "Alice" + assert chapter.leaders[1].key is None + assert chapter.leaders[1].name == "Bob" assert chapter.name == chapter_data["name"] assert chapter.region == chapter_data["region"] assert chapter.updated_at == datetime.fromisoformat(chapter_data["updated_at"]) diff --git a/backend/tests/apps/api/rest/v0/project_test.py b/backend/tests/apps/api/rest/v0/project_test.py index 112b41fb5c..e197ab05f7 100644 --- a/backend/tests/apps/api/rest/v0/project_test.py +++ b/backend/tests/apps/api/rest/v0/project_test.py @@ -27,17 +27,35 @@ ], ) def test_project_serializer_validation(project_data): + class MockMember: + def __init__(self, login): + self.login = login + + class MockEntityMember: + def __init__(self, name, login=None): + self.member = MockMember(login) if login else None + self.member_name = name + class MockProject: def __init__(self, data): for key, value in data.items(): setattr(self, key, value) self.nest_key = data["key"] + self.entity_leaders = [ + MockEntityMember("Alice", "alice"), + MockEntityMember("Bob"), + ] project = ProjectDetail.from_orm(MockProject(project_data)) assert project.created_at == datetime.fromisoformat(project_data["created_at"]) assert project.description == project_data["description"] assert project.key == project_data["key"] + assert len(project.leaders) == 2 + assert project.leaders[0].key == "alice" + assert project.leaders[0].name == "Alice" + assert project.leaders[1].key is None + assert project.leaders[1].name == "Bob" assert project.level == project_data["level"] assert project.name == project_data["name"] assert project.updated_at == datetime.fromisoformat(project_data["updated_at"]) diff --git a/backend/tests/apps/common/extensions_test.py b/backend/tests/apps/common/extensions_test.py new file mode 100644 index 0000000000..3b7bb1c982 --- /dev/null +++ b/backend/tests/apps/common/extensions_test.py @@ -0,0 +1,163 @@ +"""Tests for CacheExtension.""" + +from unittest.mock import MagicMock, patch + +import pytest +from strawberry.permission import PermissionExtension + +from apps.common.extensions import CacheExtension, get_protected_fields + + +class TestGenerateKey: + """Test cases for the generate_key method.""" + + @pytest.fixture + def extension(self): + """Return a CacheExtension instance.""" + return CacheExtension() + + def test_creates_deterministic_hash(self, extension): + """Test that generate_key creates a deterministic hash key.""" + key1 = extension.generate_key("chapter", {"key": "germany"}) + key2 = extension.generate_key("chapter", {"key": "germany"}) + + assert key1 == key2 + assert key1.startswith("graphql-") + assert len(key1.split("-")[-1]) == 64 # SHA256 hex digest length + + def test_differs_for_different_field_names(self, extension): + """Test that different field names produce different keys.""" + key1 = extension.generate_key("chapter", {"key": "germany"}) + key2 = extension.generate_key("project", {"key": "germany"}) + + assert key1 != key2 + + def test_differs_for_different_args(self, extension): + """Test that different arguments produce different keys.""" + key1 = extension.generate_key("chapter", {"key": "germany"}) + key2 = extension.generate_key("chapter", {"key": "canada"}) + + assert key1 != key2 + + def test_sorts_args_for_consistency(self, extension): + """Test that argument order doesn't affect the key.""" + key1 = extension.generate_key("chapter", {"a": "1", "b": "2"}) + key2 = extension.generate_key("chapter", {"b": "2", "a": "1"}) + + assert key1 == key2 + + +class TestGetProtectedFields: + """Test cases for the get_protected_fields function.""" + + @pytest.fixture + def mock_schema(self): + """Return a mock schema with protected and public fields.""" + mock_field_protected = MagicMock() + mock_field_protected.name = "api_keys" + mock_field_protected.extensions = [MagicMock(spec=PermissionExtension)] + + mock_field_public = MagicMock() + mock_field_public.name = "chapters" + mock_field_public.extensions = [] + + mock_query_type = MagicMock() + mock_query_type.definition.fields = [mock_field_protected, mock_field_public] + + mock_schema = MagicMock() + mock_schema.schema_converter.type_map.get.return_value = mock_query_type + return mock_schema + + def test_returns_protected_fields_in_camel_case(self, mock_schema): + """Test that protected fields are returned in camelCase.""" + get_protected_fields.cache_clear() + protected = get_protected_fields(mock_schema) + + assert "apiKeys" in protected + assert "chapters" not in protected + + def test_returns_tuple(self, mock_schema): + """Test that get_protected_fields returns a tuple.""" + get_protected_fields.cache_clear() + protected = get_protected_fields(mock_schema) + + assert isinstance(protected, tuple) + + +class TestResolve: + """Test cases for the resolve method.""" + + @pytest.fixture(autouse=True) + def mock_protected_fields(self): + """Patch get_protected_fields for all tests.""" + with patch("apps.common.extensions.get_protected_fields", return_value=("apiKeys",)): + yield + + @pytest.fixture + def mock_info(self): + """Return a mock GraphQL resolve info.""" + mock = MagicMock() + mock.field_name = "chapter" + mock.parent_type.name = "Query" + return mock + + @pytest.fixture + def mock_next(self): + """Return a mock next resolver.""" + return MagicMock(return_value={"name": "OWASP"}) + + @pytest.fixture + def extension(self): + """Return a CacheExtension instance.""" + extension = CacheExtension() + extension.execution_context = MagicMock() + return extension + + def test_skips_introspection_queries(self, extension, mock_info, mock_next): + """Test that introspection queries skip caching.""" + mock_info.field_name = "__schema" + + result = extension.resolve(mock_next, None, mock_info) + + mock_next.assert_called_once() + assert result == mock_next.return_value + + def test_skips_non_query_fields(self, extension, mock_info, mock_next): + """Test that non-Query parent types skip caching.""" + mock_info.parent_type.name = "ChapterNode" + + result = extension.resolve(mock_next, None, mock_info) + + mock_next.assert_called_once() + assert result == mock_next.return_value + + def test_skips_protected_fields(self, extension, mock_info, mock_next): + """Test that protected fields skip caching.""" + mock_info.field_name = "apiKeys" + + result = extension.resolve(mock_next, None, mock_info) + + mock_next.assert_called_once() + assert result == mock_next.return_value + + @patch("apps.common.extensions.cache") + def test_returns_cached_result_on_hit(self, mock_cache, extension, mock_info, mock_next): + """Test that cached result is returned on cache hit.""" + cached_result = {"name": "Cached OWASP"} + mock_cache.get_or_set.return_value = cached_result + + result = extension.resolve(mock_next, None, mock_info, key="germany") + + assert result == cached_result + mock_cache.get_or_set.assert_called_once() + mock_next.assert_not_called() + + @patch("apps.common.extensions.cache") + def test_caches_result_on_miss(self, mock_cache, extension, mock_info, mock_next): + """Test that result is cached on cache miss.""" + mock_cache.get_or_set.side_effect = lambda _key, default, _timeout: default() + + extension.resolve(mock_next, None, mock_info, key="germany") + + mock_next.assert_called_once() + mock_cache.get_or_set.assert_called_once() diff --git a/backend/tests/apps/common/management/commands/add_project_custom_tags_test.py b/backend/tests/apps/common/management/commands/add_project_custom_tags_test.py index 453f29f580..6efbc7995e 100644 --- a/backend/tests/apps/common/management/commands/add_project_custom_tags_test.py +++ b/backend/tests/apps/common/management/commands/add_project_custom_tags_test.py @@ -96,7 +96,7 @@ def __init__(self, key): self.key = key self.custom_tags = projects[key] - def save(self, update_fields=None): + def save(self, update_fields=None): # NOSONAR unused parameter. projects[self.key] = self.custom_tags mock_get.side_effect = lambda key: MockProject(key) diff --git a/backend/tests/apps/github/index/registry/repository_test.py b/backend/tests/apps/github/index/registry/repository_test.py index 99c1bbbf80..0a8f3c9acb 100644 --- a/backend/tests/apps/github/index/registry/repository_test.py +++ b/backend/tests/apps/github/index/registry/repository_test.py @@ -40,7 +40,9 @@ def test_get_entities(self, repository_index): with patch.object(Repository, "objects", mock_manager): queryset = repository_index.get_entities() - mock_manager.filter.assert_called_once_with(is_template=False) + mock_manager.filter.assert_called_once_with( + is_template=False, organization__isnull=False + ) mock_manager.filter.return_value.prefetch_related.assert_called_once_with( "repositorycontributor_set" ) diff --git a/backend/tests/apps/github/management/commands/github_sync_user_test.py b/backend/tests/apps/github/management/commands/github_sync_user_test.py index 66b5973b77..b465eccff8 100644 --- a/backend/tests/apps/github/management/commands/github_sync_user_test.py +++ b/backend/tests/apps/github/management/commands/github_sync_user_test.py @@ -16,7 +16,7 @@ class MockPaginatedList: def __init__(self, items): self._items = items - self.totalCount = len(items) + self.totalCount = len(items) # NOSONAR (for consistency with PyGithub). def __iter__(self): """Return an iterator for the items.""" diff --git a/backend/tests/apps/nest/management/commands/nest_update_badges_test.py b/backend/tests/apps/nest/management/commands/nest_update_badges_test.py index 8ec63cff2d..56b708e54c 100644 --- a/backend/tests/apps/nest/management/commands/nest_update_badges_test.py +++ b/backend/tests/apps/nest/management/commands/nest_update_badges_test.py @@ -5,7 +5,10 @@ from django.core.management import call_command -from apps.nest.management.commands.nest_update_badges import OWASP_STAFF_BADGE_NAME +from apps.nest.management.commands.nest_update_badges import ( + OWASP_PROJECT_LEADER_BADGE_NAME, + OWASP_STAFF_BADGE_NAME, +) def make_mock_employees(mock_employee): @@ -29,6 +32,18 @@ def make_mock_former_employees(mock_former_employee): return mock_former_employees +def make_mock_project_leaders(mock_leader): + """Create mock objects for project leaders query chain.""" + mock_filtered_leaders = MagicMock() + mock_distinct_leaders = MagicMock() + mock_leaders_without_badge = MagicMock() + mock_leaders_without_badge.__iter__.return_value = iter([mock_leader]) + mock_leaders_without_badge.count.return_value = 1 + mock_filtered_leaders.distinct.return_value = mock_distinct_leaders + mock_distinct_leaders.exclude.return_value = mock_leaders_without_badge + return mock_filtered_leaders + + def extract_is_owasp_staff(arg): """Extract is_owasp_staff value from Q object, dict, or tuple.""" if hasattr(arg, "children"): @@ -69,6 +84,8 @@ def user_filter_side_effect(*args, **kwargs): class TestSyncUserBadgesCommand: """Tests for the nest_update_badges management command.""" + @patch("apps.nest.management.commands.nest_update_badges.EntityMember.objects.filter") + @patch("apps.nest.management.commands.nest_update_badges.ContentType.objects.get_for_model") @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.get_or_create") @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.filter") @patch("apps.nest.management.commands.nest_update_badges.Badge.objects.get_or_create") @@ -79,6 +96,8 @@ def test_sync_owasp_staff_badge( mock_badge_get_or_create, mock_user_badge_filter, mock_user_badge_get_or_create, + mock_content_type_get, + mock_entity_member_filter, ): # Set up badge mock mock_badge = MagicMock() @@ -118,15 +137,77 @@ def test_sync_owasp_staff_badge( for s in ("Removed badge from 1 non-staff", "Removed badge from 1 non-employees") ) + @patch("apps.nest.management.commands.nest_update_badges.EntityMember.objects.filter") + @patch("apps.nest.management.commands.nest_update_badges.ContentType.objects.get_for_model") + @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.get_or_create") + @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.filter") + @patch("apps.nest.management.commands.nest_update_badges.Badge.objects.get_or_create") + @patch("apps.nest.management.commands.nest_update_badges.User.objects.filter") + def test_sync_owasp_project_leader_badge( + self, + mock_user_filter, + mock_badge_get_or_create, + mock_user_badge_filter, + mock_user_badge_get_or_create, + mock_content_type_get_for_model, + mock_entity_member_filter, + ): + mock_badge = MagicMock() + mock_badge.name = OWASP_PROJECT_LEADER_BADGE_NAME + mock_badge_get_or_create.return_value = (mock_badge, False) + mock_leader = MagicMock() + mock_leader.id = 999 + mock_project_leaders = make_mock_project_leaders(mock_leader) + + def user_filter_side_effect(*_args, **kwargs): + if "id__in" in kwargs: + return mock_project_leaders + return MagicMock() + + mock_user_filter.side_effect = user_filter_side_effect + mock_user_badge_get_or_create.return_value = (MagicMock(), True) + mock_entity_qs = MagicMock() + mock_values_qs = MagicMock() + mock_distinct_qs = MagicMock() + mock_entity_member_filter.return_value = mock_entity_qs + mock_entity_qs.values_list.return_value = mock_values_qs + mock_values_qs.distinct.return_value = mock_distinct_qs + mock_distinct_qs.__iter__.return_value = iter([mock_leader.id]) + out = StringIO() + call_command("nest_update_badges", stdout=out) + + mock_badge_get_or_create.assert_any_call( + name=OWASP_PROJECT_LEADER_BADGE_NAME, + defaults={ + "description": "Official OWASP Project Leader", + "css_class": "fa-user-shield", + "weight": 90, + }, + ) + + mock_user_badge_get_or_create.assert_any_call(user=mock_leader, badge=mock_badge) + + output = out.getvalue() + assert "Added badge to 1 project leaders" in output + + @patch("apps.nest.management.commands.nest_update_badges.EntityMember.objects.filter") + @patch("apps.nest.management.commands.nest_update_badges.ContentType.objects.get_for_model") + @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.get_or_create") + @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.filter") @patch("apps.nest.management.commands.nest_update_badges.Badge.objects.get_or_create") @patch("apps.nest.management.commands.nest_update_badges.User.objects.filter") - def test_badge_creation(self, mock_user_filter, mock_badge_get_or_create): - # Set up badge creation mock + def test_badge_creation( + self, + mock_user_filter, + mock_badge_get_or_create, + mock_user_badge_filter, + mock_user_badge_get_or_create, + mock_content_type_get, + mock_entity_member_filter, + ): mock_badge = MagicMock() mock_badge.name = OWASP_STAFF_BADGE_NAME mock_badge_get_or_create.return_value = (mock_badge, True) - - # Set up empty querysets mock_employees = MagicMock() mock_employees.__iter__.return_value = iter([]) mock_employees.count.return_value = 0 @@ -142,12 +223,23 @@ def test_badge_creation(self, mock_user_filter, mock_badge_get_or_create): mock_former_employees.values_list.return_value = [] mock_former_employees.distinct.return_value = mock_former_employees - mock_user_filter.side_effect = [mock_employees, mock_former_employees] + mock_user_filter.side_effect = [ + mock_employees, + mock_former_employees, + mock_employees, + ] + mock_entity_qs = MagicMock() + mock_values_qs = MagicMock() + mock_distinct_qs = MagicMock() + mock_entity_member_filter.return_value = mock_entity_qs + mock_entity_qs.values_list.return_value = mock_values_qs + mock_values_qs.distinct.return_value = mock_distinct_qs + mock_distinct_qs.__iter__.return_value = iter([]) out = StringIO() call_command("nest_update_badges", stdout=out) - mock_badge_get_or_create.assert_called_once_with( + mock_badge_get_or_create.assert_any_call( name=OWASP_STAFF_BADGE_NAME, defaults={ "description": "Official OWASP Staff", @@ -158,9 +250,21 @@ def test_badge_creation(self, mock_user_filter, mock_badge_get_or_create): output = out.getvalue() assert f"Created badge: {mock_badge.name}" in output + @patch("apps.nest.management.commands.nest_update_badges.EntityMember.objects.filter") + @patch("apps.nest.management.commands.nest_update_badges.ContentType.objects.get_for_model") + @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.get_or_create") + @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.filter") @patch("apps.nest.management.commands.nest_update_badges.Badge.objects.get_or_create") @patch("apps.nest.management.commands.nest_update_badges.User.objects.filter") - def test_command_idempotency(self, mock_user_filter, mock_badge_get_or_create): + def test_command_idempotency( + self, + mock_user_filter, + mock_badge_get_or_create, + mock_user_badge_filter, + mock_user_badge_get_or_create, + mock_content_type_get, + mock_entity_member_filter, + ): """Test that running the command multiple times has the same effect as running it once.""" # Set up badge mock mock_badge = MagicMock() @@ -186,13 +290,29 @@ def test_command_idempotency(self, mock_user_filter, mock_badge_get_or_create): mock_non_employees_filter.values_list.return_value = [] mock_non_employees_filter.distinct.return_value = mock_non_employees_filter + mock_leaders = MagicMock() + mock_leaders.distinct.return_value = mock_leaders + mock_leaders.exclude.return_value = mock_leaders + mock_leaders.count.return_value = 0 + # Configure filter side effects for two command runs mock_user_filter.side_effect = [ mock_employees, mock_non_employees_filter, + mock_leaders, mock_employees, mock_non_employees_filter, + mock_leaders, ] + mock_entity_qs = MagicMock() + mock_values_qs = MagicMock() + mock_distinct_qs = MagicMock() + mock_entity_member_filter.return_value = mock_entity_qs + mock_entity_qs.values_list.return_value = mock_values_qs + mock_values_qs.distinct.return_value = mock_distinct_qs + mock_distinct_qs.__iter__.return_value = iter([]) + mock_user_badge_filter.return_value.count.return_value = 0 + mock_user_badge_filter.return_value.exclude.return_value.count.return_value = 0 # First run out1 = StringIO() diff --git a/backend/tests/apps/owasp/api/internal/nodes/chapter_test.py b/backend/tests/apps/owasp/api/internal/nodes/chapter_test.py new file mode 100644 index 0000000000..f05abd9b80 --- /dev/null +++ b/backend/tests/apps/owasp/api/internal/nodes/chapter_test.py @@ -0,0 +1,92 @@ +"""Test cases for ChapterNode.""" + +from apps.owasp.api.internal.nodes.chapter import ChapterNode + + +class TestChapterNode: + def test_chapter_node_inheritance(self): + assert hasattr(ChapterNode, "__strawberry_definition__") + + def test_meta_configuration(self): + field_names = {field.name for field in ChapterNode.__strawberry_definition__.fields} + expected_field_names = { + "contribution_data", + "contribution_stats", + "country", + "created_at", + "is_active", + "name", + "region", + "summary", + "key", + "geo_location", + "suggested_location", + "meetup_group", + "postal_code", + "tags", + } + assert expected_field_names.issubset(field_names) + + def _get_field_by_name(self, name): + return next( + (f for f in ChapterNode.__strawberry_definition__.fields if f.name == name), None + ) + + def test_resolve_key(self): + field = self._get_field_by_name("key") + assert field is not None + assert field.type is str + + def test_resolve_country(self): + field = self._get_field_by_name("country") + assert field is not None + assert field.type is str + + def test_resolve_region(self): + field = self._get_field_by_name("region") + assert field is not None + assert field.type is str + + def test_resolve_is_active(self): + field = self._get_field_by_name("is_active") + assert field is not None + assert field.type is bool + + def test_resolve_contribution_data(self): + field = self._get_field_by_name("contribution_data") + assert field is not None + assert field.type.__class__.__name__ == "NewType" + + def test_resolve_contribution_stats(self): + field = self._get_field_by_name("contribution_stats") + assert field is not None + assert field.type.__class__.__name__ == "StrawberryOptional" + + def test_contribution_stats_transforms_snake_case_to_camel_case(self): + """Test that contribution_stats resolver transforms snake_case keys to camelCase.""" + from unittest.mock import Mock + + mock_chapter = Mock() + mock_chapter.contribution_stats = { + "commits": 75, + "pull_requests": 30, + "issues": 15, + "releases": 5, + "total": 125, + } + + instance = type("BoundNode", (), {})() + instance.contribution_stats = mock_chapter.contribution_stats + + field = self._get_field_by_name("contribution_stats") + resolver = field.base_resolver.wrapped_func + + result = resolver(instance) + + assert result is not None + assert result["commits"] == 75 + assert result["pullRequests"] == 30 + assert result["issues"] == 15 + assert result["releases"] == 5 + assert result["total"] == 125 + assert "pull_requests" not in result 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 03cff7115b..d47cb64287 100644 --- a/backend/tests/apps/owasp/api/internal/nodes/project_test.py +++ b/backend/tests/apps/owasp/api/internal/nodes/project_test.py @@ -16,26 +16,28 @@ def test_project_node_inheritance(self): def test_meta_configuration(self): field_names = {field.name for field in ProjectNode.__strawberry_definition__.fields} expected_field_names = { + "contribution_data", + "contribution_stats", "contributors_count", "created_at", "forks_count", "is_active", - "level", - "name", - "open_issues_count", - "stars_count", - "summary", - "type", "issues_count", "key", "languages", + "level", + "name", + "open_issues_count", "recent_issues", "recent_milestones", "recent_pull_requests", "recent_releases", - "repositories", "repositories_count", + "repositories", + "stars_count", + "summary", "topics", + "type", } assert expected_field_names.issubset(field_names) @@ -103,3 +105,42 @@ def test_resolve_topics(self): field = self._get_field_by_name("topics") assert field is not None assert field.type == list[str] + + def test_resolve_contribution_stats(self): + field = self._get_field_by_name("contribution_stats") + assert field is not None + assert field.type.__class__.__name__ == "StrawberryOptional" + + def test_resolve_contribution_data(self): + field = self._get_field_by_name("contribution_data") + assert field is not None + assert field.type.__class__.__name__ == "StrawberryOptional" + + def test_contribution_stats_transforms_snake_case_to_camel_case(self): + """Test that contribution_stats resolver transforms snake_case keys to camelCase.""" + from unittest.mock import Mock + + mock_project = Mock() + mock_project.contribution_stats = { + "commits": 100, + "issues": 25, + "pull_requests": 50, + "releases": 10, + "total": 185, + } + + instance = type("BoundNode", (), {})() + instance.contribution_stats = mock_project.contribution_stats + + field = self._get_field_by_name("contribution_stats") + resolver = field.base_resolver.wrapped_func + + result = resolver(instance) + + assert result is not None + assert result["commits"] == 100 + assert result["pullRequests"] == 50 + assert result["issues"] == 25 + assert result["releases"] == 10 + assert result["total"] == 185 + assert "pull_requests" not in result diff --git a/backend/tests/apps/owasp/index/registry/__init__.py b/backend/tests/apps/owasp/index/registry/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/apps/owasp/index/registry/project_test.py b/backend/tests/apps/owasp/index/registry/project_test.py new file mode 100644 index 0000000000..1e3f9a1a3d --- /dev/null +++ b/backend/tests/apps/owasp/index/registry/project_test.py @@ -0,0 +1,58 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from apps.owasp.index.registry.project import ProjectIndex +from apps.owasp.models.project import Project + + +@pytest.fixture +def project_index(mocker): + """Return an instance of the ProjectIndex.""" + mocker.patch("apps.common.index.IndexBase.__init__", return_value=None) + return ProjectIndex() + + +class TestProjectIndex: + def test_class_attributes(self): + """Test that the basic class attributes are set correctly.""" + assert ProjectIndex.index_name == "projects" + assert ProjectIndex.should_index == "is_indexable" + assert isinstance(ProjectIndex.fields, tuple) + assert len(ProjectIndex.fields) > 0 + assert isinstance(ProjectIndex.settings, dict) + assert "attributesForFaceting" in ProjectIndex.settings + + @patch("apps.common.index.IndexBase.configure_replicas") + def test_configure_replicas(self, mock_configure_replicas): + """Test that configure_replicas calls the parent method with correct args.""" + ProjectIndex.configure_replicas() + assert mock_configure_replicas.call_count == 1 + assert mock_configure_replicas.call_args[0][0] == "projects" + assert isinstance(mock_configure_replicas.call_args[0][1], dict) + + @patch("apps.common.index.IndexBase.reindex_synonyms") + def test_update_synonyms(self, mock_reindex_synonyms): + """Test that update_synonyms calls the parent method with correct args.""" + ProjectIndex.update_synonyms() + mock_reindex_synonyms.assert_called_once_with("owasp", "projects") + + def test_get_entities(self, project_index): + """Test that get_entities constructs the correct queryset by chaining.""" + mock_manager = MagicMock() + mock_manager.prefetch_related.return_value.filter.return_value.distinct.return_value = ( + "final_queryset" + ) + + with patch.object(Project, "objects", mock_manager): + queryset = project_index.get_entities() + + mock_manager.prefetch_related.assert_called_once_with( + "organizations", + "repositories", + ) + mock_manager.prefetch_related.return_value.filter.assert_called_once_with( + organizations__isnull=False + ) + mock_manager.prefetch_related.return_value.filter.return_value.distinct.assert_called_once() + assert queryset == "final_queryset" diff --git a/backend/tests/apps/owasp/management/commands/owasp_aggregate_contributions_test.py b/backend/tests/apps/owasp/management/commands/owasp_aggregate_contributions_test.py new file mode 100644 index 0000000000..e988e6d061 --- /dev/null +++ b/backend/tests/apps/owasp/management/commands/owasp_aggregate_contributions_test.py @@ -0,0 +1,444 @@ +"""Test cases for owasp_aggregate_contributions management command.""" + +from datetime import UTC, datetime, timedelta +from unittest import mock + +import pytest + +from apps.owasp.management.commands.owasp_aggregate_contributions import Command +from apps.owasp.models import Chapter, Project + + +class MockQuerySet: + """Mock QuerySet that supports slicing and iteration without database access.""" + + def __init__(self, items): + self._items = items + + def __iter__(self): + """Return iterator over items.""" + return iter(self._items) + + def __getitem__(self, key): + """Get item by key or slice.""" + if isinstance(key, slice): + return MockQuerySet(self._items[key]) + return self._items[key] + + def filter(self, **kwargs): + # Return self to support filter chaining + return self + + def order_by(self, *_fields): + """Mock order_by method.""" + return self + + def select_related(self, *_): + """Mock select_related method.""" + return self + + def prefetch_related(self, *_): + """Mock prefetch_related method.""" + return self + + def count(self): + """Return count of items.""" + return len(self._items) + + def __len__(self): + """Return length of items.""" + return len(self._items) + + +class TestOwaspAggregateContributions: + @pytest.fixture + def command(self): + return Command() + + @pytest.fixture + def mock_chapter(self): + chapter = mock.Mock(spec=Chapter) + chapter.key = "www-chapter-test" + chapter.name = "Test Chapter" + chapter.owasp_repository = mock.Mock() + chapter.owasp_repository.id = 1 + # Fix Django ORM compatibility. + chapter.owasp_repository.resolve_expression = mock.Mock( + return_value=chapter.owasp_repository + ) + chapter.owasp_repository.get_source_expressions = mock.Mock(return_value=[]) + return chapter + + @pytest.fixture + def mock_project(self): + project = mock.Mock(spec=Project) + project.key = "www-project-test" + project.name = "Test Project" + project.owasp_repository = mock.Mock() + project.owasp_repository.id = 1 + # Fix Django ORM compatibility. + project.owasp_repository.resolve_expression = mock.Mock( + return_value=project.owasp_repository + ) + project.owasp_repository.get_source_expressions = mock.Mock(return_value=[]) + + # Mock additional repositories. + additional_repo1 = mock.Mock(id=2) + additional_repo1.resolve_expression = mock.Mock(return_value=additional_repo1) + additional_repo1.get_source_expressions = mock.Mock(return_value=[]) + + additional_repo2 = mock.Mock(id=3) + additional_repo2.resolve_expression = mock.Mock(return_value=additional_repo2) + additional_repo2.get_source_expressions = mock.Mock(return_value=[]) + + project.repositories.all.return_value = [additional_repo1, additional_repo2] + return project + + def test_aggregate_contribution_dates_helper(self, command): + """Test the helper method that aggregates dates.""" + contribution_map = {} + + # Create mock queryset with dates. + mock_dates = [ + datetime(2024, 11, 16, 10, 0, 0, tzinfo=UTC), + datetime(2024, 11, 16, 14, 0, 0, tzinfo=UTC), + datetime(2024, 11, 17, 9, 0, 0, tzinfo=UTC), + None, + ] + + mock_queryset = mock.Mock() + mock_queryset.values_list.return_value = mock_dates + + command._aggregate_contribution_dates( + mock_queryset, + "created_at", + contribution_map, + ) + + assert contribution_map == { + "2024-11-16": 2, + "2024-11-17": 1, + } + + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + def test_aggregate_chapter_contributions( + self, + mock_release, + mock_pr, + mock_issue, + mock_commit, + command, + mock_chapter, + ): + """Test aggregating contributions for a chapter.""" + start_date = datetime.now(tz=UTC) - timedelta(days=365) + + # Mock querysets. + mock_commit.objects.filter.return_value.values_list.return_value = [ + datetime(2024, 11, 16, 10, 0, 0, tzinfo=UTC), + ] + mock_issue.objects.filter.return_value.values_list.return_value = [ + datetime(2024, 11, 16, 11, 0, 0, tzinfo=UTC), + ] + mock_pr.objects.filter.return_value.values_list.return_value = [ + datetime(2024, 11, 17, 10, 0, 0, tzinfo=UTC), + ] + mock_release.objects.filter.return_value.values_list.return_value = [ + datetime(2024, 11, 17, 12, 0, 0, tzinfo=UTC), + ] + + result = command.aggregate_contributions(mock_chapter, start_date) + + assert result == { + "2024-11-16": 2, + "2024-11-17": 2, + } + + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + def test_aggregate_project_contributions( + self, + mock_release, + mock_pr, + mock_issue, + mock_commit, + command, + mock_project, + ): + """Test aggregating contributions for a project.""" + start_date = datetime.now(tz=UTC) - timedelta(days=365) + + # Mock querysets. + mock_commit.objects.filter.return_value.values_list.return_value = [ + datetime(2024, 11, 16, 10, 0, 0, tzinfo=UTC), + datetime(2024, 11, 16, 14, 0, 0, tzinfo=UTC), + ] + mock_issue.objects.filter.return_value.values_list.return_value = [ + datetime(2024, 11, 17, 11, 0, 0, tzinfo=UTC), + ] + mock_pr.objects.filter.return_value.values_list.return_value = [ + datetime(2024, 11, 18, 10, 0, 0, tzinfo=UTC), + ] + mock_release.objects.filter.return_value.values_list.return_value = [ + datetime(2024, 11, 18, 12, 0, 0, tzinfo=UTC), + ] + + result = command.aggregate_contributions(mock_project, start_date) + + assert result == { + "2024-11-16": 2, + "2024-11-17": 1, + "2024-11-18": 2, + } + + def test_aggregate_chapter_without_repository(self, command, mock_chapter): + """Test that chapters without repositories return empty map.""" + mock_chapter.owasp_repository = None + start_date = datetime.now(tz=UTC) - timedelta(days=365) + + result = command.aggregate_contributions(mock_chapter, start_date) + + assert result == {} + + def test_aggregate_project_without_repositories(self, command, mock_project): + """Test that projects without repositories return empty map.""" + mock_project.owasp_repository = None + mock_project.repositories.all.return_value = [] + start_date = datetime.now(tz=UTC) - timedelta(days=365) + + result = command.aggregate_contributions(mock_project, start_date) + + assert result == {} + + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + def test_handle_chapters_only( + self, + mock_release, + mock_pr, + mock_issue, + mock_commit, + mock_chapter_model, + command, + mock_chapter, + ): + """Test command execution for chapters only.""" + mock_chapter_model.objects.filter.return_value = MockQuerySet([mock_chapter]) + mock_chapter_model.bulk_save = mock.Mock() + + # Mock ORM queries to return counts. + mock_commit.objects.filter.return_value.count.return_value = 5 + mock_issue.objects.filter.return_value.count.return_value = 3 + mock_pr.objects.filter.return_value.count.return_value = 2 + mock_release.objects.filter.return_value.count.return_value = 1 + + with mock.patch.object( + command, + "aggregate_contributions", + return_value={"2024-11-16": 5}, + ): + command.handle(entity_type="chapter", days=365, offset=0) + + assert mock_chapter.contribution_data == {"2024-11-16": 5} + assert mock_chapter_model.bulk_save.called + + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Project") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + def test_handle_projects_only( + self, + mock_release, + mock_pr, + mock_issue, + mock_commit, + mock_project_model, + command, + mock_project, + ): + """Test command execution for projects only.""" + mock_project_model.objects.filter.return_value = MockQuerySet([mock_project]) + mock_project_model.bulk_save = mock.Mock() + + # Mock ORM queries to return counts. + mock_commit.objects.filter.return_value.count.return_value = 8 + mock_issue.objects.filter.return_value.count.return_value = 4 + mock_pr.objects.filter.return_value.count.return_value = 3 + mock_release.objects.filter.return_value.count.return_value = 2 + + with mock.patch.object( + command, + "aggregate_contributions", + return_value={"2024-11-16": 10}, + ): + command.handle(entity_type="project", days=365, offset=0) + + assert mock_project.contribution_data == {"2024-11-16": 10} + assert mock_project.contribution_stats is not None + assert "commits" in mock_project.contribution_stats + assert mock_project_model.bulk_save.called + + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Project") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + def test_handle_both_entities( + self, + mock_release, + mock_pr, + mock_issue, + mock_commit, + mock_project_model, + mock_chapter_model, + command, + mock_chapter, + mock_project, + ): + """Test command execution for both chapters and projects (run separately).""" + mock_chapter_model.objects.filter.return_value = MockQuerySet([mock_chapter]) + mock_project_model.objects.filter.return_value = MockQuerySet([mock_project]) + mock_chapter_model.bulk_save = mock.Mock() + mock_project_model.bulk_save = mock.Mock() + + # Mock ORM queries to return counts. + mock_commit.objects.filter.return_value.count.return_value = 5 + mock_issue.objects.filter.return_value.count.return_value = 3 + mock_pr.objects.filter.return_value.count.return_value = 2 + mock_release.objects.filter.return_value.count.return_value = 1 + + with mock.patch.object( + command, + "aggregate_contributions", + return_value={"2024-11-16": 5}, + ): + command.handle(entity_type="chapter", days=365, offset=0) + command.handle(entity_type="project", days=365, offset=0) + + assert mock_chapter_model.bulk_save.called + assert mock_project_model.bulk_save.called + + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + def test_handle_with_specific_key( + self, + mock_release, + mock_pr, + mock_issue, + mock_commit, + mock_chapter_model, + command, + mock_chapter, + ): + """Test command execution with a specific entity key.""" + mock_chapter_model.objects.filter.return_value = MockQuerySet([mock_chapter]) + mock_chapter_model.bulk_save = mock.Mock() + + # Mock ORM queries to return counts. + mock_commit.objects.filter.return_value.count.return_value = 3 + mock_issue.objects.filter.return_value.count.return_value = 2 + mock_pr.objects.filter.return_value.count.return_value = 1 + mock_release.objects.filter.return_value.count.return_value = 1 + + with mock.patch.object( + command, + "aggregate_contributions", + return_value={"2024-11-16": 3}, + ): + command.handle(entity_type="chapter", key="www-chapter-test", days=365, offset=0) + + # Verify filter was called with the specific key. + mock_chapter_model.objects.filter.assert_called() + + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + def test_handle_with_offset( + self, + mock_release, + mock_pr, + mock_issue, + mock_commit, + mock_chapter_model, + command, + mock_chapter, + ): + """Test command execution with offset parameter.""" + chapters = [mock_chapter, mock_chapter, mock_chapter] + mock_chapter_model.objects.filter.return_value = MockQuerySet(chapters) + mock_chapter_model.bulk_save = mock.Mock() + + # Mock ORM queries to return counts. + mock_commit.objects.filter.return_value.count.return_value = 1 + mock_issue.objects.filter.return_value.count.return_value = 1 + mock_pr.objects.filter.return_value.count.return_value = 1 + mock_release.objects.filter.return_value.count.return_value = 0 + + with mock.patch.object( + command, + "aggregate_contributions", + return_value={"2024-11-16": 1}, + ) as mock_aggregate: + command.handle(entity_type="chapter", offset=2, days=365) + + # Verify that offset was applied correctly. + assert mock_aggregate.call_count == 1, ( + "Expected aggregate to be called once for 1 remaining chapter after offset" + ) + mock_aggregate.assert_called_once() + mock_chapter_model.bulk_save.assert_called_once() + + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + def test_handle_custom_days( + self, + mock_release, + mock_pr, + mock_issue, + mock_commit, + mock_chapter_model, + command, + mock_chapter, + ): + """Test command execution with custom days parameter.""" + mock_chapter_model.objects.filter.return_value = MockQuerySet([mock_chapter]) + mock_chapter_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="chapter", days=90, offset=0) + + # Verify aggregate was called with correct start_date. + assert mock_aggregate.called + call_args = mock_aggregate.call_args[0] + start_date = call_args[1] + expected_start = datetime.now(tz=UTC) - timedelta(days=90) + + # Allow 1 second tolerance for test execution time. + assert abs((expected_start - start_date).total_seconds()) < 1 diff --git a/backend/tests/apps/slack/common/handlers/users_test.py b/backend/tests/apps/slack/common/handlers/users_test.py index dd7c72088b..3e9a3494fd 100644 --- a/backend/tests/apps/slack/common/handlers/users_test.py +++ b/backend/tests/apps/slack/common/handlers/users_test.py @@ -37,8 +37,9 @@ def test_get_blocks_with_results(self, mocker, mock_users_data): """Tests the happy path, ensuring user data is formatted correctly into blocks.""" mocker.patch("apps.github.index.search.user.get_users", return_value=mock_users_data) blocks = get_blocks(search_query="john") - assert len(blocks) > 1 - user_block_text = blocks[0]["text"]["text"] + user_block_text = blocks[1]["text"]["text"] + + assert "OWASP users that I found" in blocks[0]["text"]["text"] assert "1. " in user_block_text assert "Company: OWASP" in user_block_text assert "Location: San Francisco" in user_block_text diff --git a/cspell/Dockerfile b/cspell/Dockerfile index 777edac23b..1e0069ca51 100644 --- a/cspell/Dockerfile +++ b/cspell/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-alpine +FROM node:24-alpine WORKDIR /opt/node diff --git a/cspell/cspell.json b/cspell/cspell.json index a85cf271ed..28750082d4 100644 --- a/cspell/cspell.json +++ b/cspell/cspell.json @@ -38,8 +38,9 @@ "win32" ], "enabled": true, - "files": ["**/*"], + "files": ["**/*", ".github/**/*"], "ignorePaths": [ + ".github/workflows/check-pr-issue-skip-usernames.txt", "backend/**/migrations/*.py", "backend/data/project-custom-tags/*.json", "backend/static/**", diff --git a/cspell/custom-dict.txt b/cspell/custom-dict.txt index 1c9a5c8d6f..eeb2f778cd 100644 --- a/cspell/custom-dict.txt +++ b/cspell/custom-dict.txt @@ -43,6 +43,7 @@ algoliasearch ansa apexcharts apk +aquasecurity arithmatex arkid15r askowasp @@ -60,6 +61,7 @@ defectdojo demojize dismissable dkr +dockerhub dsn env facebookexternalhit @@ -67,10 +69,13 @@ gamesec geocoders geoloc geopy +gha graphiql gunicorn +hackathon heroui hsl +ics igoat igw inlinehilite @@ -98,8 +103,10 @@ millify mpim navlink nestbot +ngx noinput nosniff +numfmt openstreetmap owasppcitoolkit owtf @@ -131,8 +138,8 @@ speakerdeck superfences tfbackend tiktok +trivyignores tsc -turbopack unassigning unhover usefixtures @@ -144,5 +151,6 @@ xdg xdist xoxb xsser +zaproxy zsc éàëîôû diff --git a/cspell/package.json b/cspell/package.json index b4e592231b..d31b166e05 100644 --- a/cspell/package.json +++ b/cspell/package.json @@ -1,15 +1,15 @@ { "devDependencies": { - "@cspell/dict-aws": "^4.0.16", - "@cspell/dict-data-science": "^2.0.12", - "@cspell/dict-en_us": "^4.4.24", + "@cspell/dict-aws": "^4.0.17", + "@cspell/dict-data-science": "^2.0.13", + "@cspell/dict-en_us": "^4.4.27", "@cspell/dict-fullstack": "^3.2.7", - "@cspell/dict-golang": "^6.0.24", + "@cspell/dict-golang": "^6.0.26", "@cspell/dict-k8s": "^1.0.12", - "@cspell/dict-people-names": "^1.1.15", + "@cspell/dict-people-names": "^1.1.16", "@cspell/dict-software-terms": "^4.2.5", "@cspell/dict-terraform": "^1.1.3", - "@cspell/dict-win32": "^2.0.9", + "@cspell/dict-win32": "^2.0.10", "cspell": "^8.19.4" } } diff --git a/cspell/pnpm-lock.yaml b/cspell/pnpm-lock.yaml index 72e06002f4..ac4a6294aa 100644 --- a/cspell/pnpm-lock.yaml +++ b/cspell/pnpm-lock.yaml @@ -9,26 +9,26 @@ importers: .: devDependencies: '@cspell/dict-aws': - specifier: ^4.0.16 - version: 4.0.16 + specifier: ^4.0.17 + version: 4.0.17 '@cspell/dict-data-science': - specifier: ^2.0.12 - version: 2.0.12 + specifier: ^2.0.13 + version: 2.0.13 '@cspell/dict-en_us': - specifier: ^4.4.24 - version: 4.4.24 + specifier: ^4.4.27 + version: 4.4.27 '@cspell/dict-fullstack': specifier: ^3.2.7 version: 3.2.7 '@cspell/dict-golang': - specifier: ^6.0.24 - version: 6.0.24 + specifier: ^6.0.26 + version: 6.0.26 '@cspell/dict-k8s': specifier: ^1.0.12 version: 1.0.12 '@cspell/dict-people-names': - specifier: ^1.1.15 - version: 1.1.15 + specifier: ^1.1.16 + version: 1.1.16 '@cspell/dict-software-terms': specifier: ^4.2.5 version: 4.2.5 @@ -36,8 +36,8 @@ importers: specifier: ^1.1.3 version: 1.1.3 '@cspell/dict-win32': - specifier: ^2.0.9 - version: 2.0.9 + specifier: ^2.0.10 + version: 2.0.10 cspell: specifier: ^8.19.4 version: 8.19.4 @@ -74,56 +74,56 @@ packages: '@cspell/dict-al@1.1.1': resolution: {integrity: sha512-sD8GCaZetgQL4+MaJLXqbzWcRjfKVp8x+px3HuCaaiATAAtvjwUQ5/Iubiqwfd1boIh2Y1/3EgM3TLQ7Q8e0wQ==} - '@cspell/dict-aws@4.0.16': - resolution: {integrity: sha512-a681zShZbtTo947NvTYGLer95ZDQw1ROKvIFydak1e0OlfFCsNdtcYTupn0nbbYs53c9AO7G2DU8AcNEAnwXPA==} + '@cspell/dict-aws@4.0.17': + resolution: {integrity: sha512-ORcblTWcdlGjIbWrgKF+8CNEBQiLVKdUOFoTn0KPNkAYnFcdPP0muT4892h7H4Xafh3j72wqB4/loQ6Nti9E/w==} '@cspell/dict-bash@4.2.2': resolution: {integrity: sha512-kyWbwtX3TsCf5l49gGQIZkRLaB/P8g73GDRm41Zu8Mv51kjl2H7Au0TsEvHv7jzcsRLS6aUYaZv6Zsvk1fOz+Q==} - '@cspell/dict-companies@3.2.7': - resolution: {integrity: sha512-fEyr3LmpFKTaD0LcRhB4lfW1AmULYBqzg4gWAV0dQCv06l+TsA+JQ+3pZJbUcoaZirtgsgT3dL3RUjmGPhUH0A==} + '@cspell/dict-companies@3.2.10': + resolution: {integrity: sha512-bJ1qnO1DkTn7JYGXvxp8FRQc4yq6tRXnrII+jbP8hHmq5TX5o1Wu+rdfpoUQaMWTl6balRvcMYiINDesnpR9Bw==} - '@cspell/dict-cpp@6.0.14': - resolution: {integrity: sha512-dkmpSwvVfVdtoZ4mW/CK2Ep1v8mJlp6uiKpMNbSMOdJl4kq28nQS4vKNIX3B2bJa0Ha5iHHu+1mNjiLeO3g7Xg==} + '@cspell/dict-cpp@6.0.15': + resolution: {integrity: sha512-N7MKK3llRNoBncygvrnLaGvmjo4xzVr5FbtAc9+MFGHK6/LeSySBupr1FM72XDaVSIsmBEe7sDYCHHwlI9Jb2w==} '@cspell/dict-cryptocurrencies@5.0.5': resolution: {integrity: sha512-R68hYYF/rtlE6T/dsObStzN5QZw+0aQBinAXuWCVqwdS7YZo0X33vGMfChkHaiCo3Z2+bkegqHlqxZF4TD3rUA==} - '@cspell/dict-csharp@4.0.7': - resolution: {integrity: sha512-H16Hpu8O/1/lgijFt2lOk4/nnldFtQ4t8QHbyqphqZZVE5aS4J/zD/WvduqnLY21aKhZS6jo/xF5PX9jyqPKUA==} + '@cspell/dict-csharp@4.0.8': + resolution: {integrity: sha512-qmk45pKFHSxckl5mSlbHxmDitSsGMlk/XzFgt7emeTJWLNSTUK//MbYAkBNRtfzB4uD7pAFiKgpKgtJrTMRnrQ==} - '@cspell/dict-css@4.0.18': - resolution: {integrity: sha512-EF77RqROHL+4LhMGW5NTeKqfUd/e4OOv6EDFQ/UQQiFyWuqkEKyEz0NDILxOFxWUEVdjT2GQ2cC7t12B6pESwg==} + '@cspell/dict-css@4.0.19': + resolution: {integrity: sha512-VYHtPnZt/Zd/ATbW3rtexWpBnHUohUrQOHff/2JBhsVgxOrksAxJnLAO43Q1ayLJBJUUwNVo+RU0sx0aaysZfg==} - '@cspell/dict-dart@2.3.1': - resolution: {integrity: sha512-xoiGnULEcWdodXI6EwVyqpZmpOoh8RA2Xk9BNdR7DLamV/QMvEYn8KJ7NlRiTSauJKPNkHHQ5EVHRM6sTS7jdg==} + '@cspell/dict-dart@2.3.2': + resolution: {integrity: sha512-sUiLW56t9gfZcu8iR/5EUg+KYyRD83Cjl3yjDEA2ApVuJvK1HhX+vn4e4k4YfjpUQMag8XO2AaRhARE09+/rqw==} - '@cspell/dict-data-science@2.0.12': - resolution: {integrity: sha512-vI/mg6cI28IkFcpeINS7cm5M9HWemmXSTnxJiu3nmc4VAGx35SXIEyuLGBcsVzySvDablFYf4hsEpmg1XpVsUQ==} + '@cspell/dict-data-science@2.0.13': + resolution: {integrity: sha512-l1HMEhBJkPmw4I2YGVu2eBSKM89K9pVF+N6qIr5Uo5H3O979jVodtuwP8I7LyPrJnC6nz28oxeGRCLh9xC5CVA==} - '@cspell/dict-django@4.1.5': - resolution: {integrity: sha512-AvTWu99doU3T8ifoMYOMLW2CXKvyKLukPh1auOPwFGHzueWYvBBN+OxF8wF7XwjTBMMeRleVdLh3aWCDEX/ZWg==} + '@cspell/dict-django@4.1.6': + resolution: {integrity: sha512-SdbSFDGy9ulETqNz15oWv2+kpWLlk8DJYd573xhIkeRdcXOjskRuxjSZPKfW7O3NxN/KEf3gm3IevVOiNuFS+w==} - '@cspell/dict-docker@1.1.16': - resolution: {integrity: sha512-UiVQ5RmCg6j0qGIxrBnai3pIB+aYKL3zaJGvXk1O/ertTKJif9RZikKXCEgqhaCYMweM4fuLqWSVmw3hU164Iw==} + '@cspell/dict-docker@1.1.17': + resolution: {integrity: sha512-OcnVTIpHIYYKhztNTyK8ShAnXTfnqs43hVH6p0py0wlcwRIXe5uj4f12n7zPf2CeBI7JAlPjEsV0Rlf4hbz/xQ==} - '@cspell/dict-dotnet@5.0.10': - resolution: {integrity: sha512-ooar8BP/RBNP1gzYfJPStKEmpWy4uv/7JCq6FOnJLeD1yyfG3d/LFMVMwiJo+XWz025cxtkM3wuaikBWzCqkmg==} + '@cspell/dict-dotnet@5.0.11': + resolution: {integrity: sha512-LSVKhpFf/ASTWJcfYeS0Sykcl1gVMsv2Z5Eo0TnTMSTLV3738HH+66pIsjUTChqU6SF3gKPuCe6EOaRYqb/evA==} '@cspell/dict-elixir@4.0.8': resolution: {integrity: sha512-CyfphrbMyl4Ms55Vzuj+mNmd693HjBFr9hvU+B2YbFEZprE5AG+EXLYTMRWrXbpds4AuZcvN3deM2XVB80BN/Q==} - '@cspell/dict-en-common-misspellings@2.1.8': - resolution: {integrity: sha512-vDsjRFPQGuAADAiitf82z9Mz3DcqKZi6V5hPAEIFkLLKjFVBcjUsSq59SfL59ElIFb76MtBO0BLifdEbBj+DoQ==} + '@cspell/dict-en-common-misspellings@2.1.11': + resolution: {integrity: sha512-2jcY494If1udvzd7MT2z/QH/RACUo/I02vIY4ttNdZhgYvUmRKhg8OBdrbzYo0lJOcc7XUb8rhIFQRHzxOSVeA==} '@cspell/dict-en-gb@1.1.33': resolution: {integrity: sha512-tKSSUf9BJEV+GJQAYGw5e+ouhEe2ZXE620S7BLKe3ZmpnjlNG9JqlnaBhkIMxKnNFkLY2BP/EARzw31AZnOv4g==} - '@cspell/dict-en_us@4.4.24': - resolution: {integrity: sha512-JE+/H2YicHJTneRmgH4GSI21rS+1yGZVl1jfOQgl8iHLC+yTTMtCvueNDMK94CgJACzYAoCsQB70MqiFJJfjLQ==} + '@cspell/dict-en_us@4.4.27': + resolution: {integrity: sha512-0y4vH2i5cFmi8sxkc4OlD2IlnqDznOtKczm4h6jA288g5VVrm3bhkYK6vcB8b0CoRKtYWKet4VEmHBP1yI+Qfw==} - '@cspell/dict-filetypes@3.0.14': - resolution: {integrity: sha512-KSXaSMYYNMLLdHEnju1DyRRH3eQWPRYRnOXpuHUdOh2jC44VgQoxyMU7oB3NAhDhZKBPCihabzECsAGFbdKfEA==} + '@cspell/dict-filetypes@3.0.15': + resolution: {integrity: sha512-uDMeqYlLlK476w/muEFQGBy9BdQWS0mQ7BJiy/iQv5XUWZxE2O54ZQd9nW8GyQMzAgoyg5SG4hf9l039Qt66oA==} '@cspell/dict-flutter@1.1.1': resolution: {integrity: sha512-UlOzRcH2tNbFhZmHJN48Za/2/MEdRHl2BMkCWZBYs+30b91mWvBfzaN4IJQU7dUZtowKayVIF9FzvLZtZokc5A==} @@ -143,8 +143,8 @@ packages: '@cspell/dict-git@3.0.7': resolution: {integrity: sha512-odOwVKgfxCQfiSb+nblQZc4ErXmnWEnv8XwkaI4sNJ7cNmojnvogYVeMqkXPjvfrgEcizEEA4URRD2Ms5PDk1w==} - '@cspell/dict-golang@6.0.24': - resolution: {integrity: sha512-rY7PlC3MsHozmjrZWi0HQPUl0BVCV0+mwK0rnMT7pOIXqOe4tWCYMULDIsEk4F0gbIxb5badd2dkCPDYjLnDgA==} + '@cspell/dict-golang@6.0.26': + resolution: {integrity: sha512-YKA7Xm5KeOd14v5SQ4ll6afe9VSy3a2DWM7L9uBq4u3lXToRBQ1W5PRa+/Q9udd+DTURyVVnQ+7b9cnOlNxaRg==} '@cspell/dict-google@1.0.9': resolution: {integrity: sha512-biL65POqialY0i4g6crj7pR6JnBkbsPovB2WDYkj3H4TuC/QXv7Pu5pdPxeUJA6TSCHI7T5twsO4VSVyRxD9CA==} @@ -152,11 +152,11 @@ packages: '@cspell/dict-haskell@4.0.6': resolution: {integrity: sha512-ib8SA5qgftExpYNjWhpYIgvDsZ/0wvKKxSP+kuSkkak520iPvTJumEpIE+qPcmJQo4NzdKMN8nEfaeci4OcFAQ==} - '@cspell/dict-html-symbol-entities@4.0.4': - resolution: {integrity: sha512-afea+0rGPDeOV9gdO06UW183Qg6wRhWVkgCFwiO3bDupAoyXRuvupbb5nUyqSTsLXIKL8u8uXQlJ9pkz07oVXw==} + '@cspell/dict-html-symbol-entities@4.0.5': + resolution: {integrity: sha512-429alTD4cE0FIwpMucvSN35Ld87HCyuM8mF731KU5Rm4Je2SG6hmVx7nkBsLyrmH3sQukTcr1GaiZsiEg8svPA==} - '@cspell/dict-html@4.0.12': - resolution: {integrity: sha512-JFffQ1dDVEyJq6tCDWv0r/RqkdSnV43P2F/3jJ9rwLgdsOIXwQbXrz6QDlvQLVvNSnORH9KjDtenFTGDyzfCaA==} + '@cspell/dict-html@4.0.14': + resolution: {integrity: sha512-2bf7n+kS92g+cMKV0wr9o/Oq9n8JzU7CcrB96gIh2GHgnF+0xDOqO2W/1KeFAqOfqosoOVE48t+4dnEMkkoJ2Q==} '@cspell/dict-java@5.0.12': resolution: {integrity: sha512-qPSNhTcl7LGJ5Qp6VN71H8zqvRQK04S08T67knMq9hTA8U7G1sTKzLmBaDOFhq17vNX/+rT+rbRYp+B5Nwza1A==} @@ -182,28 +182,28 @@ packages: '@cspell/dict-makefile@1.0.5': resolution: {integrity: sha512-4vrVt7bGiK8Rx98tfRbYo42Xo2IstJkAF4tLLDMNQLkQ86msDlYSKG1ZCk8Abg+EdNcFAjNhXIiNO+w4KflGAQ==} - '@cspell/dict-markdown@2.0.12': - resolution: {integrity: sha512-ufwoliPijAgWkD/ivAMC+A9QD895xKiJRF/fwwknQb7kt7NozTLKFAOBtXGPJAB4UjhGBpYEJVo2elQ0FCAH9A==} + '@cspell/dict-markdown@2.0.14': + resolution: {integrity: sha512-uLKPNJsUcumMQTsZZgAK9RgDLyQhUz/uvbQTEkvF/Q4XfC1i/BnA8XrOrd0+Vp6+tPOKyA+omI5LRWfMu5K/Lw==} peerDependencies: - '@cspell/dict-css': ^4.0.18 - '@cspell/dict-html': ^4.0.12 - '@cspell/dict-html-symbol-entities': ^4.0.4 + '@cspell/dict-css': ^4.0.19 + '@cspell/dict-html': ^4.0.14 + '@cspell/dict-html-symbol-entities': ^4.0.5 '@cspell/dict-typescript': ^3.2.3 - '@cspell/dict-monkeyc@1.0.11': - resolution: {integrity: sha512-7Q1Ncu0urALI6dPTrEbSTd//UK0qjRBeaxhnm8uY5fgYNFYAG+u4gtnTIo59S6Bw5P++4H3DiIDYoQdY/lha8w==} + '@cspell/dict-monkeyc@1.0.12': + resolution: {integrity: sha512-MN7Vs11TdP5mbdNFQP5x2Ac8zOBm97ARg6zM5Sb53YQt/eMvXOMvrep7+/+8NJXs0jkp70bBzjqU4APcqBFNAw==} '@cspell/dict-node@5.0.8': resolution: {integrity: sha512-AirZcN2i84ynev3p2/1NCPEhnNsHKMz9zciTngGoqpdItUb2bDt1nJBjwlsrFI78GZRph/VaqTVFwYikmncpXg==} - '@cspell/dict-npm@5.2.24': - resolution: {integrity: sha512-R+s2SC7tQCxmV6nZxkmwdR2ruSDerAkbBjIAuAcH9ZXdpacTghSDcHJtSyBYnbcLFq8Bdp3cGB+J6vCRbw3pyw==} + '@cspell/dict-npm@5.2.28': + resolution: {integrity: sha512-tjnBjpIJsgYMTqNSrL5YlvFcXdtc7gkrL1ZI+MPSJSYOoJ78yeegS5UrIIbH3VrQtbNYSS8YhlEVF+xN0G4E8Q==} - '@cspell/dict-people-names@1.1.15': - resolution: {integrity: sha512-czH7kLsWL2E20bJjMCRPMU9ualOYbSmb3CZuP4GqxjjBWBw3WzEykxhx0Mrl3L7zNywrRQsZ9tv5ErlRCg4Spg==} + '@cspell/dict-people-names@1.1.16': + resolution: {integrity: sha512-jiV+V32DVdaMqpznnqqNNMNaKFtyaHnZvak7HrVLWulGgobilQk+8NzFO9mtkyDs7Pde7CEGSExBAvc+xZxgeA==} - '@cspell/dict-php@4.1.0': - resolution: {integrity: sha512-dTDeabyOj7eFvn2Q4Za3uVXM2+SzeFMqX8ly2P0XTo4AzbCmI2hulFD/QIADwWmwiRrInbbf8cxwFHNIYrXl4w==} + '@cspell/dict-php@4.1.1': + resolution: {integrity: sha512-EXelI+4AftmdIGtA8HL8kr4WlUE11OqCSVlnIgZekmTkEGSZdYnkFdiJ5IANSALtlQ1mghKjz+OFqVs6yowgWA==} '@cspell/dict-powershell@5.0.15': resolution: {integrity: sha512-l4S5PAcvCFcVDMJShrYD0X6Huv9dcsQPlsVsBGbH38wvuN7gS7+GxZFAjTNxDmTY1wrNi1cCatSg6Pu2BW4rgg==} @@ -211,20 +211,20 @@ packages: '@cspell/dict-public-licenses@2.0.15': resolution: {integrity: sha512-cJEOs901H13Pfy0fl4dCD1U+xpWIMaEPq8MeYU83FfDZvellAuSo4GqWCripfIqlhns/L6+UZEIJSOZnjgy7Wg==} - '@cspell/dict-python@4.2.22': - resolution: {integrity: sha512-rgF7DuleVK2lkzlw33jjEfxS2a0CU5kwAhOqf5B6XkuaPbqZ/0g0LBCdwglAGccYu7sBuvxRS8Yubk+ytSAFTg==} + '@cspell/dict-python@4.2.25': + resolution: {integrity: sha512-hDdN0YhKgpbtZVRjQ2c8jk+n0wQdidAKj1Fk8w7KEHb3YlY5uPJ0mAKJk7AJKPNLOlILoUmN+HAVJz+cfSbWYg==} '@cspell/dict-r@2.1.1': resolution: {integrity: sha512-71Ka+yKfG4ZHEMEmDxc6+blFkeTTvgKbKAbwiwQAuKl3zpqs1Y0vUtwW2N4b3LgmSPhV3ODVY0y4m5ofqDuKMw==} - '@cspell/dict-ruby@5.0.9': - resolution: {integrity: sha512-H2vMcERMcANvQshAdrVx0XoWaNX8zmmiQN11dZZTQAZaNJ0xatdJoSqY8C8uhEMW89bfgpN+NQgGuDXW2vmXEw==} + '@cspell/dict-ruby@5.1.0': + resolution: {integrity: sha512-9PJQB3cfkBULrMLp5kSAcFPpzf8oz9vFN+QYZABhQwWkGbuzCIXSorHrmWSASlx4yejt3brjaWS57zZ/YL5ZQQ==} - '@cspell/dict-rust@4.0.12': - resolution: {integrity: sha512-z2QiH+q9UlNhobBJArvILRxV8Jz0pKIK7gqu4TgmEYyjiu1TvnGZ1tbYHeu9w3I/wOP6UMDoCBTty5AlYfW0mw==} + '@cspell/dict-rust@4.1.0': + resolution: {integrity: sha512-ysFxxKc3QjPWtPacbwxzz8sDOACHNShlhQpnBsDXAHN3LogmuBsQtfyuU30APqFjCOg9KwGciKYC/hcGxJCbiA==} - '@cspell/dict-scala@5.0.8': - resolution: {integrity: sha512-YdftVmumv8IZq9zu1gn2U7A4bfM2yj9Vaupydotyjuc+EEZZSqAafTpvW/jKLWji2TgybM1L2IhmV0s/Iv9BTw==} + '@cspell/dict-scala@5.0.9': + resolution: {integrity: sha512-AjVcVAELgllybr1zk93CJ5wSUNu/Zb5kIubymR/GAYkMyBdYFCZ3Zbwn4Zz8GJlFFAbazABGOu0JPVbeY59vGg==} '@cspell/dict-shell@1.1.2': resolution: {integrity: sha512-WqOUvnwcHK1X61wAfwyXq04cn7KYyskg90j4lLg3sGGKMW9Sq13hs91pqrjC44Q+lQLgCobrTkMDw9Wyl9nRFA==} @@ -232,8 +232,8 @@ packages: '@cspell/dict-software-terms@4.2.5': resolution: {integrity: sha512-CaRzkWti3AgcXoxuRcMijaNG7YUk/MH1rHjB8VX34v3UdCxXXeqvRyElRKnxhFeVLB/robb2UdShqh/CpskxRg==} - '@cspell/dict-software-terms@5.1.14': - resolution: {integrity: sha512-Eu9h090hxHJiqzVFS0WxOZbYXnmb7F1RFIUEg4Nru+D/78bXVDH4b8BiKGVFNRljaieNQRAHaryzdaKJRCH6ZA==} + '@cspell/dict-software-terms@5.1.19': + resolution: {integrity: sha512-3leJLYvibbOnPsIUV/60WcSPxzRmgrx6/0QkqRi8cSsEuRY5/cbUU8Jc0/hKYCIhWJlnIWh5yx34Ep2s8QSIBw==} '@cspell/dict-sql@2.2.1': resolution: {integrity: sha512-qDHF8MpAYCf4pWU8NKbnVGzkoxMNrFqBHyG/dgrlic5EQiKANCLELYtGlX5auIMDLmTf1inA0eNtv74tyRJ/vg==} @@ -253,8 +253,8 @@ packages: '@cspell/dict-vue@3.0.5': resolution: {integrity: sha512-Mqutb8jbM+kIcywuPQCCaK5qQHTdaByoEO2J9LKFy3sqAdiBogNkrplqUK0HyyRFgCfbJUgjz3N85iCMcWH0JA==} - '@cspell/dict-win32@2.0.9': - resolution: {integrity: sha512-Jr7CR+6VghZ3PAz4zMM65wmAKZ0HpTaWk9r5VPv7Oo9AfLU1n0aZaK3LNSFudn4FZ9CGG+/5V3gAbMxlJy7r/Q==} + '@cspell/dict-win32@2.0.10': + resolution: {integrity: sha512-t81sV6cOqD7xN/u8MOTkoolxvSMvvrc9t/rNGpNtFAXyh24yeulC+5rz1zEfmE6sGFppsqQNxQ8mVOMhw1iZ/w==} '@cspell/dynamic-import@8.19.4': resolution: {integrity: sha512-0LLghC64+SiwQS20Sa0VfFUBPVia1rNyo0bYeIDoB34AA3qwguDBVJJkthkpmaP1R2JeR/VmxmJowuARc4ZUxA==} @@ -295,8 +295,8 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} - comment-json@4.4.1: - resolution: {integrity: sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==} + comment-json@4.5.1: + resolution: {integrity: sha512-taEtr3ozUmOB7it68Jll7s0Pwm+aoiHyXKrEC8SEodL4rNpdfDLqa7PfBlrgFoCNNdR8ImL+muti5IGvktJAAg==} engines: {node: '>= 6'} core-util-is@1.0.3: @@ -350,8 +350,8 @@ packages: engines: {node: '>=4'} hasBin: true - fast-equals@5.3.3: - resolution: {integrity: sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw==} + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} engines: {node: '>=6.0.0'} fast-json-stable-stringify@2.1.0: @@ -441,8 +441,8 @@ packages: resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} engines: {node: '>=12'} - yaml@2.8.1: - resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} hasBin: true @@ -452,34 +452,34 @@ snapshots: dependencies: '@cspell/dict-ada': 4.1.1 '@cspell/dict-al': 1.1.1 - '@cspell/dict-aws': 4.0.16 + '@cspell/dict-aws': 4.0.17 '@cspell/dict-bash': 4.2.2 - '@cspell/dict-companies': 3.2.7 - '@cspell/dict-cpp': 6.0.14 + '@cspell/dict-companies': 3.2.10 + '@cspell/dict-cpp': 6.0.15 '@cspell/dict-cryptocurrencies': 5.0.5 - '@cspell/dict-csharp': 4.0.7 - '@cspell/dict-css': 4.0.18 - '@cspell/dict-dart': 2.3.1 - '@cspell/dict-data-science': 2.0.12 - '@cspell/dict-django': 4.1.5 - '@cspell/dict-docker': 1.1.16 - '@cspell/dict-dotnet': 5.0.10 + '@cspell/dict-csharp': 4.0.8 + '@cspell/dict-css': 4.0.19 + '@cspell/dict-dart': 2.3.2 + '@cspell/dict-data-science': 2.0.13 + '@cspell/dict-django': 4.1.6 + '@cspell/dict-docker': 1.1.17 + '@cspell/dict-dotnet': 5.0.11 '@cspell/dict-elixir': 4.0.8 - '@cspell/dict-en-common-misspellings': 2.1.8 + '@cspell/dict-en-common-misspellings': 2.1.11 '@cspell/dict-en-gb': 1.1.33 - '@cspell/dict-en_us': 4.4.24 - '@cspell/dict-filetypes': 3.0.14 + '@cspell/dict-en_us': 4.4.27 + '@cspell/dict-filetypes': 3.0.15 '@cspell/dict-flutter': 1.1.1 '@cspell/dict-fonts': 4.0.5 '@cspell/dict-fsharp': 1.1.1 '@cspell/dict-fullstack': 3.2.7 '@cspell/dict-gaming-terms': 1.1.2 '@cspell/dict-git': 3.0.7 - '@cspell/dict-golang': 6.0.24 + '@cspell/dict-golang': 6.0.26 '@cspell/dict-google': 1.0.9 '@cspell/dict-haskell': 4.0.6 - '@cspell/dict-html': 4.0.12 - '@cspell/dict-html-symbol-entities': 4.0.4 + '@cspell/dict-html': 4.0.14 + '@cspell/dict-html-symbol-entities': 4.0.5 '@cspell/dict-java': 5.0.12 '@cspell/dict-julia': 1.1.1 '@cspell/dict-k8s': 1.0.12 @@ -488,20 +488,20 @@ snapshots: '@cspell/dict-lorem-ipsum': 4.0.5 '@cspell/dict-lua': 4.0.8 '@cspell/dict-makefile': 1.0.5 - '@cspell/dict-markdown': 2.0.12(@cspell/dict-css@4.0.18)(@cspell/dict-html-symbol-entities@4.0.4)(@cspell/dict-html@4.0.12)(@cspell/dict-typescript@3.2.3) - '@cspell/dict-monkeyc': 1.0.11 + '@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.8 - '@cspell/dict-npm': 5.2.24 - '@cspell/dict-php': 4.1.0 + '@cspell/dict-npm': 5.2.28 + '@cspell/dict-php': 4.1.1 '@cspell/dict-powershell': 5.0.15 '@cspell/dict-public-licenses': 2.0.15 - '@cspell/dict-python': 4.2.22 + '@cspell/dict-python': 4.2.25 '@cspell/dict-r': 2.1.1 - '@cspell/dict-ruby': 5.0.9 - '@cspell/dict-rust': 4.0.12 - '@cspell/dict-scala': 5.0.8 + '@cspell/dict-ruby': 5.1.0 + '@cspell/dict-rust': 4.1.0 + '@cspell/dict-scala': 5.0.9 '@cspell/dict-shell': 1.1.2 - '@cspell/dict-software-terms': 5.1.14 + '@cspell/dict-software-terms': 5.1.19 '@cspell/dict-sql': 2.2.1 '@cspell/dict-svelte': 1.0.7 '@cspell/dict-swift': 2.0.6 @@ -527,41 +527,41 @@ snapshots: '@cspell/dict-al@1.1.1': {} - '@cspell/dict-aws@4.0.16': {} + '@cspell/dict-aws@4.0.17': {} '@cspell/dict-bash@4.2.2': dependencies: '@cspell/dict-shell': 1.1.2 - '@cspell/dict-companies@3.2.7': {} + '@cspell/dict-companies@3.2.10': {} - '@cspell/dict-cpp@6.0.14': {} + '@cspell/dict-cpp@6.0.15': {} '@cspell/dict-cryptocurrencies@5.0.5': {} - '@cspell/dict-csharp@4.0.7': {} + '@cspell/dict-csharp@4.0.8': {} - '@cspell/dict-css@4.0.18': {} + '@cspell/dict-css@4.0.19': {} - '@cspell/dict-dart@2.3.1': {} + '@cspell/dict-dart@2.3.2': {} - '@cspell/dict-data-science@2.0.12': {} + '@cspell/dict-data-science@2.0.13': {} - '@cspell/dict-django@4.1.5': {} + '@cspell/dict-django@4.1.6': {} - '@cspell/dict-docker@1.1.16': {} + '@cspell/dict-docker@1.1.17': {} - '@cspell/dict-dotnet@5.0.10': {} + '@cspell/dict-dotnet@5.0.11': {} '@cspell/dict-elixir@4.0.8': {} - '@cspell/dict-en-common-misspellings@2.1.8': {} + '@cspell/dict-en-common-misspellings@2.1.11': {} '@cspell/dict-en-gb@1.1.33': {} - '@cspell/dict-en_us@4.4.24': {} + '@cspell/dict-en_us@4.4.27': {} - '@cspell/dict-filetypes@3.0.14': {} + '@cspell/dict-filetypes@3.0.15': {} '@cspell/dict-flutter@1.1.1': {} @@ -575,15 +575,15 @@ snapshots: '@cspell/dict-git@3.0.7': {} - '@cspell/dict-golang@6.0.24': {} + '@cspell/dict-golang@6.0.26': {} '@cspell/dict-google@1.0.9': {} '@cspell/dict-haskell@4.0.6': {} - '@cspell/dict-html-symbol-entities@4.0.4': {} + '@cspell/dict-html-symbol-entities@4.0.5': {} - '@cspell/dict-html@4.0.12': {} + '@cspell/dict-html@4.0.14': {} '@cspell/dict-java@5.0.12': {} @@ -601,44 +601,44 @@ snapshots: '@cspell/dict-makefile@1.0.5': {} - '@cspell/dict-markdown@2.0.12(@cspell/dict-css@4.0.18)(@cspell/dict-html-symbol-entities@4.0.4)(@cspell/dict-html@4.0.12)(@cspell/dict-typescript@3.2.3)': + '@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)': dependencies: - '@cspell/dict-css': 4.0.18 - '@cspell/dict-html': 4.0.12 - '@cspell/dict-html-symbol-entities': 4.0.4 + '@cspell/dict-css': 4.0.19 + '@cspell/dict-html': 4.0.14 + '@cspell/dict-html-symbol-entities': 4.0.5 '@cspell/dict-typescript': 3.2.3 - '@cspell/dict-monkeyc@1.0.11': {} + '@cspell/dict-monkeyc@1.0.12': {} '@cspell/dict-node@5.0.8': {} - '@cspell/dict-npm@5.2.24': {} + '@cspell/dict-npm@5.2.28': {} - '@cspell/dict-people-names@1.1.15': {} + '@cspell/dict-people-names@1.1.16': {} - '@cspell/dict-php@4.1.0': {} + '@cspell/dict-php@4.1.1': {} '@cspell/dict-powershell@5.0.15': {} '@cspell/dict-public-licenses@2.0.15': {} - '@cspell/dict-python@4.2.22': + '@cspell/dict-python@4.2.25': dependencies: - '@cspell/dict-data-science': 2.0.12 + '@cspell/dict-data-science': 2.0.13 '@cspell/dict-r@2.1.1': {} - '@cspell/dict-ruby@5.0.9': {} + '@cspell/dict-ruby@5.1.0': {} - '@cspell/dict-rust@4.0.12': {} + '@cspell/dict-rust@4.1.0': {} - '@cspell/dict-scala@5.0.8': {} + '@cspell/dict-scala@5.0.9': {} '@cspell/dict-shell@1.1.2': {} '@cspell/dict-software-terms@4.2.5': {} - '@cspell/dict-software-terms@5.1.14': {} + '@cspell/dict-software-terms@5.1.19': {} '@cspell/dict-sql@2.2.1': {} @@ -652,7 +652,7 @@ snapshots: '@cspell/dict-vue@3.0.5': {} - '@cspell/dict-win32@2.0.9': {} + '@cspell/dict-win32@2.0.10': {} '@cspell/dynamic-import@8.19.4': dependencies: @@ -682,7 +682,7 @@ snapshots: commander@13.1.0: {} - comment-json@4.4.1: + comment-json@4.5.1: dependencies: array-timsort: 1.0.3 core-util-is: 1.0.3 @@ -693,15 +693,15 @@ snapshots: cspell-config-lib@8.19.4: dependencies: '@cspell/cspell-types': 8.19.4 - comment-json: 4.4.1 - yaml: 2.8.1 + comment-json: 4.5.1 + yaml: 2.8.2 cspell-dictionary@8.19.4: dependencies: '@cspell/cspell-pipe': 8.19.4 '@cspell/cspell-types': 8.19.4 cspell-trie-lib: 8.19.4 - fast-equals: 5.3.3 + fast-equals: 5.4.0 cspell-gitignore@8.19.4: dependencies: @@ -735,7 +735,7 @@ snapshots: '@cspell/strong-weak-map': 8.19.4 '@cspell/url': 8.19.4 clear-module: 4.1.2 - comment-json: 4.4.1 + comment-json: 4.5.1 cspell-config-lib: 8.19.4 cspell-dictionary: 8.19.4 cspell-glob: 8.19.4 @@ -743,7 +743,7 @@ snapshots: cspell-io: 8.19.4 cspell-trie-lib: 8.19.4 env-paths: 3.0.0 - fast-equals: 5.3.3 + fast-equals: 5.4.0 gensequence: 7.0.0 import-fresh: 3.3.1 resolve-from: 5.0.0 @@ -781,7 +781,7 @@ snapshots: esprima@4.0.1: {} - fast-equals@5.3.3: {} + fast-equals@5.4.0: {} fast-json-stable-stringify@2.1.0: {} @@ -848,4 +848,4 @@ snapshots: xdg-basedir@5.1.0: {} - yaml@2.8.1: {} + yaml@2.8.2: {} diff --git a/docker-compose/local.yaml b/docker-compose/local/compose.yaml similarity index 85% rename from docker-compose/local.yaml rename to docker-compose/local/compose.yaml index 0e8eda2004..f87130e29e 100644 --- a/docker-compose/local.yaml +++ b/docker-compose/local/compose.yaml @@ -6,15 +6,16 @@ services: python manage.py migrate && python manage.py runserver 0.0.0.0:8000 ' + image: nest-local-backend build: - context: ../backend - dockerfile: docker/Dockerfile.local + context: ../../backend + dockerfile: ../docker/backend/Dockerfile.local depends_on: cache: condition: service_healthy db: condition: service_healthy - env_file: ../backend/.env + env_file: ../../backend/.env environment: DJANGO_DB_HOST: ${DJANGO_DB_HOST:-db} DJANGO_DB_NAME: ${DJANGO_DB_NAME:-nest_db_dev} @@ -28,7 +29,7 @@ services: ports: - 8000:8000 volumes: - - ../backend:/home/owasp + - ../../backend:/home/owasp - backend-venv:/home/owasp/.venv cache: @@ -37,7 +38,7 @@ services: redis-server --requirepass $$REDIS_PASSWORD --maxmemory 25mb --maxmemory-policy allkeys-lru ' container_name: nest-cache - image: redis:7.2.7-alpine3.21 + image: redis:8.0.5-alpine3.21 environment: REDIS_PASSWORD: ${DJANGO_REDIS_PASSWORD:-nest-cache-password} healthcheck: @@ -74,14 +75,14 @@ services: mkdocs serve -a 0.0.0.0:8001 ' build: - context: .. - dockerfile: docs/docker/Dockerfile.local + context: ../../ + dockerfile: docker/docs/Dockerfile.local networks: - nest-network ports: - 8001:8001 volumes: - - ../docs:/home/owasp/docs + - ../../docs:/home/owasp/docs - docs-venv:/home/owasp/.venv frontend: @@ -91,8 +92,8 @@ services: pnpm run dev ' build: - context: ../frontend - dockerfile: docker/Dockerfile.local + context: ../../frontend + dockerfile: ../docker/frontend/Dockerfile.local depends_on: - backend environment: @@ -103,7 +104,7 @@ services: ports: - 3000:3000 volumes: - - ../frontend:/home/owasp + - ../../frontend:/home/owasp - frontend-next:/home/owasp/.next - frontend-node-modules:/home/owasp/node_modules @@ -113,15 +114,15 @@ services: sh -c ' python manage.py rqworker ai --with-scheduler ' - build: - context: ../backend - dockerfile: docker/Dockerfile.local + image: nest-local-backend depends_on: + backend: + condition: service_started cache: condition: service_healthy db: condition: service_healthy - env_file: ../backend/.env + env_file: ../../backend/.env environment: DJANGO_DB_HOST: ${DJANGO_DB_HOST:-db} DJANGO_DB_NAME: ${DJANGO_DB_NAME:-nest_db_dev} @@ -133,7 +134,7 @@ services: networks: - nest-network volumes: - - ../backend:/home/owasp + - ../../backend:/home/owasp - backend-venv:/home/owasp/.venv networks: diff --git a/docker-compose/production.yaml b/docker-compose/production/compose.yaml similarity index 93% rename from docker-compose/production.yaml rename to docker-compose/production/compose.yaml index c8402bea9a..ed79a179e8 100644 --- a/docker-compose/production.yaml +++ b/docker-compose/production/compose.yaml @@ -14,12 +14,12 @@ services: - nest-cache-network - nest-db-network volumes: + - ./.github.pem:/home/owasp/.github.pem:ro - ./backend/data:/home/owasp/data - - ~/.github.pem:/home/owasp/.github.pem:ro production-nest-cache: container_name: production-nest-cache - image: redis:7.2.7-alpine3.21 + image: redis:8.0.5-alpine3.21 command: > sh -c ' redis-server --requirepass $$REDIS_PASSWORD --maxmemory 100mb --maxmemory-policy allkeys-lru @@ -68,6 +68,8 @@ services: python manage.py rqworker ai --with-scheduler ' depends_on: + production-nest-backend: + condition: service_started production-nest-cache: condition: service_healthy production-nest-db: diff --git a/proxy/docker-compose.yaml b/docker-compose/proxy/compose.yaml similarity index 77% rename from proxy/docker-compose.yaml rename to docker-compose/proxy/compose.yaml index 55948c1b9d..1d3cf22305 100644 --- a/proxy/docker-compose.yaml +++ b/docker-compose/proxy/compose.yaml @@ -1,34 +1,41 @@ 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 + restart: unless-stopped volumes: - ./blocked_ips.conf:/etc/nginx/blocked_ips.conf + - ./cloudflare_realip.conf:/etc/nginx/cloudflare_realip.conf - ./headers.conf:/etc/nginx/headers.conf - ./letsencrypt:/etc/letsencrypt - ./production.conf:/etc/nginx/conf.d/production.conf + - ./proxy_cache.conf:/etc/nginx/conf.d/proxy_cache.conf - ./redirects.conf:/etc/nginx/redirects.conf - ./staging.conf:/etc/nginx/conf.d/staging.conf - nest-certbot-webroot:/var/www/certbot - entrypoint: /bin/sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot --quiet; sleep 12h & wait $${!}; done;' nest-nginx: container_name: nest-nginx image: nginx:latest + networks: + - production_nest-app-network + - staging_nest-app-network ports: - 80:80 - 443:443 + restart: unless-stopped volumes: - ./blocked_ips.conf:/etc/nginx/blocked_ips.conf + - ./cloudflare_realip.conf:/etc/nginx/cloudflare_realip.conf - ./headers.conf:/etc/nginx/headers.conf - ./letsencrypt:/etc/letsencrypt - ./production.conf:/etc/nginx/conf.d/production.conf + - ./proxy_cache.conf:/etc/nginx/conf.d/proxy_cache.conf - ./redirects.conf:/etc/nginx/redirects.conf - ./staging.conf:/etc/nginx/conf.d/staging.conf - nest-certbot-webroot:/var/www/certbot - networks: - - production_nest-app-network - - staging_nest-app-network + - nest-nginx-cache:/var/cache/nginx networks: production_nest-app-network: @@ -38,3 +45,4 @@ networks: volumes: nest-certbot-webroot: + nest-nginx-cache: diff --git a/docker-compose/staging.yaml b/docker-compose/staging/compose.yaml similarity index 95% rename from docker-compose/staging.yaml rename to docker-compose/staging/compose.yaml index 17542305ec..b2a8161a85 100644 --- a/docker-compose/staging.yaml +++ b/docker-compose/staging/compose.yaml @@ -18,7 +18,7 @@ services: staging-nest-cache: container_name: staging-nest-cache - image: redis:7.2.7-alpine3.21 + image: redis:8.0.5-alpine3.21 command: > sh -c ' redis-server --requirepass $$REDIS_PASSWORD --maxmemory 25mb --maxmemory-policy allkeys-lru @@ -67,6 +67,8 @@ services: python manage.py rqworker ai --with-scheduler ' depends_on: + staging-nest-backend: + condition: service_started staging-nest-cache: condition: service_healthy staging-nest-db: diff --git a/backend/docker/Dockerfile b/docker/backend/Dockerfile similarity index 95% rename from backend/docker/Dockerfile rename to docker/backend/Dockerfile index 5bc235755d..95493539b6 100644 --- a/backend/docker/Dockerfile +++ b/docker/backend/Dockerfile @@ -36,9 +36,7 @@ RUN --mount=type=cache,target=${POETRY_CACHE_DIR},uid=${OWASP_UID},gid=${OWASP_G poetry install --no-root --without dev --without test COPY apps apps -COPY docker/entrypoint.sh entrypoint.sh -COPY manage.py wsgi.py ./ -COPY Makefile Makefile +COPY Makefile entrypoint.sh manage.py wsgi.py ./ COPY settings settings COPY static static COPY templates templates diff --git a/backend/docker/Dockerfile.local b/docker/backend/Dockerfile.local similarity index 100% rename from backend/docker/Dockerfile.local rename to docker/backend/Dockerfile.local diff --git a/backend/docker/Dockerfile.test b/docker/backend/Dockerfile.test similarity index 100% rename from backend/docker/Dockerfile.test rename to docker/backend/Dockerfile.test diff --git a/docker/cspell/Dockerfile b/docker/cspell/Dockerfile new file mode 100644 index 0000000000..1e0069ca51 --- /dev/null +++ b/docker/cspell/Dockerfile @@ -0,0 +1,23 @@ +FROM node:24-alpine + +WORKDIR /opt/node + +ENV PNPM_HOME="/pnpm" +ENV NPM_CONFIG_RETRY=5 \ + NPM_CACHE="/nest/.npm" \ + NPM_CONFIG_TIMEOUT=30000 \ + PATH="$PNPM_HOME:$PATH" + +RUN --mount=type=cache,target=${NPM_CACHE} \ + npm install --ignore-scripts -g pnpm --cache ${NPM_CACHE} + +COPY package.json pnpm-lock.yaml ./ + +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --ignore-scripts + +WORKDIR /nest + +ENTRYPOINT ["/opt/node/node_modules/.bin/cspell"] + +USER node diff --git a/docs/docker/Dockerfile.local b/docker/docs/Dockerfile.local similarity index 100% rename from docs/docker/Dockerfile.local rename to docker/docs/Dockerfile.local diff --git a/frontend/docker/Dockerfile b/docker/frontend/Dockerfile similarity index 98% rename from frontend/docker/Dockerfile rename to docker/frontend/Dockerfile index 8c26df42ba..9106c60da7 100644 --- a/frontend/docker/Dockerfile +++ b/docker/frontend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-alpine AS base +FROM node:24-alpine AS base # Install dependencies and build the project. FROM base AS builder diff --git a/frontend/docker/Dockerfile.e2e.test b/docker/frontend/Dockerfile.e2e.test similarity index 93% rename from frontend/docker/Dockerfile.e2e.test rename to docker/frontend/Dockerfile.e2e.test index f9c13f1e70..681ef18691 100644 --- a/frontend/docker/Dockerfile.e2e.test +++ b/docker/frontend/Dockerfile.e2e.test @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/playwright:v1.56.1-jammy +FROM mcr.microsoft.com/playwright:v1.57.0-jammy ENV FORCE_COLOR=1 \ NPM_CACHE="/app/.npm" \ diff --git a/frontend/docker/Dockerfile.local b/docker/frontend/Dockerfile.local similarity index 96% rename from frontend/docker/Dockerfile.local rename to docker/frontend/Dockerfile.local index 7db53fe2c6..faa9106777 100644 --- a/frontend/docker/Dockerfile.local +++ b/docker/frontend/Dockerfile.local @@ -1,4 +1,4 @@ -FROM node:22-alpine AS builder +FROM node:24-alpine AS builder SHELL ["/bin/sh", "-o", "pipefail", "-c"] @@ -29,7 +29,7 @@ COPY --chmod=444 --chown=root:root package.json pnpm-lock.yaml ./ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile --ignore-scripts -FROM node:22-alpine +FROM node:24-alpine SHELL ["/bin/sh", "-o", "pipefail", "-c"] diff --git a/frontend/docker/Dockerfile.unit.test b/docker/frontend/Dockerfile.unit.test similarity index 96% rename from frontend/docker/Dockerfile.unit.test rename to docker/frontend/Dockerfile.unit.test index a2cb408b26..7a0e19e2bd 100644 --- a/frontend/docker/Dockerfile.unit.test +++ b/docker/frontend/Dockerfile.unit.test @@ -1,4 +1,4 @@ -FROM node:22-alpine +FROM node:24-alpine ENV FORCE_COLOR=1 \ NPM_CACHE="/app/.npm" \ diff --git a/docs/index.md b/docs/index.md index bdb29448bf..0baf6c94a1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,7 +14,7 @@ title: OWASP Nest ## What is OWASP Nest? -**OWASP Nest** is a comprehensive platform designed to enhance collaboration and contribution within the OWASP community. It serves as a central hub for exploring OWASP projects, finding contribution opportunities, and fostering community engagement in software security. +**OWASP Nest** is a comprehensive, community-first platform built to enhance collaboration and contribution across the OWASP community. It serves as a central hub for exploring OWASP projects, finding contribution opportunities, and fostering community engagement in software security. ### **Key Features** diff --git a/docs/poetry.lock b/docs/poetry.lock index 91d74e6e56..4d97b445fd 100644 --- a/docs/poetry.lock +++ b/docs/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "babel" @@ -37,14 +37,14 @@ extras = ["regex"] [[package]] name = "certifi" -version = "2025.11.12" +version = "2026.1.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, - {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, + {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, + {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, ] [[package]] @@ -475,14 +475,14 @@ pyyaml = ">=5.1" [[package]] name = "mkdocs-material" -version = "9.7.0" +version = "9.7.1" description = "Documentation that simply works" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "mkdocs_material-9.7.0-py3-none-any.whl", hash = "sha256:da2866ea53601125ff5baa8aa06404c6e07af3c5ce3d5de95e3b52b80b442887"}, - {file = "mkdocs_material-9.7.0.tar.gz", hash = "sha256:602b359844e906ee402b7ed9640340cf8a474420d02d8891451733b6b02314ec"}, + {file = "mkdocs_material-9.7.1-py3-none-any.whl", hash = "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c"}, + {file = "mkdocs_material-9.7.1.tar.gz", hash = "sha256:89601b8f2c3e6c6ee0a918cc3566cb201d40bf37c3cd3c2067e26fadb8cce2b8"}, ] [package.dependencies] @@ -496,7 +496,7 @@ mkdocs-material-extensions = ">=1.3" paginate = ">=0.5" pygments = ">=2.16" pymdown-extensions = ">=10.2" -requests = ">=2.26" +requests = ">=2.30" [package.extras] git = ["mkdocs-git-committers-plugin-2 (>=1.1,<3)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4,<2.0)"] @@ -535,18 +535,18 @@ mkdocs = ">=1.4.1" [[package]] name = "mkdocstrings" -version = "0.30.1" +version = "1.0.0" description = "Automatic documentation from sources, for MkDocs." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "mkdocstrings-0.30.1-py3-none-any.whl", hash = "sha256:41bd71f284ca4d44a668816193e4025c950b002252081e387433656ae9a70a82"}, - {file = "mkdocstrings-0.30.1.tar.gz", hash = "sha256:84a007aae9b707fb0aebfc9da23db4b26fc9ab562eb56e335e9ec480cb19744f"}, + {file = "mkdocstrings-1.0.0-py3-none-any.whl", hash = "sha256:4c50eb960bff6e05dfc631f6bc00dfabffbcb29c5ff25f676d64daae05ed82fa"}, + {file = "mkdocstrings-1.0.0.tar.gz", hash = "sha256:351a006dbb27aefce241ade110d3cd040c1145b7a3eb5fd5ac23f03ed67f401a"}, ] [package.dependencies] -Jinja2 = ">=2.11.1" +Jinja2 = ">=3.1" Markdown = ">=3.6" MarkupSafe = ">=1.1" mkdocs = ">=1.6" @@ -600,14 +600,14 @@ files = [ [[package]] name = "platformdirs" -version = "4.5.0" +version = "4.5.1" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"}, - {file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"}, + {file = "platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"}, + {file = "platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda"}, ] [package.extras] @@ -632,14 +632,14 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pymdown-extensions" -version = "10.17.1" +version = "10.20" description = "Extension pack for Python Markdown." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pymdown_extensions-10.17.1-py3-none-any.whl", hash = "sha256:1f160209c82eecbb5d8a0d8f89a4d9bd6bdcbde9a8537761844cfc57ad5cd8a6"}, - {file = "pymdown_extensions-10.17.1.tar.gz", hash = "sha256:60d05fe55e7fb5a1e4740fc575facad20dc6ee3a748e8d3d36ba44142e75ce03"}, + {file = "pymdown_extensions-10.20-py3-none-any.whl", hash = "sha256:ea9e62add865da80a271d00bfa1c0fa085b20d133fb3fc97afdc88e682f60b2f"}, + {file = "pymdown_extensions-10.20.tar.gz", hash = "sha256:5c73566ab0cf38c6ba084cb7c5ea64a119ae0500cce754ccb682761dfea13a52"}, ] [package.dependencies] @@ -798,21 +798,21 @@ files = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.2" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, - {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, + {file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"}, + {file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"}, ] [package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "watchdog" @@ -860,4 +860,4 @@ watchmedo = ["PyYAML (>=3.10)"] [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "d2a2201d0ca7101e52a18e12473e757d8ff9c5e56ab5862fabf775e679738619" +content-hash = "f565d79d95768ab65837d4b113b14539048a2d177a2a1c90b720d8c10172ce0c" diff --git a/docs/pyproject.toml b/docs/pyproject.toml index 2f68e68585..458c7b028e 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -13,6 +13,6 @@ license = "MIT" mkdocs = "^1.6.1" mkdocs-material = "^9.6.19" mkdocs-minify-plugin = "^0.8.0" -mkdocstrings = "^0.30.0" -pymdown-extensions = "^10.14.3" +mkdocstrings = "^1.0.0" +pymdown-extensions = "^10.20.0" python = "^3.13" diff --git a/frontend/Makefile b/frontend/Makefile index bbf879e051..a43ae55be3 100644 --- a/frontend/Makefile +++ b/frontend/Makefile @@ -59,14 +59,14 @@ test-frontend: \ test-frontend-e2e: @DOCKER_BUILDKIT=1 NEXT_PUBLIC_ENVIRONMENT=local docker build \ --cache-from nest-test-frontend-e2e \ - -f frontend/docker/Dockerfile.e2e.test frontend \ + -f docker/frontend/Dockerfile.e2e.test frontend \ -t nest-test-frontend-e2e @docker run --env-file frontend/.env.example --rm nest-test-frontend-e2e pnpm run test:e2e test-frontend-unit: @DOCKER_BUILDKIT=1 NEXT_PUBLIC_ENVIRONMENT=local docker build \ --cache-from nest-test-frontend-unit \ - -f frontend/docker/Dockerfile.unit.test frontend \ + -f docker/frontend/Dockerfile.unit.test frontend \ -t nest-test-frontend-unit @docker run --env-file frontend/.env.example --rm nest-test-frontend-unit pnpm run test:unit diff --git a/frontend/__tests__/e2e/components/Footer.spec.ts b/frontend/__tests__/e2e/components/Footer.spec.ts index 5641d2846b..373c6fc032 100644 --- a/frontend/__tests__/e2e/components/Footer.spec.ts +++ b/frontend/__tests__/e2e/components/Footer.spec.ts @@ -69,7 +69,7 @@ test.describe('Footer - Mobile (iPhone 13)', () => { test('should show sub-menu when menu clicked', async ({ page }) => { await page.getByRole('button', { name: 'OWASP Nest' }).click() // only check if the sub-menu is visible - await expect(page.getByRole('link', { name: 'GSoC 2025' })).toBeVisible() + await expect(page.getByRole('link', { name: 'GSoC 2026' })).toBeVisible() }) test('should have links', async ({ page }) => { await expect(page.getByRole('link', { name: 'OWASP Nest Bluesky' })).toBeVisible() diff --git a/frontend/__tests__/e2e/components/Header.spec.ts b/frontend/__tests__/e2e/components/Header.spec.ts index ef1a997083..d5b35be7c7 100644 --- a/frontend/__tests__/e2e/components/Header.spec.ts +++ b/frontend/__tests__/e2e/components/Header.spec.ts @@ -58,6 +58,21 @@ test.describe('Header - Desktop (Chrome)', () => { await expect(navbar.getByRole('link', { name: 'Organizations' })).toBeVisible() await expect(navbar.getByRole('link', { name: 'Snapshots' })).toBeVisible() }) + + test('all dropdown triggers should use pointer cursor', async ({ page }) => { + await page.goto('/') + + const dropdownButtons = page.locator('#navbar-sticky button') + + const count = await dropdownButtons.count() + expect(count).toBeGreaterThan(0) + + for (let i = 0; i < count; i++) { + const btn = dropdownButtons.nth(i) + const cursor = await btn.evaluate((el) => globalThis.getComputedStyle(el).cursor) + expect(cursor).toBe('pointer') + } + }) }) // Mobile tests (iPhone 13) diff --git a/frontend/__tests__/e2e/helpers/mockDashboardCookies.ts b/frontend/__tests__/e2e/helpers/mockDashboardCookies.ts index 6814779c21..4da8ddbefb 100644 --- a/frontend/__tests__/e2e/helpers/mockDashboardCookies.ts +++ b/frontend/__tests__/e2e/helpers/mockDashboardCookies.ts @@ -14,29 +14,26 @@ export const mockDashboardCookies = async (page, mockDashboardData, isOwaspStaff }) await page.route('**/graphql/', async (route, request) => { const postData = request.postDataJSON() - switch (postData.operationName) { - case 'SyncDjangoSession': - await route.fulfill({ - status: 200, - json: { - data: { - githubAuth: { - message: 'test message', - ok: true, - user: { isOwaspStaff: isOwaspStaff }, - }, + if (postData.operationName === 'SyncDjangoSession') { + await route.fulfill({ + status: 200, + json: { + data: { + githubAuth: { + message: 'test message', + ok: true, + user: { isOwaspStaff: isOwaspStaff }, }, }, - }) - break - default: - await route.fulfill({ - status: 200, - json: { - data: mockDashboardData, - }, - }) - break + }, + }) + } else { + await route.fulfill({ + status: 200, + json: { + data: mockDashboardData, + }, + }) } }) await page.context().addCookies([ diff --git a/frontend/__tests__/e2e/pages/About.spec.ts b/frontend/__tests__/e2e/pages/About.spec.ts index f25f73f2fc..7fca8dfd21 100644 --- a/frontend/__tests__/e2e/pages/About.spec.ts +++ b/frontend/__tests__/e2e/pages/About.spec.ts @@ -77,7 +77,7 @@ test.describe('About Page', () => { } }) - test('displays animated counters with correct values', async ({ page }) => { + test('displays project statistics with correct values', async ({ page }) => { await expect(page.getByText('1.2K+Contributors')).toBeVisible() await expect(page.getByText('40+Open Issues')).toBeVisible() await expect(page.getByText('60+Forks')).toBeVisible() diff --git a/frontend/__tests__/e2e/pages/CalendarButton.spec.ts b/frontend/__tests__/e2e/pages/CalendarButton.spec.ts new file mode 100644 index 0000000000..c4fd2a875b --- /dev/null +++ b/frontend/__tests__/e2e/pages/CalendarButton.spec.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs' +import { mockHomeData } from '@e2e/data/mockHomeData' +import { test, expect } from '@playwright/test' +import slugify from 'utils/slugify' + +test.describe('Calendar Export Functionality', () => { + test.beforeEach(async ({ page }) => { + await page.route('**/graphql/', async (route) => { + await route.fulfill({ + status: 200, + json: mockHomeData, + }) + }) + await page.context().addCookies([ + { + name: 'csrftoken', + value: 'abc123', + domain: 'localhost', + path: '/', + }, + ]) + await page.goto('/') + }) + + test('should download a valid ICS file when clicked', async ({ page }) => { + const calendarButton = page.getByRole('button', { name: 'Add Event 1 to Calendar' }) + await expect(calendarButton).toBeVisible() + + const downloadPromise = page.waitForEvent('download') + + await calendarButton.click() + + const download = await downloadPromise + + expect(download.suggestedFilename()).toBe(`${slugify('Event 1')}.ics`) + + const path = await download.path() + expect(path, 'Expected Playwright to provide a download path').toBeTruthy() + const content = fs.readFileSync(path, 'utf-8') + + expect(content).toContain('BEGIN:VCALENDAR') + expect(content).toContain('VERSION:2.0') + expect(content).toContain('BEGIN:VEVENT') + + expect(content).toContain('SUMMARY:') + expect(content).toContain('END:VCALENDAR') + }) +}) diff --git a/frontend/__tests__/e2e/pages/ChapterDetails.spec.ts b/frontend/__tests__/e2e/pages/ChapterDetails.spec.ts index 452aaa65f3..d7715e0aba 100644 --- a/frontend/__tests__/e2e/pages/ChapterDetails.spec.ts +++ b/frontend/__tests__/e2e/pages/ChapterDetails.spec.ts @@ -35,6 +35,8 @@ test.describe('Chapter Details Page', () => { await expect(page.locator('#chapter-map')).toBeVisible() await expect(page.locator('#chapter-map').locator('img').nth(1)).toBeVisible() + await page.getByRole('button', { name: 'Unlock map' }).click() + await expect(page.getByRole('button', { name: 'Zoom in' })).toBeVisible() await expect(page.getByRole('button', { name: 'Zoom out' })).toBeVisible() await expect(page.getByRole('button', { name: 'Marker' })).toBeVisible() @@ -42,9 +44,13 @@ test.describe('Chapter Details Page', () => { test('should have top contributors', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Top Contributors' })).toBeVisible() - await expect(page.getByRole('img', { name: 'Contributor 1', exact: true })).toBeVisible() + await expect( + page.getByRole('img', { name: "Contributor 1's avatar", exact: true }) + ).toBeVisible() await expect(page.getByText('Contributor 1', { exact: true })).toBeVisible() - await expect(page.getByRole('img', { name: 'Contributor 2', exact: true })).toBeVisible() + await expect( + page.getByRole('img', { name: "Contributor 2's avatar", exact: true }) + ).toBeVisible() await expect(page.getByText('Contributor 2', { exact: true })).toBeVisible() }) diff --git a/frontend/__tests__/e2e/pages/Home.spec.ts b/frontend/__tests__/e2e/pages/Home.spec.ts index dbfbcac522..d82c879abe 100644 --- a/frontend/__tests__/e2e/pages/Home.spec.ts +++ b/frontend/__tests__/e2e/pages/Home.spec.ts @@ -59,7 +59,9 @@ test.describe('Home Page', () => { test('should have top contributors', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Top Contributors' })).toBeVisible() - await expect(page.getByRole('img', { name: 'Contributor 1', exact: true })).toBeVisible() + await expect( + page.getByRole('img', { name: "Contributor 1's avatar", exact: true }) + ).toBeVisible() await expect(page.getByText('Contributor 1', { exact: true })).toBeVisible() }) @@ -98,9 +100,9 @@ test.describe('Home Page', () => { await expect(page.getByRole('heading', { name: 'Upcoming Events' })).toBeVisible({ timeout: 10000, }) - await expect(page.getByRole('button', { name: 'Event 1' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Event 1', exact: true })).toBeVisible() await expect(page.getByText('Apr 5 — 6, 2025')).toBeVisible() - await page.getByRole('button', { name: 'Event 1' }).click() + await page.getByRole('button', { name: 'Event 1', exact: true }).click() }) test('should have stats', async ({ page }) => { diff --git a/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts b/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts index 70eb9c1fa7..3931924930 100644 --- a/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts +++ b/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts @@ -61,9 +61,13 @@ test.describe('Project Details Page', () => { test('should have top contributors', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Top Contributors' })).toBeVisible() - await expect(page.getByRole('img', { name: 'Contributor 1', exact: true })).toBeVisible() + await expect( + page.getByRole('img', { name: "Contributor 1's avatar", exact: true }) + ).toBeVisible() await expect(page.getByText('Contributor 1', { exact: true })).toBeVisible() - await expect(page.getByRole('img', { name: 'Contributor 2', exact: true })).toBeVisible() + await expect( + page.getByRole('img', { name: "Contributor 2's avatar", exact: true }) + ).toBeVisible() await expect(page.getByText('Contributor 2', { exact: true })).toBeVisible() }) diff --git a/frontend/__tests__/e2e/pages/RepositoryDetails.spec.ts b/frontend/__tests__/e2e/pages/RepositoryDetails.spec.ts index 91763e3020..ee2376296b 100644 --- a/frontend/__tests__/e2e/pages/RepositoryDetails.spec.ts +++ b/frontend/__tests__/e2e/pages/RepositoryDetails.spec.ts @@ -55,9 +55,13 @@ test.describe('Repository Details Page', () => { test('should have top contributors', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Top Contributors' })).toBeVisible() - await expect(page.getByRole('img', { name: 'Contributor 1', exact: true })).toBeVisible() + await expect( + page.getByRole('img', { name: "Contributor 1's avatar", exact: true }) + ).toBeVisible() await expect(page.getByText('Contributor 1', { exact: true })).toBeVisible() - await expect(page.getByRole('img', { name: 'Contributor 2', exact: true })).toBeVisible() + await expect( + page.getByRole('img', { name: "Contributor 2's avatar", exact: true }) + ).toBeVisible() await expect(page.getByText('Contributor 2', { exact: true })).toBeVisible() }) diff --git a/frontend/__tests__/unit/components/AnchorTitle.test.tsx b/frontend/__tests__/unit/components/AnchorTitle.test.tsx index f0a6f3abcc..883a93f353 100644 --- a/frontend/__tests__/unit/components/AnchorTitle.test.tsx +++ b/frontend/__tests__/unit/components/AnchorTitle.test.tsx @@ -1,11 +1,7 @@ -import { library } from '@fortawesome/fontawesome-svg-core' -import { faLink } from '@fortawesome/free-solid-svg-icons' import { screen, render, fireEvent, waitFor } from '@testing-library/react' import slugifyMock from 'utils/slugify' import AnchorTitle from 'components/AnchorTitle' -library.add(faLink) - jest.mock('utils/slugify', () => ({ __esModule: true, default: jest.fn((str: string) => @@ -101,7 +97,7 @@ describe('AnchorTitle Component', () => { expect(titleElement).toHaveAttribute('data-anchor-title', 'true') }) - it('renders FontAwesome link icon', () => { + it('renders react-icons link icon', () => { render() const link = screen.getByRole('link') const icon = link.querySelector('svg') diff --git a/frontend/__tests__/unit/components/AnimatedCounter.test.tsx b/frontend/__tests__/unit/components/AnimatedCounter.test.tsx deleted file mode 100644 index b12061aa03..0000000000 --- a/frontend/__tests__/unit/components/AnimatedCounter.test.tsx +++ /dev/null @@ -1,295 +0,0 @@ -import { render, screen, act } from '@testing-library/react' -import '@testing-library/jest-dom' -import AnimatedCounter from 'components/AnimatedCounter' - -jest.useFakeTimers() - -// Patch for performance.now() in jsdom test env -beforeAll(() => { - if (typeof performance.now !== 'function') { - performance.now = jest.fn(() => Date.now()) - } -}) - -describe('AnimatedCounter', () => { - afterEach(() => { - jest.clearAllTimers() - }) - - describe('Renders successfully with minimal required props', () => { - it('renders correctly with initial count 0', () => { - render() - const counter = screen.getByText('0') - expect(counter).toBeInTheDocument() - }) - - it('renders with all props including className', () => { - render() - const element = screen.getByText('0') - expect(element).toHaveClass('test-class') - }) - }) - - describe('Prop-based behavior – different props affect output', () => { - it('renders with correct end value', () => { - render() - expect(screen.getByText('0')).toBeInTheDocument() - }) - - it('applies custom className when provided', () => { - render() - const element = screen.getByText('0') - expect(element).toHaveClass('custom-counter') - }) - - it('renders without className when not provided', () => { - render() - const element = screen.getByText('0') - expect(element).not.toHaveAttribute('class') - }) - }) - - describe('State changes / internal logic', () => { - it('animates to the end value over duration', () => { - render() - - // Advance time by 2 seconds within act() - act(() => { - jest.advanceTimersByTime(2000) - }) - - const counter = screen.getByText('1K') - expect(counter).toBeInTheDocument() - }) - - it('updates count during animation', () => { - render() - - // Advance time by 1 second (halfway through animation) - act(() => { - jest.advanceTimersByTime(1000) - }) - - // Should show intermediate value - const displayedValue = Number.parseInt(screen.getByText(/\d+/).textContent || '0') - expect(displayedValue).toBeGreaterThan(0) - expect(displayedValue).toBeLessThanOrEqual(50) - }) - - it('stops at exact end value', () => { - render() - - act(() => { - jest.advanceTimersByTime(1000) - }) - - expect(screen.getByText('75')).toBeInTheDocument() - }) - }) - - describe('Default values and fallbacks', () => { - it('handles zero end value', () => { - render() - expect(screen.getByText('0')).toBeInTheDocument() - }) - - it('handles negative end value', () => { - render() - expect(screen.getByText('0')).toBeInTheDocument() - }) - - it('handles very small duration', () => { - render() - expect(screen.getByText('0')).toBeInTheDocument() - }) - - it('handles very large duration', () => { - render() - expect(screen.getByText('0')).toBeInTheDocument() - }) - }) - - describe('Text and content rendering', () => { - it('displays formatted numbers using millify', () => { - render() - - act(() => { - jest.advanceTimersByTime(1000) - }) - - expect(screen.getByText('1.2K')).toBeInTheDocument() - }) - - it('renders as span element', () => { - render() - const element = screen.getByText('0') - expect(element.tagName).toBe('SPAN') - }) - }) - - describe('Handles edge cases and invalid inputs', () => { - it('handles decimal end values', () => { - render() - expect(screen.getByText('0')).toBeInTheDocument() - }) - - it('handles very large end values', () => { - render() - expect(screen.getByText('0')).toBeInTheDocument() - }) - - it('handles zero duration gracefully', () => { - render() - expect(screen.getByText('0')).toBeInTheDocument() - }) - - it('handles negative duration gracefully', () => { - render() - expect(screen.getByText('0')).toBeInTheDocument() - }) - }) - - describe('DOM structure / classNames / styles', () => { - it('renders with correct HTML structure', () => { - render() - const element = screen.getByText('0') - expect(element).toBeInTheDocument() - expect(element.tagName).toBe('SPAN') - expect(element).toHaveClass('test-class') - }) - - it('applies multiple CSS classes when provided', () => { - render() - const element = screen.getByText('0') - expect(element).toHaveClass('class1', 'class2') - }) - - it('handles empty className string', () => { - render() - const element = screen.getByText('0') - expect(element).toHaveAttribute('class', '') - }) - }) - - describe('Animation behavior', () => { - it('calls requestAnimationFrame during animation', () => { - const requestAnimationFrameSpy = jest.spyOn(globalThis, 'requestAnimationFrame') - render() - - expect(requestAnimationFrameSpy).toHaveBeenCalled() - }) - - it('renders final value correctly', () => { - render() - - act(() => { - jest.advanceTimersByTime(100) - }) - - expect(screen.getByText('100')).toBeInTheDocument() - }) - - it('updates count correctly', () => { - render() - - act(() => { - jest.advanceTimersByTime(1000) - }) - - expect(screen.getByText('10')).toBeInTheDocument() - }) - }) - - describe('Component lifecycle', () => { - it('re-initializes animation when props change', () => { - const { rerender } = render() - - // Wait for first animation to complete - act(() => { - jest.advanceTimersByTime(1000) - }) - - expect(screen.getByText('50')).toBeInTheDocument() - - // Change props - rerender() - - // Should show the new end value after animation completes - act(() => { - jest.advanceTimersByTime(2000) - }) - - expect(screen.getByText('100')).toBeInTheDocument() - }) - - it('handles rapid prop changes gracefully', () => { - const { rerender } = render() - - // Rapidly change props - rerender() - rerender() - rerender() - - // Should not crash and should render - expect(screen.getByText('0')).toBeInTheDocument() - }) - }) - - describe('Accessibility considerations', () => { - it('renders content that can be read by screen readers', () => { - render() - const element = screen.getByText('0') - expect(element).toBeInTheDocument() - expect(element.textContent).toBeTruthy() - }) - - it('maintains semantic meaning of displayed numbers', () => { - render() - const element = screen.getByText('0') - expect(element).toBeInTheDocument() - // The number should be meaningful to screen readers - expect(element.textContent).toMatch(/\d+/) - }) - }) - - describe('Event handling and user interactions', () => { - it('responds to prop changes correctly', () => { - const { rerender } = render() - - act(() => { - jest.advanceTimersByTime(1000) - }) - - expect(screen.getByText('100')).toBeInTheDocument() - - // Change end value - rerender() - - // Should show the new end value after animation completes - act(() => { - jest.advanceTimersByTime(1000) - }) - - expect(screen.getByText('200')).toBeInTheDocument() - }) - }) - - describe('Performance and optimization', () => { - it('does not cause infinite re-renders', () => { - const renderSpy = jest.fn() - const TestWrapper = () => { - renderSpy() - return - } - - render() - - act(() => { - jest.advanceTimersByTime(1000) - }) - - // Should not have excessive render calls - expect(renderSpy).toHaveBeenCalledTimes(1) - }) - }) -}) diff --git a/frontend/__tests__/unit/components/Badges.test.tsx b/frontend/__tests__/unit/components/Badges.test.tsx index cd171deb42..87534d4f24 100644 --- a/frontend/__tests__/unit/components/Badges.test.tsx +++ b/frontend/__tests__/unit/components/Badges.test.tsx @@ -2,31 +2,26 @@ import { render, screen } from '@testing-library/react' import React from 'react' import Badges from 'components/Badges' -jest.mock('wrappers/FontAwesomeIconWrapper', () => { - const RealWrapper = jest.requireActual('wrappers/FontAwesomeIconWrapper').default - - const getName = (icon) => { - if (!icon) return 'medal' - if (typeof icon === 'string') { - const m = icon.match(/fa-([a-z0-9-]+)$/i) - if (m) return m[1] - const last = icon.trim().split(/\s+/).pop() || '' - return last.replace(/^fa-/, '') || 'medal' - } - if (Array.isArray(icon) && icon.length >= 2) return String(icon[1]) - if (icon && typeof icon === 'object') return icon.iconName || String(icon[1] ?? 'medal') - return 'medal' - } - - return function MockFontAwesomeIconWrapper(props) { - const name = getName(props.icon) +jest.mock('wrappers/IconWrapper', () => ({ + IconWrapper: ({ + icon, + className, + ...props + }: { + icon: React.ComponentType<{ className?: string }> + className?: string + }) => { + let iconName = icon.name?.toLowerCase() || 'medal' + iconName = iconName.replace(/^fa/, '') + iconName = iconName.replace(/^reg/, '') + if (!iconName) iconName = 'medal' return ( -
- +
+
) - } -}) + }, +})) jest.mock('@heroui/tooltip', () => ({ Tooltip: ({ diff --git a/frontend/__tests__/unit/components/BarChart.test.tsx b/frontend/__tests__/unit/components/BarChart.test.tsx index 904d812225..a97dc346bb 100644 --- a/frontend/__tests__/unit/components/BarChart.test.tsx +++ b/frontend/__tests__/unit/components/BarChart.test.tsx @@ -1,11 +1,7 @@ -import { library } from '@fortawesome/fontawesome-svg-core' -import { faFire } from '@fortawesome/free-solid-svg-icons' import { render, screen } from '@testing-library/react' -import '@testing-library/jest-dom' import React from 'react' - -// Register FontAwesome icon -library.add(faFire) +import { FaFire } from 'react-icons/fa' +import '@testing-library/jest-dom' // Mock react-apexcharts completely jest.mock('react-apexcharts', () => { @@ -170,8 +166,7 @@ describe('', () => { }) it('renders with custom icon when provided', () => { - // cspell:ignore fas - renderWithTheme() + renderWithTheme() expect(screen.getByTestId('anchor-title')).toHaveTextContent('Calories Burned') expect(screen.getByTestId('card-icon')).toBeInTheDocument() }) @@ -367,7 +362,7 @@ describe('', () => { title: 'Decimal Values', labels: ['Decimal'], days: [99.5], - requirements: [100.0], + requirements: [100], } renderWithTheme() @@ -375,7 +370,7 @@ describe('', () => { const series = JSON.parse(chartElement.dataset.series || '[]') expect(series[0].data[0].y).toBe(99.5) - expect(series[0].data[0].goals[0].value).toBe(100.0) + expect(series[0].data[0].goals[0].value).toBe(100) }) it('handles large numbers in days array', () => { diff --git a/frontend/__tests__/unit/components/BreadCrumbs.test.tsx b/frontend/__tests__/unit/components/BreadCrumbs.test.tsx index 17511e18ad..245d9c63b3 100644 --- a/frontend/__tests__/unit/components/BreadCrumbs.test.tsx +++ b/frontend/__tests__/unit/components/BreadCrumbs.test.tsx @@ -1,68 +1,99 @@ import { render, screen } from '@testing-library/react' -import { usePathname } from 'next/navigation' -import BreadCrumbs from 'components/BreadCrumbs' +import BreadCrumbRenderer from 'components/BreadCrumbs' import '@testing-library/jest-dom' -jest.mock('next/navigation', () => ({ - usePathname: jest.fn(), -})) +describe('BreadCrumbRenderer', () => { + const mockItems = [ + { title: 'Home', path: '/' }, + { title: 'Projects', path: '/projects' }, + { title: 'OWASP ZAP', path: '/projects/zap' }, + ] -describe('BreadCrumb', () => { - afterEach(() => { - jest.clearAllMocks() + test('renders all breadcrumb items', () => { + render() + + expect(screen.getByText('Home')).toBeInTheDocument() + expect(screen.getByText('Projects')).toBeInTheDocument() + expect(screen.getByText('OWASP ZAP')).toBeInTheDocument() }) - test('does not render on root path "/"', () => { - ;(usePathname as jest.Mock).mockReturnValue('/') + test('renders navigation element with correct aria-label', () => { + render() - render() - expect(screen.queryByText('Home')).not.toBeInTheDocument() + const nav = screen.getByRole('navigation') + expect(nav).toHaveAttribute('aria-label', 'breadcrumb') }) - test('renders breadcrumb with multiple segments', () => { - ;(usePathname as jest.Mock).mockReturnValue('/dashboard/users/profile') + test('renders clickable links for non-last items', () => { + render() - render() + const homeLink = screen.getByText('Home').closest('a') + const projectsLink = screen.getByText('Projects').closest('a') - expect(screen.getByText('Home')).toBeInTheDocument() - expect(screen.getByText('Dashboard')).toBeInTheDocument() - expect(screen.getByText('Users')).toBeInTheDocument() - expect(screen.getByText('Profile')).toBeInTheDocument() + expect(homeLink).toHaveAttribute('href', '/') + expect(projectsLink).toHaveAttribute('href', '/projects') }) - test('disables the last segment (non-clickable)', () => { - ;(usePathname as jest.Mock).mockReturnValue('/settings/account') + test('disables the last item (non-clickable)', () => { + render() + + const lastItem = screen.getByText('OWASP ZAP') + expect(lastItem).not.toHaveAttribute('href') + expect(lastItem.tagName).toBe('SPAN') + }) - render() + test('applies hover styles to clickable links', () => { + render() - const lastSegment = screen.getByText('Account') - expect(lastSegment).toBeInTheDocument() - expect(lastSegment).not.toHaveAttribute('href') + const homeLink = screen.getByText('Home').closest('a') + expect(homeLink).toHaveClass('hover:text-blue-700', 'hover:underline') }) - test('links have correct href attributes', () => { - ;(usePathname as jest.Mock).mockReturnValue('/dashboard/users/profile') + test('applies disabled styling to last breadcrumb', () => { + render() - render() + const lastItem = screen.getByText('OWASP ZAP') + const parentSpan = lastItem.parentElement + expect(parentSpan).toHaveClass('cursor-default', 'font-semibold') + }) - const homeLink = screen.getByText('Home').closest('a') - const dashboardLink = screen.getByText('Dashboard').closest('a') - const usersLink = screen.getByText('Users').closest('a') + test('renders chevron separators between items', () => { + const { container } = render() - expect(homeLink).toHaveAttribute('href', '/') - expect(dashboardLink).toHaveAttribute('href', '/dashboard') - expect(usersLink).toHaveAttribute('href', '/dashboard/users') + const separators = container.querySelectorAll('[data-slot="separator"]') + expect(separators).toHaveLength(2) }) - test('links have hover styles', () => { - ;(usePathname as jest.Mock).mockReturnValue('/dashboard/users') + test('handles single item (home only)', () => { + const singleItem = [{ title: 'Home', path: '/' }] + render() - render() + expect(screen.getByText('Home')).toBeInTheDocument() + const separators = screen.queryByRole('separator') + expect(separators).not.toBeInTheDocument() + }) + + test('handles empty items array', () => { + const { container } = render() + + const breadcrumbList = container.querySelector('[data-slot="list"]') + expect(breadcrumbList?.children).toHaveLength(0) + }) + + test('applies correct wrapper styling', () => { + const { container } = render() + + const wrapper = container.querySelector('.mt-16') + expect(wrapper).toHaveClass('w-full', 'pt-4') + }) + + test('links have correct href attributes', () => { + render() const homeLink = screen.getByText('Home').closest('a') - const dashboardLink = screen.getByText('Dashboard').closest('a') + const projectsLink = screen.getByText('Projects').closest('a') - expect(homeLink).toHaveClass('hover:text-blue-700', 'hover:underline') - expect(dashboardLink).toHaveClass('hover:text-blue-700', 'hover:underline') + expect(homeLink).toHaveAttribute('href', '/') + expect(projectsLink).toHaveAttribute('href', '/projects') }) }) diff --git a/frontend/__tests__/unit/components/BreadCrumbsWrapper.test.tsx b/frontend/__tests__/unit/components/BreadCrumbsWrapper.test.tsx new file mode 100644 index 0000000000..8327b4a86a --- /dev/null +++ b/frontend/__tests__/unit/components/BreadCrumbsWrapper.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { BreadcrumbRoot, registerBreadcrumb } from 'contexts/BreadcrumbContext' +import { usePathname } from 'next/navigation' +import React, { useEffect } from 'react' +import BreadCrumbsWrapper from 'components/BreadCrumbsWrapper' + +jest.mock('next/navigation', () => ({ + usePathname: jest.fn(), +})) + +// Wrapper with BreadcrumbRoot for context tests +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +// Helper component to register a breadcrumb before rendering BreadCrumbsWrapper +function BreadCrumbsWrapperWithTitle({ path, title }: Readonly<{ path: string; title: string }>) { + useEffect(() => { + const unregister = registerBreadcrumb({ path, title }) + return unregister + }, [path, title]) + return +} + +describe('BreadCrumbsWrapper', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + describe('Route Detection - Should Hide', () => { + test('returns null on home page', () => { + ;(usePathname as jest.Mock).mockReturnValue('/') + + const { container } = render() + expect(container.firstChild).toBeNull() + }) + }) + + describe('Route Detection - Should Render', () => { + test('renders for routes with registered breadcrumbs', () => { + ;(usePathname as jest.Mock).mockReturnValue('/projects') + + render(, { wrapper }) + expect(screen.getByText('Home')).toBeInTheDocument() + expect(screen.getByText('Projects')).toBeInTheDocument() + }) + + test('uses registered title', () => { + ;(usePathname as jest.Mock).mockReturnValue('/projects/test-project-1') + + render( + , + { wrapper } + ) + // Should show registered title instead of URL slug + expect(screen.getByText('Security Scanner Tool')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/__tests__/unit/components/CalendarButton.test.tsx b/frontend/__tests__/unit/components/CalendarButton.test.tsx new file mode 100644 index 0000000000..55203cb7e1 --- /dev/null +++ b/frontend/__tests__/unit/components/CalendarButton.test.tsx @@ -0,0 +1,389 @@ +/** + * @jest-environment jsdom + */ + +import { addToast } from '@heroui/toast' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { FaCalendarDay, FaCalendarPlus } from 'react-icons/fa6' +import getIcsFileUrl from 'utils/getIcsFileUrl' +import slugify from 'utils/slugify' +import CalendarButton from 'components/CalendarButton' + +jest.mock('utils/getIcsFileUrl') + +jest.mock('@heroui/toast', () => ({ + addToast: jest.fn(), +})) + +const mockEvent = { + title: 'Test Event', + description: 'Test description', + location: 'Test Location', + startDate: '2025-12-01', + endDate: '2025-12-02', +} + +describe('CalendarButton', () => { + const mockUrl = 'blob:http://localhost/mock-file' + + let appendSpy: jest.SpyInstance + let clickSpy: jest.SpyInstance + let createSpy: jest.SpyInstance + + beforeEach(() => { + jest.clearAllMocks() + globalThis.URL.createObjectURL = jest.fn(() => 'mock-url') + globalThis.URL.revokeObjectURL = jest.fn() + ;(getIcsFileUrl as jest.Mock).mockResolvedValue(mockUrl) + + appendSpy = jest.spyOn(document.body, 'appendChild') + createSpy = jest.spyOn(document, 'createElement') + + clickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + + jest.spyOn(globalThis, 'alert').mockImplementation(() => {}) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('rendering', () => { + it('renders without crashing', () => { + render() + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + }) + + it('renders as a button element', () => { + render() + const button = screen.getByRole('button') + expect(button.tagName).toBe('BUTTON') + }) + + it('renders default calendar-plus icon', () => { + render() + const svg = document.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + + it('renders custom icon when provided', () => { + render( + } /> + ) + expect(screen.getByTestId('custom-icon')).toBeInTheDocument() + }) + + it('renders custom JSX element as icon', () => { + render(📅} />) + expect(screen.getByTestId('custom-span')).toBeInTheDocument() + expect(screen.getByText('📅')).toBeInTheDocument() + }) + }) + + describe('functionality (download)', () => { + it('generates ICS file and triggers download on click', async () => { + render() + const button = screen.getByRole('button') + + fireEvent.click(button) + + expect(button).toBeDisabled() + + await waitFor(() => { + expect(getIcsFileUrl).toHaveBeenCalledWith(mockEvent) + }) + + expect(createSpy).toHaveBeenCalledWith('a') + const createdLink = createSpy.mock.results.find( + (call) => call.value instanceof HTMLAnchorElement && call.value.href === mockUrl + )?.value + + expect(createdLink).toBeDefined() + expect(createdLink.download).toBe(`${slugify(mockEvent.title)}.ics`) + expect(appendSpy).toHaveBeenCalledWith(createdLink) + expect(clickSpy).toHaveBeenCalled() + + expect(addToast).toHaveBeenCalledWith({ + description: 'Successfully downloaded ICS file', + title: `${mockEvent.title}`, + timeout: 3000, + shouldShowTimeoutProgress: true, + color: 'success', + variant: 'solid', + }) + + await waitFor(() => { + expect(button).not.toBeDisabled() + }) + + expect(addToast).toHaveBeenCalledTimes(1) + }) + + it('handles errors gracefully when generation fails', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + const errorMock = new Error('Failed to generate') + ;(getIcsFileUrl as jest.Mock).mockRejectedValueOnce(errorMock) + + render() + const button = screen.getByRole('button') + + fireEvent.click(button) + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith({ + description: 'Failed to download ICS file', + title: 'Download Failed', + timeout: 3000, + shouldShowTimeoutProgress: true, + color: 'danger', + variant: 'solid', + }) + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to download ICS file'), + errorMock + ) + }) + + await waitFor(() => { + expect(button).not.toBeDisabled() + }) + + expect(addToast).not.toHaveBeenCalledWith( + expect.objectContaining({ + color: 'success', + title: `${mockEvent.title}`, + }) + ) + expect(addToast).toHaveBeenCalledTimes(1) + consoleSpy.mockRestore() + }) + }) + + describe('accessibility', () => { + it('has aria-label with event title', () => { + render() + const button = screen.getByRole('button') + expect(button).toHaveAttribute('aria-label', 'Add Test Event to Calendar') + }) + + it('uses fallback for events without explicit title', () => { + render() + const button = screen.getByRole('button') + expect(button).toHaveAttribute('aria-label', 'Add Untitled to Calendar') + }) + }) + + describe('className prop', () => { + it('applies className to button', () => { + render() + const button = screen.getByRole('button') + expect(button).toHaveClass('custom-class') + }) + + it('applies multiple classes', () => { + render() + const button = screen.getByRole('button') + expect(button).toHaveClass('class-one') + expect(button).toHaveClass('class-two') + }) + + it('handles empty className', () => { + render() + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + }) + }) + + describe('iconClassName prop', () => { + it('applies iconClassName to default icon', () => { + render() + const svg = document.querySelector('svg') + expect(svg).toHaveClass('h-4') + expect(svg).toHaveClass('w-4') + expect(svg).toHaveClass('text-blue-500') + }) + + it('applies different size classes correctly', () => { + const { rerender } = render() + let svg = document.querySelector('svg') + expect(svg).toHaveClass('h-4', 'w-4') + + rerender() + svg = document.querySelector('svg') + expect(svg).toHaveClass('h-6', 'w-6') + expect(svg).not.toHaveClass('h-4') + }) + + it('uses default iconClassName when not provided', () => { + render() + const svg = document.querySelector('svg') + expect(svg).toHaveClass('h-4', 'w-4') + }) + + it('does not apply iconClassName when custom icon is provided', () => { + render( + Icon} + /> + ) + const customIcon = screen.getByTestId('custom') + expect(customIcon).not.toHaveClass('should-not-apply') + }) + }) + + describe('showLabel prop', () => { + it('does not show label by default', () => { + render() + expect(screen.queryByText('Add to Calendar')).not.toBeInTheDocument() + }) + + it('shows default label when showLabel is true', () => { + render() + expect(screen.getByText('Add to Calendar')).toBeInTheDocument() + }) + + it('shows custom label when provided', () => { + render() + expect(screen.getByText('Save Event')).toBeInTheDocument() + expect(screen.queryByText('Add to Calendar')).not.toBeInTheDocument() + }) + }) + + describe('label prop', () => { + it('uses custom label text', () => { + render() + expect(screen.getByText('Export ICS')).toBeInTheDocument() + }) + + it('label is not rendered without showLabel', () => { + render() + expect(screen.queryByText('Hidden Label')).not.toBeInTheDocument() + }) + }) + + describe('icon prop extensibility', () => { + it('accepts icon as JSX', () => { + render( + } /> + ) + const svg = document.querySelector('svg') + expect(svg).toHaveClass('custom-icon-class') + }) + + it('accepts any React component as icon', () => { + const CustomIcon = () =>
Custom
+ render(} />) + expect(screen.getByTestId('react-component')).toBeInTheDocument() + }) + + it('accepts SVG element as icon', () => { + render( + + + + } + /> + ) + expect(screen.getByTestId('svg-icon')).toBeInTheDocument() + }) + }) + + describe('reusability scenarios', () => { + it('works in homepage event card context', () => { + render( + + ) + const button = screen.getByRole('button') + expect(button).toHaveClass('text-gray-600') + expect(button).toHaveClass('dark:text-gray-400') + }) + + it('works in poster page context with label', () => { + render( + + ) + expect(screen.getByText('Save to Calendar')).toBeInTheDocument() + const button = screen.getByRole('button') + expect(button).toHaveClass('btn-primary') + }) + }) + + 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( + + ) + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + expect(button).toHaveAttribute('aria-label', `Add ${longTitle} to Calendar`) + }) + + it('maintains visibility with flex-shrink-0 class', () => { + render( + + ) + const button = screen.getByRole('button') + expect(button).toHaveClass('flex-shrink-0') + expect(button).toBeVisible() + }) + + 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') + }) + }) +}) diff --git a/frontend/__tests__/unit/components/Card.test.tsx b/frontend/__tests__/unit/components/Card.test.tsx index bc0c587c67..7c25ffb6d1 100644 --- a/frontend/__tests__/unit/components/Card.test.tsx +++ b/frontend/__tests__/unit/components/Card.test.tsx @@ -1,5 +1,6 @@ import { screen, render, fireEvent } from '@testing-library/react' -import { ReactNode } from 'react' +import React, { ReactNode } from 'react' +import { FaLeaf, FaFire, FaCrown, FaStar, FaGithub, FaTwitter } from 'react-icons/fa6' import type { CardProps } from 'types/card' import Card from 'components/Card' @@ -12,18 +13,12 @@ interface MockLinkProps { className?: string } -interface MockFontAwesomeIconProps { - icon: unknown - className?: string -} - interface MockTooltipProps { children: ReactNode content: string id?: string closeDelay?: number delay?: number - placement?: string showArrow?: boolean } @@ -63,14 +58,6 @@ jest.mock('next/link', () => { } }) -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: ({ icon, className }: MockFontAwesomeIconProps) => ( - - {String(icon)} - - ), -})) - jest.mock('@heroui/tooltip', () => ({ Tooltip: ({ children, content, id }: MockTooltipProps) => (
@@ -79,15 +66,22 @@ jest.mock('@heroui/tooltip', () => ({ ), })) -jest.mock('wrappers/FontAwesomeIconWrapper', () => { - return function FontAwesomeIconWrapper({ icon, className }: MockFontAwesomeIconProps) { +jest.mock('wrappers/IconWrapper', () => ({ + IconWrapper: ({ + icon, + className, + }: { + icon: React.ComponentType<{ className?: string }> + className?: string + }) => { + const iconName = icon?.name?.toLowerCase().replace('fa', '') || 'icon' return ( - - {String(icon)} + + ) - } -}) + }, +})) jest.mock('components/ActionButton', () => { return function ActionButton({ children, onClick, tooltipLabel, url }: MockActionButtonProps) { @@ -131,7 +125,11 @@ jest.mock('components/MarkdownWrapper', () => { }) jest.mock('utils/urlIconMappings', () => ({ - getSocialIcon: jest.fn().mockReturnValue('mocked-social-icon'), + getSocialIcon: jest.fn().mockReturnValue(({ className }: { className?: string }) => ( + + + + )), })) jest.mock('utils/data', () => ({ @@ -179,12 +177,12 @@ describe('Card', () => { it('conditionally renders level badge when provided', () => { const propsWithLevel = { ...baseProps, - level: { level: 'Beginner', color: '#4CAF50', icon: 'leaf-icon' }, + level: { level: 'Beginner', color: '#4CAF50', icon: FaLeaf }, } render() expect(screen.getByTestId('tooltip')).toBeInTheDocument() - expect(screen.getByTestId('font-awesome-wrapper')).toBeInTheDocument() + expect(screen.getByTestId('icon-wrapper')).toBeInTheDocument() }) it('does not render level badge when not provided', () => { @@ -229,12 +227,12 @@ describe('Card', () => { const propsWithSocial = { ...baseProps, social: [ - { title: 'GitHub', url: 'https://github.com/test', icon: 'github' }, - { title: 'Twitter', url: 'https://twitter.com/test', icon: 'twitter' }, + { title: 'GitHub', url: 'https://github.com/test', icon: FaGithub }, + { title: 'Twitter', url: 'https://twitter.com/test', icon: FaTwitter }, ], } render() - expect(screen.getAllByTestId('font-awesome-icon')).toHaveLength(2) + expect(screen.getAllByTestId('social-icon')).toHaveLength(2) const allLinks = screen.getAllByRole('link') expect(allLinks.length).toBeGreaterThan(1) @@ -296,7 +294,7 @@ describe('Card', () => { it('applies different level badge colors based on props', () => { const propsWithLevel = { ...baseProps, - level: { level: 'Advanced', color: '#FF5722', icon: 'fire-icon' }, + level: { level: 'Advanced', color: '#FF5722', icon: FaFire }, } render() @@ -400,7 +398,7 @@ describe('Card', () => { it('has proper accessibility attributes', () => { const propsWithTooltip = { ...baseProps, - level: { level: 'Intermediate', color: '#2196F3', icon: 'star-icon' }, + level: { level: 'Intermediate', color: '#2196F3', icon: FaStar }, tooltipLabel: 'Click to contribute', } @@ -466,11 +464,11 @@ describe('Card', () => { it('renders complete card with all optional props', () => { const fullProps = { ...baseProps, - level: { level: 'Expert', color: '#9C27B0', icon: 'crown-icon' }, + level: { level: 'Expert', color: '#9C27B0', icon: FaCrown }, icons: { react: 'active', typescript: 'active' }, projectName: 'Full Stack Project', projectLink: 'https://fullstack.com', - social: [{ title: 'GitHub', url: 'https://github.com/full', icon: 'active' }], + social: [{ title: 'GitHub', url: 'https://github.com/full', icon: FaGithub }], topContributors: [ { login: 'expert', @@ -486,7 +484,7 @@ describe('Card', () => { expect(screen.getByTestId('tooltip')).toBeInTheDocument() expect(screen.getAllByTestId('display-icon')).toHaveLength(2) expect(screen.getByRole('link', { name: 'Full Stack Project' })).toBeInTheDocument() - expect(screen.getByTestId('font-awesome-icon')).toBeInTheDocument() + expect(screen.getByTestId('social-icon')).toBeInTheDocument() expect(screen.getByTestId('contributor-avatar')).toBeInTheDocument() }) }) diff --git a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx index ab62cbc02e..78acfe216c 100644 --- a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx +++ b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx @@ -1,10 +1,9 @@ -import { faCode, faTags } from '@fortawesome/free-solid-svg-icons' import { render, screen, cleanup } from '@testing-library/react' import React from 'react' import '@testing-library/jest-dom' +import { FaCode, FaTags } from 'react-icons/fa6' import type { DetailsCardProps } from 'types/card' -import CardDetailsPage from 'components/CardDetailsPage' - +import CardDetailsPage, { type CardType } from 'components/CardDetailsPage' jest.mock('next/link', () => { const MockLink = ({ children, @@ -56,31 +55,16 @@ jest.mock('next/image', () => ({ ), })) -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: ({ - icon, - className, - ...props - }: { - icon: { iconName: string } - className?: string - [key: string]: unknown - }) => , -})) - jest.mock('utils/env.client', () => ({ IS_PROJECT_HEALTH_ENABLED: true, })) jest.mock('utils/urlIconMappings', () => ({ getSocialIcon: (url: string) => { - if (url?.includes('github')) { - return { iconName: 'github' } + const safe = encodeURIComponent(url) + return function MockSocialIcon(props: { className?: string }) { + return } - if (url?.includes('twitter')) { - return { iconName: 'twitter' } - } - return { iconName: 'link' } }, })) @@ -107,11 +91,13 @@ jest.mock('components/ChapterMapWrapper', () => ({ geoLocData: _geoLocData, showLocal, style, + showLocationSharing: _showLocationSharing, ...otherProps }: { geoLocData?: unknown showLocal: boolean style: React.CSSProperties + showLocationSharing?: boolean [key: string]: unknown }) => { return ( @@ -131,9 +117,54 @@ jest.mock('components/HealthMetrics', () => ({ ), })) +jest.mock('components/ContributionHeatmap', () => ({ + __esModule: true, + default: ({ + contributionData, + startDate, + endDate, + ...props + }: { + contributionData: Record + startDate: string + endDate: string + [key: string]: unknown + }) => ( +
+ Heatmap: {Object.keys(contributionData).length} days from {startDate} to {endDate} +
+ ), +})) + +jest.mock('components/ContributionStats', () => ({ + __esModule: true, + default: ({ + title, + stats, + ...props + }: { + title: string + stats?: { commits: number; pullRequests: number; issues: number; total: number } + [key: string]: unknown + }) => ( +
+

{title}

+ {stats && ( + <> +

{stats.commits}

+

{stats.pullRequests}

+

{stats.issues}

+

{stats.total}

+ + )} +
+ ), +})) + jest.mock('components/InfoBlock', () => ({ __esModule: true, default: ({ + icon: _icon, pluralizedName, unit, value, @@ -174,11 +205,16 @@ jest.mock('components/MetricsScoreCircle', () => ({ clickable?: boolean onClick?: () => void [key: string]: unknown - }) => ( -
- Score: {score} -
- ), + }) => + clickable ? ( + + ) : ( +
+ Score: {score} +
+ ), })) jest.mock('components/Milestones', () => ({ @@ -276,9 +312,10 @@ jest.mock('components/SecondaryCard', () => ({ title, children, className, + icon: _icon, ...props }: { - _icon: unknown + _icon?: unknown title: React.ReactNode children: React.ReactNode className?: string @@ -314,6 +351,7 @@ jest.mock('components/ToggleableList', () => ({ __esModule: true, default: ({ items, + icon: _icon, label, ...props }: { @@ -412,13 +450,13 @@ describe('CardDetailsPage', () => { const mockStats = [ { - icon: faCode, + icon: FaCode, pluralizedName: 'repositories', unit: '', value: 10, }, { - icon: faTags, + icon: FaTags, pluralizedName: 'stars', unit: '', value: 100, @@ -700,7 +738,13 @@ describe('CardDetailsPage', () => { expect(detailsCard).toHaveClass('md:col-span-5') }) - const supportedTypes = ['project', 'repository', 'committee', 'user', 'organization'] + const supportedTypes: CardType[] = [ + 'project', + 'repository', + 'committee', + 'user', + 'organization', + ] test.each(supportedTypes)('renders statistics section for %s type', (entityType) => { render() @@ -956,7 +1000,14 @@ describe('CardDetailsPage', () => { expect(screen.getByTestId('recent-releases')).toBeInTheDocument() }) - const entityTypes = ['project', 'repository', 'user', 'organization', 'committee', 'chapter'] + const entityTypes: CardType[] = [ + 'project', + 'repository', + 'user', + 'organization', + 'committee', + 'chapter', + ] test.each(entityTypes)('renders all expected sections for %s type', (entityType) => { render( @@ -975,7 +1026,13 @@ describe('CardDetailsPage', () => { ).toBeInTheDocument() }) - const supportedTypes = ['project', 'repository', 'committee', 'user', 'organization'] + const supportedTypes: CardType[] = [ + 'project', + 'repository', + 'committee', + 'user', + 'organization', + ] test.each(supportedTypes)('renders statistics section for supported %s type', (entityType) => { render() @@ -1158,8 +1215,8 @@ describe('CardDetailsPage', () => { }) it('handles unsupported entity types gracefully', () => { - render() - + // eslint-disable-next-line @typescript-eslint/no-explicit-any + render() expect(screen.getByText('Unsupported-type Details')).toBeInTheDocument() }) @@ -1200,7 +1257,7 @@ describe('CardDetailsPage', () => { it('validates required vs optional props correctly', () => { const minimalValidProps: DetailsCardProps = { - type: 'project', + type: 'project' as const, stats: [], healthMetricsData: [], languages: [], @@ -1253,15 +1310,15 @@ describe('CardDetailsPage', () => { describe('Advanced Integration Tests', () => { it('handles multiple rapid prop changes', () => { - const { rerender } = render() + const { rerender } = render() - rerender() + rerender() expect(screen.getByText('Chapter Details')).toBeInTheDocument() - rerender() + rerender() expect(screen.getByText('User Details')).toBeInTheDocument() - rerender() + rerender() expect(screen.getByText('Organization Details')).toBeInTheDocument() }) @@ -1299,9 +1356,9 @@ describe('CardDetailsPage', () => { }) it('renders correctly with all optional sections enabled', () => { - const fullPropsAllSections = { + const fullPropsAllSections: DetailsCardProps = { ...defaultProps, - type: 'project', + type: 'project' as const, summary: 'Project summary text', userSummary:
User summary content
, socialLinks: ['https://github.com/test', 'https://twitter.com/test'], @@ -1329,9 +1386,9 @@ describe('CardDetailsPage', () => { it('handles zero and negative values in stats', () => { const statsWithZeroValues = [ - { icon: faCode, value: 0, unit: 'Star' }, - { icon: faTags, value: invalidValues.negativeNumber, unit: 'Issue' }, - { icon: faCode, value: invalidValues.nullValue, unit: 'Fork' }, + { icon: FaCode, value: 0, unit: 'Star' }, + { icon: FaTags, value: -10, unit: 'Issue' }, + { icon: FaCode, value: null, unit: 'Fork' }, ] expect(() => @@ -1402,9 +1459,9 @@ describe('CardDetailsPage', () => { describe('Archived Badge Functionality', () => { it('displays archived badge for archived repository', () => { - const archivedProps = { + const archivedProps: DetailsCardProps = { ...defaultProps, - type: 'repository', + type: 'repository' as const, isArchived: true, } @@ -1414,9 +1471,9 @@ describe('CardDetailsPage', () => { }) it('does not display archived badge for non-archived repository', () => { - const activeProps = { + const activeProps: DetailsCardProps = { ...defaultProps, - type: 'repository', + type: 'repository' as const, isArchived: false, } @@ -1426,9 +1483,9 @@ describe('CardDetailsPage', () => { }) it('does not display archived badge when isArchived is undefined', () => { - const undefinedProps = { + const undefinedProps: DetailsCardProps = { ...defaultProps, - type: 'repository', + type: 'repository' as const, } render() @@ -1437,9 +1494,9 @@ describe('CardDetailsPage', () => { }) it('does not display archived badge for non-repository types', () => { - const projectProps = { + const projectProps: DetailsCardProps = { ...defaultProps, - type: 'project', + type: 'project' as const, isArchived: true, } @@ -1449,9 +1506,9 @@ describe('CardDetailsPage', () => { }) it('displays archived badge alongside inactive badge', () => { - const bothBadgesProps = { + const bothBadgesProps: DetailsCardProps = { ...defaultProps, - type: 'repository', + type: 'repository' as const, isArchived: true, isActive: false, } @@ -1463,9 +1520,9 @@ describe('CardDetailsPage', () => { }) it('displays archived badge independently of active status', () => { - const archivedAndActiveProps = { + const archivedAndActiveProps: DetailsCardProps = { ...defaultProps, - type: 'repository', + type: 'repository' as const, isArchived: true, isActive: true, } @@ -1477,9 +1534,9 @@ describe('CardDetailsPage', () => { }) it('archived badge has correct positioning with flex container', () => { - const archivedProps = { + const archivedProps: DetailsCardProps = { ...defaultProps, - type: 'repository', + type: 'repository' as const, isArchived: true, } @@ -1491,9 +1548,9 @@ describe('CardDetailsPage', () => { }) it('archived badge renders with medium size', () => { - const archivedProps = { + const archivedProps: DetailsCardProps = { ...defaultProps, - type: 'repository', + type: 'repository' as const, isArchived: true, } @@ -1504,9 +1561,9 @@ describe('CardDetailsPage', () => { }) it('handles null isArchived gracefully', () => { - const nullArchivedProps = { + const nullArchivedProps: DetailsCardProps = { ...defaultProps, - type: 'repository', + type: 'repository' as const, isArchived: null, } @@ -1515,4 +1572,136 @@ describe('CardDetailsPage', () => { expect(screen.queryByText('Archived')).not.toBeInTheDocument() }) }) + + describe('Contribution Stats and Heatmap', () => { + const contributionData = { + '2024-01-01': 5, + '2024-01-02': 10, + '2024-01-03': 3, + } + + const contributionStats = { + commits: 100, + pullRequests: 50, + issues: 25, + total: 175, + } + + it('renders contribution stats and heatmap when data is provided', () => { + const propsWithContributions: DetailsCardProps = { + ...defaultProps, + type: 'project' as const, + contributionData, + contributionStats, + startDate: '2024-01-01', + endDate: '2024-12-31', + } + + render() + + expect(screen.getByText('Project Contribution Activity')).toBeInTheDocument() + expect(screen.getByText('100')).toBeInTheDocument() + expect(screen.getByText('50')).toBeInTheDocument() + expect(screen.getByText('25')).toBeInTheDocument() + expect(screen.getByText('175')).toBeInTheDocument() + }) + + it('uses correct title for chapter type', () => { + const chapterPropsWithContributions: DetailsCardProps = { + ...defaultProps, + type: 'chapter' as const, + contributionStats, + } + + render() + + expect(screen.getByText('Chapter Contribution Activity')).toBeInTheDocument() + }) + + it('does not render contribution section when no data is provided', () => { + render() + + expect(screen.queryByText('Project Contribution Activity')).not.toBeInTheDocument() + expect(screen.queryByText('Chapter Contribution Activity')).not.toBeInTheDocument() + }) + + it('renders only stats when contributionData is missing', () => { + const statsOnlyProps: DetailsCardProps = { + ...defaultProps, + type: 'project' as const, + contributionStats, + } + + render() + + expect(screen.getByText('Project Contribution Activity')).toBeInTheDocument() + expect(screen.getByText('100')).toBeInTheDocument() + }) + + it('renders heatmap when contributionData and dates are provided', () => { + const heatmapProps: DetailsCardProps = { + ...defaultProps, + type: 'project' as const, + contributionData, + startDate: '2024-01-01', + endDate: '2024-12-31', + } + + render() + + // Heatmap should be rendered (mocked in jest setup) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('does not render heatmap when dates are missing', () => { + const noDateProps: DetailsCardProps = { + ...defaultProps, + type: 'project' as const, + contributionData, + } + + render() + + expect(screen.queryByTestId('mock-heatmap-chart')).not.toBeInTheDocument() + }) + + it('does not render heatmap when contributionData is empty', () => { + const emptyDataProps: DetailsCardProps = { + ...defaultProps, + type: 'project' as const, + contributionData: {}, + startDate: '2024-01-01', + endDate: '2024-12-31', + } + + render() + + expect(screen.queryByTestId('mock-heatmap-chart')).not.toBeInTheDocument() + }) + + it('renders contribution section before top contributors', () => { + const fullProps: DetailsCardProps = { + ...defaultProps, + type: 'project' as const, + contributionStats, + topContributors: [ + { + login: 'user1', + name: 'User One', + avatarUrl: 'https://example.com/avatar1.png', + }, + ], + } + + render() + + const contributionSection = screen.getByText('Project Contribution Activity') + const contributorsSection = screen.getByText(/Top Contributors/i) + + // Check that contribution section appears before contributors + expect(contributionSection.compareDocumentPosition(contributorsSection)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + }) + }) }) diff --git a/frontend/__tests__/unit/components/ChapterMap.test.tsx b/frontend/__tests__/unit/components/ChapterMap.test.tsx index 68400d3cfe..66170e288d 100644 --- a/frontend/__tests__/unit/components/ChapterMap.test.tsx +++ b/frontend/__tests__/unit/components/ChapterMap.test.tsx @@ -8,6 +8,9 @@ const mockMap = { addLayer: jest.fn().mockReturnThis(), fitBounds: jest.fn().mockReturnThis(), on: jest.fn().mockReturnThis(), + getCenter: jest.fn(() => ({ lat: 20, lng: 0 })), + getZoom: jest.fn(() => 2), + getContainer: jest.fn(() => document.getElementById('chapter-map')), scrollWheelZoom: { enable: jest.fn(), disable: jest.fn(), @@ -35,6 +38,11 @@ const mockLatLngBounds = {} const mockIcon = {} +const mockZoomControl = { + addTo: jest.fn().mockReturnThis(), + remove: jest.fn(), +} + jest.mock('leaflet', () => ({ map: jest.fn(() => mockMap), marker: jest.fn(() => mockMarker), @@ -44,6 +52,9 @@ jest.mock('leaflet', () => ({ latLngBounds: jest.fn(() => mockLatLngBounds), // eslint-disable-next-line @typescript-eslint/naming-convention Icon: jest.fn(() => mockIcon), + control: { + zoom: jest.fn(() => mockZoomControl), + }, })) jest.mock('leaflet/dist/leaflet.css', () => ({})) @@ -132,6 +143,7 @@ describe('ChapterMap', () => { ], maxBoundsViscosity: 1.0, scrollWheelZoom: false, + zoomControl: false, }) expect(mockMap.setView).toHaveBeenCalledWith([20, 0], 2) }) @@ -266,24 +278,24 @@ describe('ChapterMap', () => { }) describe('Interactive Overlay', () => { - it('displays overlay with "Click to interact with map" message initially', () => { + it('displays overlay with "Unlock map" message initially', () => { const { getByText } = render() - expect(getByText('Click to interact with map')).toBeInTheDocument() + expect(getByText('Unlock map')).toBeInTheDocument() }) it('removes overlay when clicked', () => { const { getByText, queryByText } = render() - const overlay = getByText('Click to interact with map').closest('button') + const overlay = getByText('Unlock map').closest('button') fireEvent.click(overlay!) - expect(queryByText('Click to interact with map')).not.toBeInTheDocument() + expect(queryByText('Unlock map')).not.toBeInTheDocument() }) it('enables scroll wheel zoom when overlay is clicked', () => { const { getByText } = render() - const overlay = getByText('Click to interact with map').closest('button') + const overlay = getByText('Unlock map').closest('button') fireEvent.click(overlay!) expect(mockMap.scrollWheelZoom.enable).toHaveBeenCalled() @@ -292,7 +304,7 @@ describe('ChapterMap', () => { it('handles keyboard interaction with Enter key', () => { const { getByText } = render() - const overlay = getByText('Click to interact with map').closest('button') + const overlay = getByText('Unlock map').closest('button') fireEvent.keyDown(overlay!, { key: 'Enter' }) expect(mockMap.scrollWheelZoom.enable).toHaveBeenCalled() @@ -301,7 +313,7 @@ describe('ChapterMap', () => { it('handles keyboard interaction with Space key', () => { const { getByText } = render() - const overlay = getByText('Click to interact with map').closest('button') + const overlay = getByText('Unlock map').closest('button') fireEvent.keyDown(overlay!, { key: ' ' }) expect(mockMap.scrollWheelZoom.enable).toHaveBeenCalled() @@ -310,9 +322,9 @@ describe('ChapterMap', () => { it('has proper accessibility attributes', () => { const { getByText } = render() - const overlay = getByText('Click to interact with map').closest('button') + const overlay = getByText('Unlock map').closest('button') expect(overlay).toHaveAttribute('tabIndex', '0') - expect(overlay).toHaveAttribute('aria-label', 'Click to interact with map') + expect(overlay).toHaveAttribute('aria-label', 'Unlock map') }) }) @@ -328,7 +340,6 @@ describe('ChapterMap', () => { it('does not set local view when showLocal is false', () => { render() - expect(mockMap.setView).toHaveBeenCalledTimes(1) expect(mockMap.setView).toHaveBeenCalledWith([20, 0], 2) expect(mockMap.fitBounds).not.toHaveBeenCalled() }) @@ -352,9 +363,11 @@ describe('ChapterMap', () => { it('updates local view when showLocal prop changes', () => { const { rerender } = render() + const initialCallCount = mockMap.setView.mock.calls.length + rerender() - expect(mockMap.setView).toHaveBeenCalledTimes(2) + expect(mockMap.setView.mock.calls.length).toBeGreaterThan(initialCallCount) }) }) @@ -395,4 +408,64 @@ describe('ChapterMap', () => { expect(mapContainer).toHaveAttribute('id', 'chapter-map') }) }) + + describe('Zoom Control Visibility', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('does not show zoom control initially', () => { + render() + expect(L.map).toHaveBeenCalledWith( + 'chapter-map', + expect.objectContaining({ + zoomControl: false, + }) + ) + }) + + it('shows zoom control when unlock button is clicked', () => { + const { getByText } = render() + + const overlay = getByText('Unlock map').closest('button') + fireEvent.click(overlay) + + expect(L.control.zoom).toHaveBeenCalledWith({ position: 'topleft' }) + expect(mockZoomControl.addTo).toHaveBeenCalledWith(mockMap) + }) + }) + + describe('Share Location Button Visibility', () => { + const mockOnShareLocation = jest.fn() + + it('does not show share location button initially when map is not active', () => { + const { queryByLabelText } = render( + + ) + + expect(queryByLabelText(/share location/i)).not.toBeInTheDocument() + }) + + it('shows share location button when map becomes active', () => { + const { getByText, getByLabelText } = render( + + ) + + expect(getByText('Unlock map')).toBeInTheDocument() + + const overlay = getByText('Unlock map').closest('button') + fireEvent.click(overlay) + + expect(getByLabelText(/share location to find nearby chapters/i)).toBeInTheDocument() + }) + + it('does not render share location button when onShareLocation is not provided', () => { + const { getByText, queryByLabelText } = render() + + const overlay = getByText('Unlock map').closest('button') + fireEvent.click(overlay) + + expect(queryByLabelText(/share location/i)).not.toBeInTheDocument() + }) + }) }) diff --git a/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx new file mode 100644 index 0000000000..dbff39b822 --- /dev/null +++ b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx @@ -0,0 +1,677 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { ThemeProvider, useTheme } from 'next-themes' +import React from 'react' +import ContributionHeatmap from 'components/ContributionHeatmap' +import '@testing-library/jest-dom' + +jest.mock('react-apexcharts', () => { + return function MockChart(props: { + options: unknown + series: unknown + height: string | number + type: string + }) { + const mockSeries = props.series as Array<{ + name: string + data: Array<{ x: string; y: number; date: string }> + }> + const mockOptions = props.options as Record + + if (mockOptions.tooltip && typeof mockOptions.tooltip === 'object') { + const tooltip = mockOptions.tooltip as { custom?: (...args: unknown[]) => unknown } + if (tooltip.custom) { + if (mockSeries[0]?.data.length > 0) { + tooltip.custom({ + seriesIndex: 0, + dataPointIndex: 0, + w: { config: { series: mockSeries } }, + }) + } + tooltip.custom({ + seriesIndex: 0, + dataPointIndex: 999, + w: { config: { series: mockSeries } }, + }) + } + } + + return ( +
+ {mockSeries.map((series) => ( +
+ {series.name}: {series.data.length} data points +
+ ))} +
+ ) + } +}) + +jest.mock('next-themes', () => ({ + useTheme: jest.fn(), + ThemeProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +const renderWithTheme = (ui: React.ReactElement, theme: 'light' | 'dark' = 'light') => { + ;(useTheme as jest.Mock).mockReturnValue({ theme, setTheme: jest.fn() }) + return render({ui}) +} + +describe('ContributionHeatmap', () => { + const mockData: Record = { + '2024-01-01': 5, + '2024-01-02': 8, + '2024-01-03': 12, + '2024-01-04': 15, + '2024-01-05': 0, + '2024-01-08': 3, + '2024-01-15': 20, + } + const defaultProps = { + contributionData: mockData, + startDate: '2024-01-01', + endDate: '2024-01-31', + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering & Props', () => { + it('renders with minimal and all optional props', async () => { + const { rerender } = renderWithTheme() + await waitFor(() => expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument()) + expect(screen.queryByRole('heading')).not.toBeInTheDocument() + + rerender( + + + + ) + expect(screen.getByText('Activity')).toBeInTheDocument() + }) + + it('renders all 7 day series and correct chart type', () => { + renderWithTheme() + ;['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].forEach((day) => + expect(screen.getByTestId(`series-${day}`)).toBeInTheDocument() + ) + expect(screen.getByTestId('mock-heatmap-chart')).toHaveAttribute('data-type', 'heatmap') + }) + + it('applies custom unit and handles undefined title', () => { + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + expect(screen.queryByRole('heading')).not.toBeInTheDocument() + }) + }) + + describe('Data Processing & Edge Cases', () => { + it('handles empty, zero, and high values', () => { + const testCases = [ + { data: {}, desc: 'empty' }, + { data: { '2024-01-01': 0, '2024-01-02': 0 }, desc: 'zero' }, + { data: { '2024-01-01': 1000, '2024-01-02': 9999 }, desc: 'high' }, + ] + testCases.forEach(({ data }) => { + const { unmount } = renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + unmount() + }) + }) + + it('handles various date ranges', () => { + const ranges = [ + { start: '2024-01-01', end: '2024-01-01', data: { '2024-01-01': 5 } }, + { start: '2024-01-25', end: '2024-02-05', data: { '2024-01-25': 5, '2024-02-05': 10 } }, + { start: '2023-12-25', end: '2024-01-05', data: { '2023-12-25': 5, '2024-01-05': 10 } }, + ] + ranges.forEach(({ start, end, data }) => { + const { unmount } = renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + unmount() + }) + }) + + it('handles mid-week start and sparse data', () => { + const props = { + contributionData: { '2024-01-03': 5, '2024-01-15': 10, '2024-01-31': 3 }, + startDate: '2024-01-03', + endDate: '2024-01-31', + } + renderWithTheme() + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles large datasets (365 days)', () => { + const largeData: Record = {} + for (let i = 0; i < 365; i++) { + const date = new Date('2024-01-01') + date.setDate(date.getDate() + i) + largeData[date.toISOString().split('T')[0]] = i % 20 + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + }) + + describe('Theme & Styling', () => { + it('renders in light and dark mode with correct classes', () => { + const { rerender } = renderWithTheme( + , + 'light' + ) + expect(screen.getByText('Light').parentElement).toHaveClass('w-full') + ;(useTheme as jest.Mock).mockReturnValue({ theme: 'dark', setTheme: jest.fn() }) + rerender( + + + + ) + expect(screen.getByText('Dark').parentElement).toHaveClass('w-full') + }) + + it('applies correct container and style classes', () => { + const { container } = renderWithTheme() + expect(container.querySelector('.w-full')).toBeInTheDocument() + expect(container.querySelector('style')).toBeInTheDocument() + }) + + it('includes responsive media queries', () => { + const { container } = renderWithTheme() + const styleContent = container.querySelector('style')?.textContent + expect(styleContent).toContain('apexcharts-tooltip') + }) + }) + + describe('Content & Accessibility', () => { + it('renders title with correct styling and semantic HTML', () => { + const { container } = renderWithTheme( + + ) + const title = screen.getByText('Activity') + expect(title).toHaveClass('font-semibold') + expect(title.parentElement).toHaveClass('w-full') + expect(container.querySelector('h3')).toBeInTheDocument() + }) + + it('has accessible heading structure', () => { + renderWithTheme() + expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Accessible') + }) + }) + + describe('Chart Configuration & Performance', () => { + it('sets correct dimensions and series count', () => { + renderWithTheme() + const chart = screen.getByTestId('mock-heatmap-chart') + expect(chart).toHaveAttribute('data-height', '195') + expect(chart).toHaveAttribute('data-series-length', '7') + }) + + it('re-renders correctly when props change', () => { + const { rerender } = renderWithTheme() + const newProps = { + contributionData: { '2024-02-01': 10 }, + startDate: '2024-02-01', + endDate: '2024-02-28', + } + rerender( + + + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles dynamic import with SSR disabled', async () => { + renderWithTheme() + await waitFor(() => expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument()) + }) + }) + + describe('Tooltip Behavior', () => { + it('generates correct tooltip with date formatting', () => { + renderWithTheme() + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles singular and plural unit labels in tooltip', () => { + const { rerender } = renderWithTheme() + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + + const singleData = { '2024-01-01': 1 } + rerender( + + + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('tooltip respects theme colors', () => { + const { rerender } = renderWithTheme(, 'light') + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + ;(useTheme as jest.Mock).mockReturnValue({ theme: 'dark', setTheme: jest.fn() }) + rerender( + + + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles missing data in tooltip gracefully', () => { + const { container } = renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + + const styleTag = container.querySelector('style') + expect(styleTag).toBeInTheDocument() + }) + }) + + describe('Week Number Calculation', () => { + it('correctly calculates week numbers starting from Monday', () => { + const data = { + '2024-01-01': 5, + '2024-01-08': 10, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles week transitions correctly', () => { + const data = { + '2024-01-07': 5, + '2024-01-08': 10, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + }) + + describe('Color Scale Logic', () => { + it('applies correct color ranges for different activity levels', () => { + const activityData = { + '2024-01-01': 0, + '2024-01-02': 2, + '2024-01-03': 6, + '2024-01-04': 10, + '2024-01-05': 15, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles boundary values in color ranges', () => { + const boundaryData = { + '2024-01-01': 0, + '2024-01-02': 1, + '2024-01-03': 4, + '2024-01-04': 5, + '2024-01-05': 8, + '2024-01-06': 9, + '2024-01-07': 12, + '2024-01-08': 13, + '2024-01-09': 1000, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + }) + + describe('Date Range Edge Cases', () => { + it('handles leap year dates', () => { + const leapYearData = { + '2024-02-28': 5, + '2024-02-29': 10, + '2024-03-01': 3, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles year boundaries correctly', () => { + const yearBoundaryData = { + '2023-12-30': 5, + '2023-12-31': 10, + '2024-01-01': 8, + '2024-01-02': 12, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles dates in reverse chronological order in data object', () => { + const reversedData: Record = {} + for (let i = 30; i >= 1; i--) { + const date = new Date('2024-01-01') + date.setDate(date.getDate() + i - 1) + reversedData[date.toISOString().split('T')[0]] = i + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + }) + + describe('Extreme Values & Data Quality', () => { + it('handles negative contribution values gracefully', () => { + const negativeData = { + '2024-01-01': -5, + '2024-01-02': 10, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles floating point contribution values', () => { + const floatData = { + '2024-01-01': 5.5, + '2024-01-02': 10.99, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles extremely large contribution counts', () => { + const extremeData = { + '2024-01-01': 999999, + '2024-01-02': 1000000, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + }) + + describe('Component State & Lifecycle', () => { + it('maintains consistent rendering across multiple updates', () => { + const { rerender } = renderWithTheme() + + for (let i = 0; i < 5; i++) { + const newData = { [`2024-01-${i + 1}`]: i * 5 } + rerender( + + + + ) + } + + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles unmounting and remounting', () => { + const { unmount } = renderWithTheme() + unmount() + renderWithTheme() + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + }) + + describe('String Formatting & Localization', () => { + it('formats date strings correctly in different formats', () => { + const dateFormatData = { + '2024-01-01': 5, + '2024-1-2': 10, + } + renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('handles custom unit strings with special characters', () => { + const { unmount } = renderWithTheme( + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + unmount() + + const { rerender } = renderWithTheme() + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + + rerender( + + + + ) + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + }) + + describe('Chart Options Validation', () => { + it('configures chart with correct options structure', () => { + renderWithTheme() + const chart = screen.getByTestId('mock-heatmap-chart') + expect(chart).toHaveAttribute('data-type', 'heatmap') + }) + + it('renders heatmap chart', () => { + renderWithTheme() + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + }) + + describe('Responsive Design', () => { + it('applies responsive container classes', () => { + const { container } = renderWithTheme() + const heatmapContainer = container.querySelector('.w-full') + expect(heatmapContainer).toBeInTheDocument() + }) + + it('maintains aspect ratio on different screen sizes', () => { + const { container } = renderWithTheme() + const styleContent = container.querySelector('style')?.textContent + expect(styleContent).toContain('apexcharts-tooltip') + }) + }) + + describe('Integration & Real-world Scenarios', () => { + it('renders complete heatmap with realistic GitHub contribution data', () => { + const githubLikeData: Record = { + '2024-01-01': 5, + '2024-01-03': 12, + '2024-01-05': 3, + '2024-01-08': 8, + '2024-01-10': 15, + '2024-01-12': 7, + '2024-01-15': 20, + '2024-01-18': 4, + '2024-01-20': 9, + '2024-01-22': 11, + '2024-01-25': 6, + '2024-01-28': 13, + } + renderWithTheme( + + ) + + expect(screen.getByText('GitHub Contributions')).toBeInTheDocument() + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + ;['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].forEach((day) => + expect(screen.getByTestId(`series-${day}`)).toBeInTheDocument() + ) + }) + + it('handles complete absence of data gracefully', () => { + renderWithTheme( + + ) + + expect(screen.getByText('No Activity')).toBeInTheDocument() + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + + it('renders correctly with all edge cases combined', () => { + const complexData: Record = { + '2024-02-29': 100, + '2024-12-31': 50, + '2024-01-01': 0, + '2024-06-15': 1, + } + + renderWithTheme( + + ) + + expect(screen.getByTestId('mock-heatmap-chart')).toBeInTheDocument() + }) + }) + + describe('Variants', () => { + it('renders default variant with full dimensions', () => { + renderWithTheme() + const chart = screen.getByTestId('mock-heatmap-chart') + // Verify full-size dimensions (195px height for default variant) + expect(chart).toHaveAttribute('data-height', '195') + }) + + it('renders compact variant with smaller dimensions', () => { + renderWithTheme() + const chart = screen.getByTestId('mock-heatmap-chart') + // Verify compact dimensions (150px height for compact variant) + expect(chart).toHaveAttribute('data-height', '150') + }) + + it('applies compact-specific container styling when variant is compact', () => { + const { container } = renderWithTheme( + + ) + // Verify compact variant uses inline-block class + const chartContainer = container.querySelector('.inline-block') + expect(chartContainer).toBeInTheDocument() + }) + + it('applies default variant container styling when variant is default', () => { + const { container } = renderWithTheme( + + ) + // Verify default variant uses inline-block class + const chartContainer = container.querySelector('.inline-block') + expect(chartContainer).toBeInTheDocument() + }) + + it('defaults to default variant when no variant is specified', () => { + renderWithTheme() + const chart = screen.getByTestId('mock-heatmap-chart') + // Should render with default variant dimensions + expect(chart).toHaveAttribute('data-height', '195') + }) + + it('renders title with same styling regardless of variant', () => { + const { rerender } = renderWithTheme( + + ) + let title = screen.getByText('Test Title') + expect(title).toHaveClass('mb-4', 'text-sm', 'font-semibold') + + rerender( + + + + ) + title = screen.getByText('Test Title') + expect(title).toHaveClass('mb-4', 'text-sm', 'font-semibold') + }) + }) +}) diff --git a/frontend/__tests__/unit/components/ContributionStats.test.tsx b/frontend/__tests__/unit/components/ContributionStats.test.tsx new file mode 100644 index 0000000000..a2512b3f8b --- /dev/null +++ b/frontend/__tests__/unit/components/ContributionStats.test.tsx @@ -0,0 +1,326 @@ +import { render, screen } from '@testing-library/react' +import ContributionStats from 'components/ContributionStats' + +describe('ContributionStats', () => { + const mockStats = { + commits: 150, + pullRequests: 25, + issues: 42, + total: 217, + } + + const defaultProps = { + title: 'Test Contribution Activity', + stats: mockStats, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('renders the component with title and stats', () => { + render() + + expect(screen.getByText('Test Contribution Activity')).toBeInTheDocument() + expect(screen.getByText('Commits')).toBeInTheDocument() + expect(screen.getByText('PRs')).toBeInTheDocument() + expect(screen.getByText('Issues')).toBeInTheDocument() + expect(screen.getByText('Total')).toBeInTheDocument() + }) + + it('displays formatted numbers correctly', () => { + render() + + expect(screen.getByText('150')).toBeInTheDocument() + expect(screen.getByText('25')).toBeInTheDocument() + expect(screen.getByText('42')).toBeInTheDocument() + expect(screen.getByText('217')).toBeInTheDocument() + }) + + it('displays large numbers with locale formatting', () => { + const largeStats = { + commits: 1500, + pullRequests: 2500, + issues: 4200, + total: 8200, + } + + render() + + expect(screen.getByText('1,500')).toBeInTheDocument() + expect(screen.getByText('2,500')).toBeInTheDocument() + expect(screen.getByText('4,200')).toBeInTheDocument() + expect(screen.getByText('8,200')).toBeInTheDocument() + }) + + it('renders all react-icons correctly', () => { + render() + + const container = screen.getByTestId('contribution-stats') + const icons = container.querySelectorAll('svg') + expect(icons).toHaveLength(5) // Title icon + 4 stat icons + + // Verify specific icon data attributes + expect(screen.getByTestId('contribution-stats')).toBeInTheDocument() + expect(screen.getByText('Test Contribution Activity')).toBeInTheDocument() + }) + + it('formats extremely large numbers correctly', () => { + const extremeStats = { + commits: 1234567, + pullRequests: 987654, + issues: 456789, + total: 2679010, + } + + render() + + expect(screen.getByText('1,234,567')).toBeInTheDocument() + expect(screen.getByText('987,654')).toBeInTheDocument() + expect(screen.getByText('456,789')).toBeInTheDocument() + expect(screen.getByText('2,679,010')).toBeInTheDocument() + }) + }) + + describe('Edge Cases - No Data', () => { + it('handles undefined stats gracefully', () => { + render() + + expect(screen.getByText('No Stats')).toBeInTheDocument() + expect(screen.getAllByText('0')).toHaveLength(4) // All stats should show 0 + }) + + it('handles null stats gracefully', () => { + render() + + expect(screen.getByText('Null Stats')).toBeInTheDocument() + expect(screen.getAllByText('0')).toHaveLength(4) // All stats should show 0 + }) + + it('handles empty object stats', () => { + render() + + expect(screen.getByText('Empty Stats')).toBeInTheDocument() + expect(screen.getAllByText('0')).toHaveLength(4) // All stats should show 0 + }) + }) + + describe('Edge Cases - Partial Data', () => { + it('handles partial stats data - only commits', () => { + const partialStats = { + commits: 100, + } + + render() + + // Verify commits value + expect(screen.getByText('100')).toBeInTheDocument() + + // Verify PRs, issues, and total are 0 + expect(screen.getAllByText('0')).toHaveLength(3) // pullRequests, issues, total should be 0 + }) + + it('handles partial stats data - mixed values', () => { + const partialStats = { + commits: 50, + issues: 25, + total: 75, + } + + render() + + expect(screen.getByText('50')).toBeInTheDocument() // commits + expect(screen.getByText('25')).toBeInTheDocument() // issues + expect(screen.getByText('75')).toBeInTheDocument() // total + expect(screen.getByText('0')).toBeInTheDocument() // pullRequests should be 0 + }) + + it('handles zero values correctly', () => { + const zeroStats = { + commits: 0, + pullRequests: 0, + issues: 0, + total: 0, + } + + render() + + expect(screen.getAllByText('0')).toHaveLength(4) + }) + }) + + describe('Edge Cases - Invalid Values', () => { + it('handles negative values gracefully', () => { + const negativeStats = { + commits: -5, + pullRequests: -3, + issues: -2, + total: -10, + } + + render() + + // Component should still render, showing the negative values or handling them gracefully + expect(screen.getByText('Negative Stats')).toBeInTheDocument() + }) + + it('handles non-numeric values', () => { + const invalidStats = { + commits: 'invalid' as unknown as number, + pullRequests: null as unknown as number, + issues: undefined as unknown as number, + total: 42, + } + + render() + + expect(screen.getByText('Invalid Stats')).toBeInTheDocument() + expect(screen.getByText('42')).toBeInTheDocument() // total should still work + }) + + it('handles very large numbers without breaking', () => { + const largeStats = { + commits: Number.MAX_SAFE_INTEGER, + pullRequests: 999999999, + issues: 888888888, + total: Number.MAX_SAFE_INTEGER, + } + + render() + + expect(screen.getByText('Large Stats')).toBeInTheDocument() + // Should not crash, even with very large numbers + }) + }) + + describe('Loading States', () => { + it('renders with loading-like undefined stats', () => { + render() + + expect(screen.getByText('Loading...')).toBeInTheDocument() + expect(screen.getAllByText('0')).toHaveLength(4) // Should show zeros while loading + }) + + it('handles transitioning from undefined to actual data', () => { + const { rerender } = render() + + expect(screen.getAllByText('0')).toHaveLength(4) + + // Simulate data loading + rerender() + + expect(screen.getByText('150')).toBeInTheDocument() + expect(screen.getByText('25')).toBeInTheDocument() + expect(screen.getByText('42')).toBeInTheDocument() + expect(screen.getByText('217')).toBeInTheDocument() + }) + }) + + describe('Accessibility', () => { + it('has proper heading structure', () => { + render() + + const heading = screen.getByRole('heading', { level: 2 }) + expect(heading).toHaveTextContent('Test Contribution Activity') + }) + + it('has proper semantic structure', () => { + render() + + // Check that the container exists and the grid has proper classes + const container = screen.getByTestId('contribution-stats') + expect(container).toBeInTheDocument() + + // The mb-6 class is on the grid div, not the container + const grid = container.querySelector('.grid') + expect(grid).toHaveClass('mb-6', 'grid', 'grid-cols-2', 'gap-4', 'sm:grid-cols-4') + }) + + it('provides meaningful labels for screen readers', () => { + render() + + expect(screen.getByText('Commits')).toBeInTheDocument() + expect(screen.getByText('PRs')).toBeInTheDocument() + expect(screen.getByText('Issues')).toBeInTheDocument() + expect(screen.getByText('Total')).toBeInTheDocument() + }) + }) + + describe('Different Use Cases', () => { + it('renders project-specific title correctly', () => { + render() + + expect(screen.getByText('Project Contribution Activity')).toBeInTheDocument() + }) + + it('renders chapter-specific title correctly', () => { + render() + + expect(screen.getByText('Chapter Contribution Activity')).toBeInTheDocument() + }) + + it('renders board candidate context correctly', () => { + render() + + expect(screen.getByText('Board Candidate Contributions')).toBeInTheDocument() + }) + }) + + describe('Type Safety and Props', () => { + it('accepts readonly props without issues', () => { + const readonlyProps = { + title: 'Readonly Test' as const, + stats: mockStats, + } + + expect(() => render()).not.toThrow() + }) + + it('handles dynamic title changes', () => { + const { rerender } = render() + + expect(screen.getByText('Initial Title')).toBeInTheDocument() + + rerender() + + expect(screen.getByText('Updated Title')).toBeInTheDocument() + expect(screen.queryByText('Initial Title')).not.toBeInTheDocument() + }) + }) + + describe('Visual Elements', () => { + it('renders with proper CSS classes for styling', () => { + render() + + const container = screen.getByTestId('contribution-stats') + expect(container).toBeInTheDocument() + + const heading = container.querySelector('h2') + expect(heading).toHaveClass('mb-4', 'flex', 'items-center', 'gap-2') + + // The mb-6 class is on the grid div + const grid = container.querySelector('.grid') + expect(grid).toHaveClass('mb-6', 'grid', 'grid-cols-2', 'gap-4', 'sm:grid-cols-4') + }) + + it('renders all required icons with proper attributes', () => { + render() + + const container = screen.getByTestId('contribution-stats') + const icons = container.querySelectorAll('svg') + expect(icons).toHaveLength(5) + + // Verify icons have proper styling classes + icons.forEach((icon) => { + expect(icon).toHaveClass('text-gray-600', 'dark:text-gray-400') + }) + + // Verify specific viewBox attributes for different react-icons + const viewBoxes = Array.from(icons).map((icon) => icon.getAttribute('viewBox')) + expect(viewBoxes).toContain('0 0 512 512') // chart-line and exclamation-circle + expect(viewBoxes).toContain('0 0 640 512') // code + expect(viewBoxes).toContain('0 0 384 512') // code-branch + }) + }) +}) diff --git a/frontend/__tests__/unit/components/DashboardCard.test.tsx b/frontend/__tests__/unit/components/DashboardCard.test.tsx index 55b295f304..5d5b8e08f5 100644 --- a/frontend/__tests__/unit/components/DashboardCard.test.tsx +++ b/frontend/__tests__/unit/components/DashboardCard.test.tsx @@ -1,6 +1,6 @@ -import { faUser, faChartBar } from '@fortawesome/free-solid-svg-icons' import { render, screen } from '@testing-library/react' import React from 'react' +import { FaUser, FaChartBar } from 'react-icons/fa' import '@testing-library/jest-dom' import DashboardCard from 'components/DashboardCard' @@ -45,7 +45,7 @@ jest.mock('components/SecondaryCard', () => ({ describe('DashboardCard', () => { const baseProps = { title: 'Test Card', - icon: faUser, + icon: FaUser, className: undefined, stats: undefined, } @@ -56,7 +56,7 @@ describe('DashboardCard', () => { describe('Essential Rendering', () => { it('renders successfully with minimal required props', () => { - render() + render() expect(screen.getByTestId('secondary-card')).toBeInTheDocument() expect(screen.getByTestId('anchor-title')).toHaveTextContent('Test Card') expect(screen.getByTestId('secondary-content')).toBeInTheDocument() @@ -101,15 +101,15 @@ describe('DashboardCard', () => { }) it('renders different icons based on prop', () => { - const { rerender } = render() + const { rerender } = render() expect(screen.getByTestId('secondary-content').querySelector('svg')).toBeInTheDocument() - rerender() + rerender() expect(screen.getByTestId('secondary-content').querySelector('svg')).toBeInTheDocument() }) }) describe('DOM Structure', () => { - it('renders FontAwesomeIcon with correct icon', () => { + it('renders icon with correct icon', () => { render() expect(screen.getByTestId('secondary-content').querySelector('svg')).toBeInTheDocument() }) @@ -218,7 +218,7 @@ describe('DashboardCard', () => { it('handles rapid prop changes gracefully', () => { const { rerender } = render() - const icons = [faUser, faChartBar] + const icons = [FaUser, FaChartBar] const titles = ['A', 'B', 'C'] for (let i = 0; i < 3; i++) { rerender( diff --git a/frontend/__tests__/unit/components/DisplayIcon.test.tsx b/frontend/__tests__/unit/components/DisplayIcon.test.tsx index 981d817239..f34eb7ae3a 100644 --- a/frontend/__tests__/unit/components/DisplayIcon.test.tsx +++ b/frontend/__tests__/unit/components/DisplayIcon.test.tsx @@ -1,9 +1,9 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import React from 'react' +import { FaStar, FaCodeFork, FaUser, FaClock, FaComment, FaQuestion } from 'react-icons/fa6' import type { Icon } from 'types/icon' import DisplayIcon from 'components/DisplayIcon' - interface TooltipProps { children: React.ReactNode content: string @@ -13,11 +13,6 @@ interface TooltipProps { placement: string } -interface IconWrapperProps { - className?: string - icon: string -} - jest.mock('@heroui/tooltip', () => ({ Tooltip: ({ children, content, delay, closeDelay, showArrow, placement }: TooltipProps) => (
({ }), })) -jest.mock('wrappers/FontAwesomeIconWrapper', () => { - return function MockFontAwesomeIconWrapper({ className, icon }: IconWrapperProps) { - return - } -}) +jest.mock('wrappers/IconWrapper', () => ({ + IconWrapper: ({ + className, + icon: IconComponent, + }: { + className?: string + icon: React.ComponentType<{ className?: string }> + }) => { + // This derives a data-icon attribute from the react-icon component name + let iconName = '' + const toKebab = (name: string) => + name + .replaceAll('Fa', '') + .replaceAll(/([a-z0-9])([A-Z])/g, '$1-$2') + .toLowerCase() + + if (IconComponent?.displayName) { + iconName = 'fa-' + toKebab(IconComponent.displayName) + } else if (IconComponent?.name) { + iconName = 'fa-' + toKebab(IconComponent.name) + } + + return IconComponent ? ( + + + + ) : null + }, +})) jest.mock('utils/data', () => ({ ICONS: { - starsCount: { label: 'Stars', icon: 'fa-star' }, - forksCount: { label: 'Forks', icon: 'fa-code-fork' }, - contributorsCount: { label: 'Contributors', icon: 'fa-users' }, - contributionCount: { label: 'Contributors', icon: 'fa-users' }, - issuesCount: { label: 'Issues', icon: 'fa-exclamation-circle' }, - license: { label: 'License', icon: 'fa-balance-scale' }, - unknownItem: { label: 'Unknown', icon: 'fa-question' }, + starsCount: { label: 'Stars', icon: FaStar }, + forksCount: { label: 'Forks', icon: FaCodeFork }, + contributorsCount: { label: 'Contributors', icon: FaUser }, + createdAt: { label: 'Creation date', icon: FaClock }, + commentsCount: { label: 'Comments count', icon: FaComment }, + unknownItem: { label: 'Unknown', icon: FaQuestion }, }, })) @@ -117,7 +135,7 @@ describe('DisplayIcon', () => { expect(screen.getByTestId('font-awesome-icon')).toHaveAttribute('data-icon', 'fa-code-fork') rerender() - expect(screen.getByTestId('font-awesome-icon')).toHaveAttribute('data-icon', 'fa-users') + expect(screen.getByTestId('font-awesome-icon')).toHaveAttribute('data-icon', 'fa-user') }) it('applies different container classes based on item type', () => { diff --git a/frontend/__tests__/unit/components/DonutBarChart.test.tsx b/frontend/__tests__/unit/components/DonutBarChart.test.tsx index b3bcb71f8f..593e187746 100644 --- a/frontend/__tests__/unit/components/DonutBarChart.test.tsx +++ b/frontend/__tests__/unit/components/DonutBarChart.test.tsx @@ -1,7 +1,7 @@ -import { IconProp } from '@fortawesome/fontawesome-svg-core' import { screen } from '@testing-library/react' import { render } from '@testing-library/react' import { useTheme } from 'next-themes' +import { FaChartPie, FaChartBar, FaChartLine, FaTachometerAlt, FaHeart } from 'react-icons/fa' import DonutBarChart from 'components/DonutBarChart' // Mock next-themes @@ -47,12 +47,16 @@ jest.mock('components/AnchorTitle', () => { }) jest.mock('components/SecondaryCard', () => { - const MockSecondaryCard = ({ title, icon, children }) => ( -
-
{title}
-
{children}
-
- ) + const MockSecondaryCard = ({ title, icon, children }) => { + const iconName = icon?.displayName ?? icon?.name ?? icon + + return ( +
+
{title}
+
{children}
+
+ ) + } MockSecondaryCard.displayName = 'SecondaryCard' return MockSecondaryCard }) @@ -60,8 +64,6 @@ jest.mock('components/SecondaryCard', () => { describe('DonutBarChart Component Test Suite', () => { const mockUseTheme = useTheme as jest.MockedFunction - const iconProp = (name: string): IconProp => name as IconProp - beforeEach(() => { mockUseTheme.mockReturnValue({ theme: 'light', @@ -75,28 +77,22 @@ describe('DonutBarChart Component Test Suite', () => { describe('Basic rendering functionality', () => { it('renders the component with required props', () => { - render( - - ) + render() expect(screen.getByTestId('secondary-card')).toBeInTheDocument() expect(screen.getByTestId('apex-chart')).toBeInTheDocument() }) it('renders title through AnchorTitle component', () => { - render( - - ) + render() expect(screen.getByTestId('anchor-title')).toHaveTextContent('Health Metrics') }) it('passes icon to SecondaryCard', () => { - render( - - ) + render() - expect(screen.getByTestId('secondary-card')).toHaveAttribute('data-icon', 'analytics') + expect(screen.getByTestId('secondary-card')).toHaveAttribute('data-icon', 'FaChartLine') }) }) @@ -104,7 +100,7 @@ describe('DonutBarChart Component Test Suite', () => { it('processes series data with rounding', () => { const series = [33.333, 33.333, 33.334] - render() + render() const chart = screen.getByTestId('apex-chart') const chartSeries = JSON.parse(chart.dataset.series || '[]') @@ -116,7 +112,7 @@ describe('DonutBarChart Component Test Suite', () => { it('handles integer values correctly', () => { const series = [50, 30, 20] - render() + render() const chart = screen.getByTestId('apex-chart') const chartSeries = JSON.parse(chart.dataset.series || '[]') @@ -127,7 +123,7 @@ describe('DonutBarChart Component Test Suite', () => { it('handles decimal values with proper rounding', () => { const series = [25.555, 30.777, 43.668] - render() + render() const chart = screen.getByTestId('apex-chart') const chartSeries = JSON.parse(chart.dataset.series || '[]') @@ -139,7 +135,7 @@ describe('DonutBarChart Component Test Suite', () => { it('handles zero values', () => { const series = [0, 50, 0] - render() + render() const chart = screen.getByTestId('apex-chart') const chartSeries = JSON.parse(chart.dataset.series || '[]') @@ -150,7 +146,7 @@ describe('DonutBarChart Component Test Suite', () => { it('handles single value', () => { const series = [100] - render() + render() const chart = screen.getByTestId('apex-chart') const chartSeries = JSON.parse(chart.dataset.series || '[]') @@ -161,7 +157,7 @@ describe('DonutBarChart Component Test Suite', () => { it('handles empty series array', () => { const series: number[] = [] - render() + render() const chart = screen.getByTestId('apex-chart') const chartSeries = JSON.parse(chart.dataset.series || '[]') @@ -172,7 +168,7 @@ describe('DonutBarChart Component Test Suite', () => { describe('Chart configuration', () => { it('configures chart with correct options', () => { - render() + render() const chart = screen.getByTestId('apex-chart') const options = JSON.parse(chart.dataset.options || '{}') @@ -187,7 +183,7 @@ describe('DonutBarChart Component Test Suite', () => { }) it('sets correct chart type and height', () => { - render() + render() const chart = screen.getByTestId('apex-chart') @@ -196,7 +192,7 @@ describe('DonutBarChart Component Test Suite', () => { }) it('uses fixed color scheme', () => { - render() + render() const chart = screen.getByTestId('apex-chart') const options = JSON.parse(chart.dataset.options || '{}') @@ -209,7 +205,7 @@ describe('DonutBarChart Component Test Suite', () => { }) it('uses fixed labels', () => { - render() + render() const chart = screen.getByTestId('apex-chart') const options = JSON.parse(chart.dataset.options || '{}') @@ -228,7 +224,7 @@ describe('DonutBarChart Component Test Suite', () => { resolvedTheme: 'light', }) - render() + render() const chart = screen.getByTestId('apex-chart') const options = JSON.parse(chart.dataset.options || '{}') @@ -245,7 +241,7 @@ describe('DonutBarChart Component Test Suite', () => { resolvedTheme: 'dark', }) - render() + render() const chart = screen.getByTestId('apex-chart') const options = JSON.parse(chart.dataset.options || '{}') @@ -262,7 +258,7 @@ describe('DonutBarChart Component Test Suite', () => { resolvedTheme: 'dark', }) - render() + render() const chart = screen.getByTestId('apex-chart') // The key should be applied but we can't directly test it in our mock @@ -274,7 +270,7 @@ describe('DonutBarChart Component Test Suite', () => { describe('Component structure and accessibility', () => { it('maintains proper component hierarchy', () => { - render() + render() const card = screen.getByTestId('secondary-card') const title = screen.getByTestId('anchor-title') @@ -290,7 +286,7 @@ describe('DonutBarChart Component Test Suite', () => { }) it('renders chart inside proper container div', () => { - render() + render() const cardContent = screen.getByTestId('card-content') const chart = screen.getByTestId('apex-chart') @@ -301,18 +297,14 @@ describe('DonutBarChart Component Test Suite', () => { describe('Prop validation and edge cases', () => { it('handles different icon types', () => { - const iconTypes = ['chart-pie', 'chart-bar', 'analytics', 'dashboard', 'heart'] + const iconTypes = [FaChartPie, FaChartBar, FaChartLine, FaTachometerAlt, FaHeart] for (const iconType of iconTypes) { const { unmount } = render( - + ) - expect(screen.getByTestId('secondary-card')).toHaveAttribute('data-icon', iconType) + expect(screen.getByTestId('secondary-card')).toHaveAttribute('data-icon', iconType.name) unmount() } }) @@ -328,7 +320,7 @@ describe('DonutBarChart Component Test Suite', () => { for (const title of titles) { const { unmount } = render( - + ) expect(screen.getByTestId('anchor-title')).toHaveTextContent(title) @@ -339,7 +331,7 @@ describe('DonutBarChart Component Test Suite', () => { it('handles large series values', () => { const largeSeries = [999999.999, 1000000.001, 2000000.5] - render() + render() const chart = screen.getByTestId('apex-chart') const chartSeries = JSON.parse(chart.dataset.series || '[]') @@ -351,7 +343,7 @@ describe('DonutBarChart Component Test Suite', () => { it('handles negative values', () => { const negativeSeries = [-10.5, 50.7, -20.3] - render() + render() const chart = screen.getByTestId('apex-chart') const chartSeries = JSON.parse(chart.dataset.series || '[]') @@ -363,14 +355,14 @@ describe('DonutBarChart Component Test Suite', () => { describe('Performance and re-rendering', () => { it('handles series updates correctly', () => { const { rerender } = render( - + ) let chart = screen.getByTestId('apex-chart') let chartSeries = JSON.parse(chart.dataset.series || '[]') expect(chartSeries).toEqual([50, 30, 20]) - rerender() + rerender() chart = screen.getByTestId('apex-chart') chartSeries = JSON.parse(chart.dataset.series || '[]') @@ -379,31 +371,31 @@ describe('DonutBarChart Component Test Suite', () => { it('handles title updates correctly', () => { const { rerender } = render( - + ) expect(screen.getByTestId('anchor-title')).toHaveTextContent('Original Title') - rerender() + rerender() expect(screen.getByTestId('anchor-title')).toHaveTextContent('Updated Title') }) it('handles icon updates correctly', () => { const { rerender } = render( - + ) - expect(screen.getByTestId('secondary-card')).toHaveAttribute('data-icon', 'chart-pie') + expect(screen.getByTestId('secondary-card')).toHaveAttribute('data-icon', 'FaChartPie') - rerender() + rerender() - expect(screen.getByTestId('secondary-card')).toHaveAttribute('data-icon', 'chart-bar') + expect(screen.getByTestId('secondary-card')).toHaveAttribute('data-icon', 'FaChartBar') }) it('handles theme changes correctly', () => { const { rerender } = render( - + ) let chart = screen.getByTestId('apex-chart') @@ -418,7 +410,7 @@ describe('DonutBarChart Component Test Suite', () => { resolvedTheme: 'dark', }) - rerender() + rerender() chart = screen.getByTestId('apex-chart') options = JSON.parse(chart.dataset.options || '{}') @@ -431,7 +423,7 @@ describe('DonutBarChart Component Test Suite', () => { const mockRound = jest.requireMock('utils/round').round render( - + ) expect(mockRound).toHaveBeenCalledTimes(3) @@ -441,13 +433,13 @@ describe('DonutBarChart Component Test Suite', () => { }) it('integrates properly with next-themes useTheme hook', () => { - render() + render() expect(mockUseTheme).toHaveBeenCalled() }) it('uses dynamic import for Chart component (SSR safety)', () => { - render() + render() // Chart should render (mocked) proving dynamic import works expect(screen.getByTestId('apex-chart')).toBeInTheDocument() diff --git a/frontend/__tests__/unit/components/EntityActions.test.tsx b/frontend/__tests__/unit/components/EntityActions.test.tsx new file mode 100644 index 0000000000..dac23e12be --- /dev/null +++ b/frontend/__tests__/unit/components/EntityActions.test.tsx @@ -0,0 +1,487 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { useRouter } from 'next/navigation' +import { ProgramStatusEnum } from 'types/__generated__/graphql' +import EntityActions from 'components/EntityActions' + +// Mock next/navigation +const mockPush = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), +})) + +describe('EntityActions', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(useRouter as jest.Mock).mockReturnValue({ + push: mockPush, + }) + }) + + describe('Program Actions - Create Module', () => { + it('navigates to create module page when Add Module is clicked', () => { + render() + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + const addModuleButton = screen.getByText('Add Module') + fireEvent.click(addModuleButton) + + expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/test-program/modules/create') + }) + + it('closes dropdown after clicking Add Module', () => { + render() + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + + const addModuleButton = screen.getByText('Add Module') + fireEvent.click(addModuleButton) + + expect(button).toHaveAttribute('aria-expanded', 'false') + }) + }) + + describe('Module Actions - Edit Module', () => { + it('navigates to edit module page when Edit is clicked with moduleKey', () => { + render() + const button = screen.getByTestId('module-actions-button') + fireEvent.click(button) + + const editButton = screen.getByText('Edit') + fireEvent.click(editButton) + + expect(mockPush).toHaveBeenCalledWith( + '/my/mentorship/programs/test-program/modules/test-module/edit' + ) + }) + + it('does not navigate when moduleKey is missing for edit action', () => { + render() + const button = screen.getByTestId('module-actions-button') + fireEvent.click(button) + + const editButton = screen.getByText('Edit') + fireEvent.click(editButton) + + expect(mockPush).not.toHaveBeenCalled() + }) + + it('closes dropdown after clicking Edit', () => { + render() + const button = screen.getByTestId('module-actions-button') + fireEvent.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + + const editButton = screen.getByText('Edit') + fireEvent.click(editButton) + + expect(button).toHaveAttribute('aria-expanded', 'false') + }) + }) + + describe('Module Actions - View Issues', () => { + it('navigates to view issues page when View Issues is clicked with moduleKey', () => { + render() + const button = screen.getByTestId('module-actions-button') + fireEvent.click(button) + + const viewIssuesButton = screen.getByText('View Issues') + fireEvent.click(viewIssuesButton) + + expect(mockPush).toHaveBeenCalledWith( + '/my/mentorship/programs/test-program/modules/test-module/issues' + ) + }) + + it('does not navigate when moduleKey is missing for view issues action', () => { + render() + const button = screen.getByTestId('module-actions-button') + fireEvent.click(button) + + const viewIssuesButton = screen.getByText('View Issues') + fireEvent.click(viewIssuesButton) + + expect(mockPush).not.toHaveBeenCalled() + }) + + it('closes dropdown after clicking View Issues', () => { + render() + const button = screen.getByTestId('module-actions-button') + fireEvent.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + + const viewIssuesButton = screen.getByText('View Issues') + fireEvent.click(viewIssuesButton) + + expect(button).toHaveAttribute('aria-expanded', 'false') + }) + }) + + describe('Program Status Changes - Publish', () => { + it('calls setStatus with PUBLISHED when Publish is clicked', () => { + const mockSetStatus = jest.fn() + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + const publishButton = screen.getByText('Publish') + fireEvent.click(publishButton) + + expect(mockSetStatus).toHaveBeenCalledWith(ProgramStatusEnum.Published) + }) + + it('shows Publish option only when status is DRAFT', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + expect(screen.getByText('Publish')).toBeInTheDocument() + }) + + it('does not show Publish option when status is PUBLISHED', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + expect(screen.queryByText('Publish')).not.toBeInTheDocument() + }) + + it('closes dropdown after clicking Publish', () => { + const mockSetStatus = jest.fn() + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + + const publishButton = screen.getByText('Publish') + fireEvent.click(publishButton) + + expect(button).toHaveAttribute('aria-expanded', 'false') + }) + }) + + describe('Program Status Changes - Draft (Unpublish)', () => { + it('calls setStatus with DRAFT when Unpublish is clicked from PUBLISHED', () => { + const mockSetStatus = jest.fn() + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + const unpublishButton = screen.getByText('Unpublish') + fireEvent.click(unpublishButton) + + expect(mockSetStatus).toHaveBeenCalledWith(ProgramStatusEnum.Draft) + }) + + it('calls setStatus with DRAFT when Unpublish is clicked from COMPLETED', () => { + const mockSetStatus = jest.fn() + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + const unpublishButton = screen.getByText('Unpublish') + fireEvent.click(unpublishButton) + + expect(mockSetStatus).toHaveBeenCalledWith(ProgramStatusEnum.Draft) + }) + + it('shows Unpublish option when status is PUBLISHED', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + expect(screen.getByText('Unpublish')).toBeInTheDocument() + }) + + it('shows Unpublish option when status is COMPLETED', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + expect(screen.getByText('Unpublish')).toBeInTheDocument() + }) + + it('does not show Unpublish option when status is DRAFT', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + expect(screen.queryByText('Unpublish')).not.toBeInTheDocument() + }) + + it('closes dropdown after clicking Unpublish', () => { + const mockSetStatus = jest.fn() + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + + const unpublishButton = screen.getByText('Unpublish') + fireEvent.click(unpublishButton) + + expect(button).toHaveAttribute('aria-expanded', 'false') + }) + }) + + describe('Program Status Changes - Completed', () => { + it('calls setStatus with COMPLETED when Mark as Completed is clicked', () => { + const mockSetStatus = jest.fn() + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + const completedButton = screen.getByText('Mark as Completed') + fireEvent.click(completedButton) + + expect(mockSetStatus).toHaveBeenCalledWith(ProgramStatusEnum.Completed) + }) + + it('shows Mark as Completed option only when status is PUBLISHED', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + expect(screen.getByText('Mark as Completed')).toBeInTheDocument() + }) + + it('does not show Mark as Completed when status is DRAFT', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + expect(screen.queryByText('Mark as Completed')).not.toBeInTheDocument() + }) + + it('does not show Mark as Completed when status is COMPLETED', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + expect(screen.queryByText('Mark as Completed')).not.toBeInTheDocument() + }) + + it('closes dropdown after clicking Mark as Completed', () => { + const mockSetStatus = jest.fn() + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + + const completedButton = screen.getByText('Mark as Completed') + fireEvent.click(completedButton) + + expect(button).toHaveAttribute('aria-expanded', 'false') + }) + }) + + describe('Click Outside Behavior', () => { + it('closes dropdown when clicking outside', async () => { + render() + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + + fireEvent.mouseDown(document.body) + + await waitFor(() => { + expect(button).toHaveAttribute('aria-expanded', 'false') + }) + }) + + it('does not close dropdown when clicking inside', () => { + render() + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + + fireEvent.mouseDown(button) + + expect(button).toHaveAttribute('aria-expanded', 'true') + }) + + it('cleans up event listener on unmount', () => { + const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener') + const { unmount } = render() + + unmount() + + expect(removeEventListenerSpy.mock.calls).toEqual( + expect.arrayContaining([['mousedown', expect.any(Function)]]) + ) + removeEventListenerSpy.mockRestore() + }) + }) + + describe('Edge Cases and Error Handling', () => { + it('handles undefined setStatus gracefully when clicking Publish', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + const publishButton = screen.getByText('Publish') + // Should not throw error even without setStatus + expect(() => fireEvent.click(publishButton)).not.toThrow() + }) + + it('handles undefined setStatus gracefully when clicking Unpublish', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + const unpublishButton = screen.getByText('Unpublish') + expect(() => fireEvent.click(unpublishButton)).not.toThrow() + }) + + it('handles undefined setStatus gracefully when clicking Mark as Completed', () => { + render( + + ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + const completedButton = screen.getByText('Mark as Completed') + expect(() => fireEvent.click(completedButton)).not.toThrow() + }) + + it('handles undefined status gracefully', () => { + render() + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + // Should still show Edit and Add Module + expect(screen.getByText('Edit')).toBeInTheDocument() + expect(screen.getByText('Add Module')).toBeInTheDocument() + }) + }) + + describe('Event Propagation', () => { + it('prevents event propagation on button click', () => { + const mockParentClick = jest.fn() + render( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
+ +
+ ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + expect(mockParentClick).not.toHaveBeenCalled() + }) + + it('prevents event propagation on menu item click', () => { + const mockParentClick = jest.fn() + render( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
+ +
+ ) + const button = screen.getByTestId('program-actions-button') + fireEvent.click(button) + + const editButton = screen.getByText('Edit') + fireEvent.click(editButton) + + expect(mockParentClick).not.toHaveBeenCalled() + }) + }) +}) diff --git a/frontend/__tests__/unit/components/Footer.test.tsx b/frontend/__tests__/unit/components/Footer.test.tsx index 8781e84fb2..51b7ab78d9 100644 --- a/frontend/__tests__/unit/components/Footer.test.tsx +++ b/frontend/__tests__/unit/components/Footer.test.tsx @@ -4,9 +4,7 @@ */ import { render, screen, fireEvent } from '@testing-library/react' import '@testing-library/jest-dom' -import { ReactNode } from 'react' -import { footerSections, footerIcons } from 'utils/constants' -import Footer from 'components/Footer' +import React, { ReactNode } from 'react' // Define proper types for mock props interface MockLinkProps { @@ -18,11 +16,6 @@ interface MockLinkProps { 'aria-label'?: string } -interface MockFontAwesomeIconProps { - icon: unknown - className?: string -} - interface MockButtonProps { children: ReactNode onPress?: () => void @@ -42,14 +35,6 @@ jest.mock('next/link', () => { } }) -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: ({ icon, className }: MockFontAwesomeIconProps) => ( - - {typeof icon === 'string' ? icon : JSON.stringify(icon)} - - ), -})) - jest.mock('@heroui/button', () => ({ Button: ({ children, onPress, className, disableAnimation, ...props }: MockButtonProps) => ( - ), -})) - -describe('ModuleList', () => { - describe('Empty and Null Cases', () => { - it('returns null when modules array is empty', () => { - const { container } = render() - expect(container.firstChild).toBeNull() - }) - - it('returns null when modules is undefined', () => { - const { container } = render() - expect(container.firstChild).toBeNull() - }) - - it('returns null when modules is null', () => { - const { container } = render() - expect(container.firstChild).toBeNull() - }) - }) - - describe('Rendering with Different Module Counts', () => { - it('renders all modules when count is less than 5', () => { - const modules = ['Module 1', 'Module 2', 'Module 3'] - render() - - expect(screen.getByText('Module 1')).toBeInTheDocument() - expect(screen.getByText('Module 2')).toBeInTheDocument() - expect(screen.getByText('Module 3')).toBeInTheDocument() - - // Should not show "Show more" button - expect(screen.queryByText('Show more')).not.toBeInTheDocument() - }) - - it('renders all modules when count is exactly 5', () => { - const modules = ['Module 1', 'Module 2', 'Module 3', 'Module 4', 'Module 5'] - render() - - for (const myModule of modules) { - expect(screen.getByText(myModule)).toBeInTheDocument() - } - - // Should not show "Show more" button - expect(screen.queryByText('Show more')).not.toBeInTheDocument() - }) - - it('renders only first 5 modules when count is greater than 5', () => { - const modules = [ - 'Module 1', - 'Module 2', - 'Module 3', - 'Module 4', - 'Module 5', - 'Module 6', - 'Module 7', - ] - render() - - // First 5 should be visible - expect(screen.getByText('Module 1')).toBeInTheDocument() - expect(screen.getByText('Module 2')).toBeInTheDocument() - expect(screen.getByText('Module 3')).toBeInTheDocument() - expect(screen.getByText('Module 4')).toBeInTheDocument() - expect(screen.getByText('Module 5')).toBeInTheDocument() - - // Last 2 should not be visible initially - expect(screen.queryByText('Module 6')).not.toBeInTheDocument() - expect(screen.queryByText('Module 7')).not.toBeInTheDocument() - - // Should show "Show more" button - expect(screen.getByText('Show more')).toBeInTheDocument() - }) - }) - - describe('Show More/Less Functionality', () => { - const manyModules = Array.from({ length: 8 }, (_, i) => `Module ${i + 1}`) - - it('shows "Show more" button with correct aria-label initially', () => { - render() - - const button = screen.getByRole('button', { name: 'Show more modules' }) - expect(button).toBeInTheDocument() - expect(screen.getByText('Show more')).toBeInTheDocument() - expect(screen.getByTestId('icon-chevron-down')).toBeInTheDocument() - }) - - it('expands to show all modules when "Show more" is clicked', () => { - render() - - // Initially only first 5 are visible - expect(screen.getByText('Module 1')).toBeInTheDocument() - expect(screen.getByText('Module 5')).toBeInTheDocument() - expect(screen.queryByText('Module 6')).not.toBeInTheDocument() - - // Click "Show more" - const showMoreButton = screen.getByText('Show more') - fireEvent.click(showMoreButton) - - // Now all modules should be visible - expect(screen.getByText('Module 6')).toBeInTheDocument() - expect(screen.getByText('Module 7')).toBeInTheDocument() - expect(screen.getByText('Module 8')).toBeInTheDocument() - }) - - it('changes to "Show less" button after expanding', () => { - render() - - const showMoreButton = screen.getByText('Show more') - fireEvent.click(showMoreButton) - - // Button should change to "Show less" - expect(screen.getByText('Show less')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Show fewer modules' })).toBeInTheDocument() - expect(screen.getByTestId('icon-chevron-up')).toBeInTheDocument() - expect(screen.queryByText('Show more')).not.toBeInTheDocument() - }) - - it('collapses back to 5 modules when "Show less" is clicked', () => { - render() - - // Expand first - const showMoreButton = screen.getByText('Show more') - fireEvent.click(showMoreButton) - - // Verify all are visible - expect(screen.getByText('Module 8')).toBeInTheDocument() - - // Click "Show less" - const showLessButton = screen.getByText('Show less') - fireEvent.click(showLessButton) - - // Should be back to first 5 only - expect(screen.getByText('Module 5')).toBeInTheDocument() - expect(screen.queryByText('Module 6')).not.toBeInTheDocument() - expect(screen.queryByText('Module 8')).not.toBeInTheDocument() - - // Button should be back to "Show more" - expect(screen.getByText('Show more')).toBeInTheDocument() - }) - }) - - describe('Module Text Truncation', () => { - it('truncates module names longer than 50 characters', () => { - const longModuleName = - 'This is a very long module name that exceeds fifty characters and should be truncated' - const modules = [longModuleName] - - render() - - const truncatedButton = screen.getByRole('button', { - name: `Module: ${longModuleName}`, - }) - expect(truncatedButton).toHaveAttribute('title', longModuleName) - }) - - it('does not truncate module names 50 characters or shorter', () => { - const exactlyFiftyChars = 'A'.repeat(50) - const modules = [exactlyFiftyChars, 'Short'] - - render() - - expect(screen.getByText(exactlyFiftyChars)).toBeInTheDocument() - expect(screen.getByText('Short')).toBeInTheDocument() - }) - - it('adds title attribute for truncated modules', () => { - const longModuleName = - 'This is a very long module name that exceeds fifty characters and should be truncated' - const modules = [longModuleName] - - render() - - const truncatedButton = screen.getByRole('button', { - name: /This is a very long module name that exceeds/, - }) - expect(truncatedButton).toHaveAttribute('title', longModuleName) - }) - - it('does not add title attribute for non-truncated modules', () => { - const shortModuleName = 'Short Module' - const modules = [shortModuleName] - - render() - - const button = screen.getByRole('button', { name: `Module: ${shortModuleName}` }) - expect(button).not.toHaveAttribute('title') - }) - }) - - describe('Module Button Properties', () => { - it('renders module buttons with correct classes', () => { - const modules = ['Test Module'] - render() - - const button = screen.getByRole('button', { name: 'Module: Test Module' }) - expect(button).toHaveClass( - 'rounded-lg', - 'border', - 'border-gray-400', - 'px-3', - 'py-1', - 'text-sm', - 'transition-all', - 'duration-200', - 'ease-in-out', - 'hover:scale-105', - 'hover:bg-gray-200', - 'dark:border-gray-300', - 'dark:hover:bg-gray-700' - ) - }) - - it('sets correct button type', () => { - const modules = ['Test Module'] - render() - - const button = screen.getByRole('button', { name: 'Module: Test Module' }) - expect(button).toHaveAttribute('type', 'button') - }) - - it('generates unique keys for modules with same name', () => { - const modules = ['Same Name', 'Same Name', 'Different Name'] - render() - - const sameNameButtons = screen.getAllByText('Same Name') - expect(sameNameButtons).toHaveLength(2) - expect(screen.getByText('Different Name')).toBeInTheDocument() - }) - }) - - describe('Container Structure', () => { - it('renders with correct container classes', () => { - const modules = ['Module 1'] - const { container } = render() - - const outerDiv = container.firstChild as HTMLElement - expect(outerDiv).toHaveClass('mt-3') - - const innerDiv = outerDiv.firstChild as HTMLElement - expect(innerDiv).toHaveClass('flex', 'flex-wrap', 'items-center', 'gap-2') - }) - }) - - describe('Edge Cases', () => { - it('handles modules with empty strings', () => { - const modules = ['', 'Valid Module', ''] - render() - - const buttons = screen.getAllByRole('button') - // Should render 3 buttons (including empty string ones) - expect(buttons).toHaveLength(3) - expect(screen.getByText('Valid Module')).toBeInTheDocument() - }) - - it('handles exactly 6 modules (edge case for show more)', () => { - const modules = Array.from({ length: 6 }, (_, i) => `Module ${i + 1}`) - render() - - // First 5 should be visible - expect(screen.getByText('Module 5')).toBeInTheDocument() - expect(screen.queryByText('Module 6')).not.toBeInTheDocument() - - // Should show "Show more" button - expect(screen.getByText('Show more')).toBeInTheDocument() - - // Click to expand - fireEvent.click(screen.getByText('Show more')) - expect(screen.getByText('Module 6')).toBeInTheDocument() - }) - }) -}) diff --git a/frontend/__tests__/unit/components/MultiSearch.test.tsx b/frontend/__tests__/unit/components/MultiSearch.test.tsx index 1eebb354b8..7d268cd19b 100644 --- a/frontend/__tests__/unit/components/MultiSearch.test.tsx +++ b/frontend/__tests__/unit/components/MultiSearch.test.tsx @@ -2,6 +2,7 @@ import { sendGAEvent } from '@next/third-parties/google' import { screen, render, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useRouter } from 'next/navigation' +import React from 'react' import { fetchAlgoliaData } from 'server/fetchAlgoliaData' import { Chapter } from 'types/chapter' import { Event } from 'types/event' @@ -30,10 +31,32 @@ jest.mock('lodash', () => ({ }), })) -// Mock FontAwesome -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: ({ icon, className }: { icon: { iconName: string }; className?: string }) => ( -
+jest.mock('react-icons/fa', () => ({ + FaSearch: (props: React.SVGProps) => ( + + ), + FaTimes: (props: React.SVGProps) => , +})) + +jest.mock('react-icons/fa6', () => ({ + FaUser: (props: React.SVGProps) => , + FaCalendar: (props: React.SVGProps) => ( + + ), + FaFolder: (props: React.SVGProps) => ( + + ), + FaBuilding: (props: React.SVGProps) => ( + + ), + FaLocationDot: (props: React.SVGProps) => ( + + ), +})) + +jest.mock('react-icons/si', () => ({ + SiAlgolia: (props: React.SVGProps) => ( + ), })) @@ -175,7 +198,7 @@ describe('Rendering', () => { render() expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument() - expect(screen.getByTestId('font-awesome-icon')).toBeInTheDocument() + expect(screen.getByTestId('fa-search-icon')).toBeInTheDocument() }) it('renders loading state when not loaded', () => { @@ -239,7 +262,7 @@ describe('Rendering', () => { render() const input = screen.getByPlaceholderText('Search...') await user.type(input, 'test') - expect(screen.getByRole('button')).toBeInTheDocument() + expect(screen.getByTestId('fa-times-icon')).toBeInTheDocument() }) it('clears search when clear button is clicked', async () => { @@ -248,7 +271,7 @@ describe('Rendering', () => { render() const input = screen.getByPlaceholderText('Search...') await user.type(input, 'test') - const clearButton = screen.getByRole('button') + const clearButton = screen.getByLabelText('Clear search') await user.click(clearButton) expect(input).toHaveValue('') }) @@ -283,7 +306,7 @@ describe('Rendering', () => { await waitFor(() => { expect(mockSendGAEvent).toHaveBeenCalledWith({ event: 'homepageSearch', - path: expect.any(String), // Don't rely on specific window.location.pathname + path: globalThis.location.pathname, value: 'test query', }) }) @@ -312,7 +335,6 @@ describe('Rendering', () => { const input = screen.getByPlaceholderText('Search...') - // Test filtering for "JavaScript" await user.type(input, 'JavaScript') await waitFor(() => { @@ -321,7 +343,6 @@ describe('Rendering', () => { expect(screen.queryByText('React Meetup')).not.toBeInTheDocument() }) - // Clear and test different filter await user.clear(input) await user.type(input, 'Python') @@ -369,8 +390,21 @@ describe('Rendering', () => { await user.type(input, 'test') await waitFor(() => { - const icons = screen.getAllByTestId('font-awesome-icon') - expect(icons.length).toBeGreaterThan(1) + expect(screen.getByTestId('fa-location-dot-icon')).toBeInTheDocument() // chapters + expect(screen.getByTestId('fa-user-icon')).toBeInTheDocument() // users + expect(screen.getByTestId('fa-folder-icon')).toBeInTheDocument() // projects + }) + }) + + it('shows Algolia branding icon', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText('Search...') + await user.type(input, 'test') + + await waitFor(() => { + expect(screen.getByTestId('si-algolia-icon')).toBeInTheDocument() }) }) @@ -529,7 +563,6 @@ describe('Rendering', () => { }) const user = userEvent.setup() - // Changed 'organization' to 'organizations' (plural) render() const input = screen.getByPlaceholderText('Search...') @@ -703,7 +736,7 @@ describe('Rendering', () => { const input = screen.getByPlaceholderText('Search...') await user.type(input, 'test') - const clearButton = screen.getByRole('button') + const clearButton = screen.getByLabelText('Clear search') expect(clearButton).toBeInTheDocument() await user.click(clearButton) @@ -745,7 +778,7 @@ describe('Rendering', () => { const input = screen.getByPlaceholderText('Search...') await user.type(input, 'test') - const clearButton = screen.getByRole('button') + const clearButton = screen.getByLabelText('Clear search') await user.click(clearButton) expect(input).toHaveValue('') diff --git a/frontend/__tests__/unit/components/NavButton.test.tsx b/frontend/__tests__/unit/components/NavButton.test.tsx index 5db9169d48..5cd8a9afc2 100644 --- a/frontend/__tests__/unit/components/NavButton.test.tsx +++ b/frontend/__tests__/unit/components/NavButton.test.tsx @@ -2,17 +2,44 @@ * @file Complete unit tests for the NavButton component. * @see {@link AutoScrollToTop.test.tsx} for structural reference. */ -import { faHome, faUser } from '@fortawesome/free-solid-svg-icons' import { render, screen, fireEvent } from '@testing-library/react' +import React from 'react' import '@testing-library/jest-dom' import type { ComponentPropsWithoutRef } from 'react' +import { FaHome } from 'react-icons/fa' +import { FaUser } from 'react-icons/fa6' import type { NavButtonProps } from 'types/button' import NavButton from 'components/NavButton' -// The NavButton component uses next/link internally. We mock it to isolate -// the NavButton's behavior and prevent actual navigation during tests. +jest.mock('react-icons/fa', () => ({ + FaHome: (props: React.SVGProps) => ( + + ), +})) + +jest.mock('react-icons/fa6', () => ({ + FaUser: (props: React.SVGProps) => ( + + ), +})) + +// Mock IconWrapper to pass through the icon component +jest.mock('wrappers/IconWrapper', () => ({ + IconWrapper: ({ + icon: IconComponent, + className, + style, + }: { + icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }> + className?: string + style?: React.CSSProperties + }) => { + return IconComponent ? : null + }, +})) + +// Mock Next.js Link jest.mock('next/link', () => { - // Mock implementation returns a simple anchor tag with the passed props. return function MockLink({ children, href, ...props }: ComponentPropsWithoutRef<'a'>) { return ( @@ -23,16 +50,15 @@ jest.mock('next/link', () => { }) describe('', () => { - // Define default props to reduce repetition in tests. - const defaultProps: NavButtonProps = { + const defaultProps: NavButtonProps & { defaultIcon: typeof FaHome; hoverIcon: typeof FaUser } = { href: '/test-path', - defaultIcon: faHome, - hoverIcon: faUser, + defaultIcon: FaHome, + hoverIcon: FaUser, text: 'Test Button', } // Helper function to render the component with optional prop overrides. - const renderNavButton = (props: Partial = {}) => { + const renderNavButton = (props: Partial = {}) => { return render() } @@ -50,13 +76,10 @@ describe('', () => { expect(screen.getByText(customText)).toBeInTheDocument() }) - it('should render the default FontAwesome icon', () => { + it('should render the default react-icons icon', () => { renderNavButton() - // FontAwesome icons are rendered as SVGs. We check for their presence. - const icon = screen.getByRole('img', { hidden: true }) + const icon = screen.getByTestId('fa-home-icon') expect(icon).toBeInTheDocument() - // Check for a class associated with the default icon (faHome). - expect(icon.parentElement?.querySelector('.fa-house')).toBeInTheDocument() }) it('should render text content inside a span element', () => { @@ -87,11 +110,12 @@ describe('', () => { hoverIconColor: 'rgb(0, 255, 0)', // green }) - const icon = screen.getByRole('img', { hidden: true }) + const icon = screen.getByTestId('fa-home-icon') expect(icon).toHaveStyle({ color: 'rgb(255, 0, 0)' }) fireEvent.mouseEnter(screen.getByRole('link')) - expect(icon).toHaveStyle({ color: 'rgb(0, 255, 0)' }) + const hoverIcon = screen.getByTestId('fa-user-icon') + expect(hoverIcon).toHaveStyle({ color: 'rgb(0, 255, 0)' }) }) it('should apply custom className while preserving default classes', () => { @@ -107,64 +131,60 @@ describe('', () => { it('should switch from default icon to hover icon on mouse enter', () => { renderNavButton() const link = screen.getByRole('link') - const iconContainer = link.querySelector('svg')?.parentElement - // Initially, the default icon (fa-house) should be present. - expect(iconContainer?.querySelector('.fa-house')).toBeInTheDocument() - expect(iconContainer?.querySelector('.fa-user')).not.toBeInTheDocument() + expect(screen.getByTestId('fa-home-icon')).toBeInTheDocument() + expect(screen.queryByTestId('fa-user-icon')).not.toBeInTheDocument() - // On hover, the hover icon (fa-user) should be present. fireEvent.mouseEnter(link) - expect(iconContainer?.querySelector('.fa-user')).toBeInTheDocument() - expect(iconContainer?.querySelector('.fa-house')).not.toBeInTheDocument() + expect(screen.getByTestId('fa-user-icon')).toBeInTheDocument() + expect(screen.queryByTestId('fa-home-icon')).not.toBeInTheDocument() }) it('should revert to the default icon on mouse leave', () => { renderNavButton() const link = screen.getByRole('link') - const iconContainer = link.querySelector('svg')?.parentElement // Hover to change state fireEvent.mouseEnter(link) - expect(iconContainer?.querySelector('.fa-user')).toBeInTheDocument() + expect(screen.getByTestId('fa-user-icon')).toBeInTheDocument() // Leave to revert state fireEvent.mouseLeave(link) - expect(iconContainer?.querySelector('.fa-house')).toBeInTheDocument() - expect(iconContainer?.querySelector('.fa-user')).not.toBeInTheDocument() + expect(screen.getByTestId('fa-home-icon')).toBeInTheDocument() + expect(screen.queryByTestId('fa-user-icon')).not.toBeInTheDocument() }) it('should apply scaling transform and yellow color on hover', () => { renderNavButton() const link = screen.getByRole('link') - const icon = screen.getByRole('img', { hidden: true }) - // The icon should not be scaled initially - expect(icon).not.toHaveClass('scale-110') - expect(icon).not.toHaveClass('text-yellow-400') + const defaultIcon = screen.getByTestId('fa-home-icon') + expect(defaultIcon).not.toHaveClass('scale-110') + expect(defaultIcon).not.toHaveClass('text-yellow-400') - // On hover, it should scale up and turn yellow fireEvent.mouseEnter(link) - expect(icon).toHaveClass('scale-110') - expect(icon).toHaveClass('text-yellow-400') + const hoverIcon = screen.getByTestId('fa-user-icon') + expect(hoverIcon).toHaveClass('scale-110') + expect(hoverIcon).toHaveClass('text-yellow-400') - // On leave, it should scale back down and remove yellow + // On leave, should revert to default icon without hover classes fireEvent.mouseLeave(link) - expect(icon).not.toHaveClass('scale-110') - expect(icon).not.toHaveClass('text-yellow-400') + const revertedIcon = screen.getByTestId('fa-home-icon') + expect(revertedIcon).not.toHaveClass('scale-110') + expect(revertedIcon).not.toHaveClass('text-yellow-400') }) it('should maintain hover state when mouse moves within the component', () => { renderNavButton() const link = screen.getByRole('link') - const icon = screen.getByRole('img', { hidden: true }) fireEvent.mouseEnter(link) - expect(icon).toHaveClass('scale-110') + const hoverIcon = screen.getByTestId('fa-user-icon') + expect(hoverIcon).toHaveClass('scale-110') // Moving mouse within component should maintain hover state fireEvent.mouseMove(link) - expect(icon).toHaveClass('scale-110') + expect(screen.getByTestId('fa-user-icon')).toHaveClass('scale-110') }) }) @@ -246,7 +266,7 @@ describe('', () => { describe('Default Values and Fallbacks', () => { it('should render without crashing when optional color props are not provided', () => { expect(() => { - render() + render() }).not.toThrow() }) @@ -258,7 +278,7 @@ describe('', () => { }) it('should render without custom className', () => { - render() + render() const link = screen.getByRole('link') expect(link).toBeInTheDocument() expect(link).toHaveClass('group') // Should still have default classes @@ -328,12 +348,12 @@ describe('', () => { }) describe('Component Composition & Structure', () => { - it('should render Link wrapper with FontAwesome icon and span inside', () => { + it('should render Link wrapper with react-icons icon and span inside', () => { renderNavButton() const link = screen.getByRole('link') // Should contain both icon and text span - expect(link.querySelector('svg')).toBeInTheDocument() // FontAwesome renders as SVG + expect(screen.getByTestId('fa-home-icon')).toBeInTheDocument() // React-icons renders as SVG expect(link.querySelector('span')).toBeInTheDocument() }) @@ -342,9 +362,9 @@ describe('', () => { const link = screen.getByRole('link') const children = link.children - // Should have 2 children: FontAwesome icon and span + // Should have 2 children: react-icons icon and span expect(children).toHaveLength(2) - expect(children[0].tagName).toBe('svg') // FontAwesome icon + expect(children[0].tagName.toUpperCase()).toBe('SVG') // React-icons icon expect(children[1].tagName).toBe('SPAN') // Text span }) }) @@ -358,7 +378,6 @@ describe('', () => { it('should handle rapid hover state changes', () => { renderNavButton() const link = screen.getByRole('link') - const icon = screen.getByRole('img', { hidden: true }) // Rapid mouse enter/leave should work correctly fireEvent.mouseEnter(link) @@ -367,8 +386,9 @@ describe('', () => { fireEvent.mouseLeave(link) // Should end up in non-hovered state - expect(icon).not.toHaveClass('scale-110') - expect(icon).not.toHaveClass('text-yellow-400') + const defaultIcon = screen.getByTestId('fa-home-icon') + expect(defaultIcon).not.toHaveClass('scale-110') + expect(defaultIcon).not.toHaveClass('text-yellow-400') }) }) }) diff --git a/frontend/__tests__/unit/components/NavDropDown.test.tsx b/frontend/__tests__/unit/components/NavDropDown.test.tsx index cca78a6c92..da02f417f2 100644 --- a/frontend/__tests__/unit/components/NavDropDown.test.tsx +++ b/frontend/__tests__/unit/components/NavDropDown.test.tsx @@ -1,5 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import React from 'react' import '@testing-library/jest-dom' import type { Link as LinkType } from 'types/link' import NavDropdown from 'components/NavDropDown' @@ -22,9 +23,10 @@ jest.mock('next/link', () => { } }) -// Mock FontAwesome icons -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: ({ className }) => , +jest.mock('react-icons/fa', () => ({ + FaChevronDown: (props: React.SVGProps) => ( + + ), })) // Mock utility function @@ -537,7 +539,13 @@ describe('NavDropdown Component', () => { render() const button = screen.getByRole('button') - expect(button).toHaveClass('flex', 'items-center', 'gap-2', 'whitespace-nowrap') + expect(button).toHaveClass( + 'flex', + 'items-center', + 'gap-2', + 'whitespace-nowrap', + 'cursor-pointer' + ) }) it('applies correct classes to dropdown menu when open', async () => { diff --git a/frontend/__tests__/unit/components/PageLayout.test.tsx b/frontend/__tests__/unit/components/PageLayout.test.tsx new file mode 100644 index 0000000000..5a12bc6400 --- /dev/null +++ b/frontend/__tests__/unit/components/PageLayout.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { BreadcrumbRoot } from 'contexts/BreadcrumbContext' +import { usePathname } from 'next/navigation' +import React from 'react' +import BreadCrumbsWrapper from 'components/BreadCrumbsWrapper' +import PageLayout from 'components/PageLayout' + +jest.mock('next/navigation', () => ({ + usePathname: jest.fn(), +})) + +// Helper to wrap components with BreadcrumbRoot and render with BreadCrumbsWrapper +const renderWithProvider = (ui: React.ReactElement) => { + return render( + + + {ui} + + ) +} + +describe('PageLayout', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + test('renders children components', () => { + ;(usePathname as jest.Mock).mockReturnValue('/projects/zap') + + renderWithProvider( + +
Child Content
+
+ ) + + expect(screen.getByText('Child Content')).toBeInTheDocument() + }) + + describe('Title Prop Handling', () => { + test('displays title in breadcrumbs', async () => { + ;(usePathname as jest.Mock).mockReturnValue('/projects/zap') + + renderWithProvider( + +
Content
+
+ ) + + // Wait for effect to run + await screen.findByText('OWASP ZAP') + expect(screen.getByText('OWASP ZAP')).toBeInTheDocument() + }) + + test('handles explicit path prop', async () => { + ;(usePathname as jest.Mock).mockReturnValue('/projects/zap/details') + + renderWithProvider( + +
Content
+
+ ) + + await screen.findByText('OWASP ZAP') + expect(screen.getByText('OWASP ZAP')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/__tests__/unit/components/Pagination.test.tsx b/frontend/__tests__/unit/components/Pagination.test.tsx index 19da1b16ce..16ee964e30 100644 --- a/frontend/__tests__/unit/components/Pagination.test.tsx +++ b/frontend/__tests__/unit/components/Pagination.test.tsx @@ -86,8 +86,10 @@ describe('', () => { expect(screen.getByRole('button', { name: `Go to page ${n}` })).toBeInTheDocument() } - // Should show exactly two "More pages" indicators - const ellipses = screen.getAllByLabelText('More pages') + const ellipsisContainers = document.querySelectorAll('div.flex.h-10.w-10') + const ellipses = Array.from(ellipsisContainers).filter((el) => + el.querySelector('svg[aria-hidden="true"]') + ) expect(ellipses).toHaveLength(2) // Should show pages around currentPage: 9, 10, 11 diff --git a/frontend/__tests__/unit/components/ProgramActions.test.tsx b/frontend/__tests__/unit/components/ProgramActions.test.tsx deleted file mode 100644 index 043b9d401d..0000000000 --- a/frontend/__tests__/unit/components/ProgramActions.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { fireEvent, screen } from '@testing-library/react' -import '@testing-library/jest-dom' -import { useSession as mockUseSession } from 'next-auth/react' -import { render } from 'wrappers/testUtil' -import { ProgramStatusEnum } from 'types/__generated__/graphql' -import ProgramActions from 'components/ProgramActions' - -const mockPush = jest.fn() -jest.mock('next/navigation', () => ({ - ...jest.requireActual('next/navigation'), - useRouter: () => ({ push: mockPush }), -})) - -jest.mock('next-auth/react', () => { - const actual = jest.requireActual('next-auth/react') - return { - ...actual, - useSession: jest.fn(), - } -}) - -describe('ProgramActions', () => { - let setStatus: jest.Mock - beforeEach(() => { - setStatus = jest.fn() - mockPush.mockClear() - }) - - beforeAll(async () => { - ;(mockUseSession as jest.Mock).mockReturnValue({ - data: { - user: { - name: 'Test User', - email: 'test@example.com', - login: 'testuser', - isLeader: true, - }, - expires: '2099-01-01T00:00:00.000Z', - }, - status: 'authenticated', - loading: false, - }) - }) - - test('renders and toggles dropdown', () => { - render() - const button = screen.getByTestId('program-actions-button') - fireEvent.click(button) - expect(screen.getByText('Add Module')).toBeInTheDocument() - expect(screen.getByText('Publish Program')).toBeInTheDocument() - fireEvent.click(button) - expect(screen.queryByText('Add Module')).not.toBeInTheDocument() - }) - - test('handles Add Module action', () => { - render() - const button = screen.getByTestId('program-actions-button') - fireEvent.click(button) - fireEvent.click(screen.getByRole('menuitem', { name: /add module/i })) - expect(mockPush).toHaveBeenCalled() - expect(setStatus).not.toHaveBeenCalled() - }) - - test('handles Publish action', () => { - render() - const button = screen.getByTestId('program-actions-button') - fireEvent.click(button) - fireEvent.click(screen.getByRole('menuitem', { name: /publish program/i })) - expect(setStatus).toHaveBeenCalledWith(ProgramStatusEnum.Published) - expect(mockPush).not.toHaveBeenCalled() - }) - - test('handles Move to Draft action', () => { - render() - const button = screen.getByTestId('program-actions-button') - fireEvent.click(button) - fireEvent.click(screen.getByRole('menuitem', { name: /move to draft/i })) - expect(setStatus).toHaveBeenCalledWith(ProgramStatusEnum.Draft) - }) - - test('handles Mark as Completed action', () => { - render() - const button = screen.getByTestId('program-actions-button') - fireEvent.click(button) - fireEvent.click(screen.getByRole('menuitem', { name: /mark as completed/i })) - expect(setStatus).toHaveBeenCalledWith(ProgramStatusEnum.Completed) - }) - - test('dropdown closes on outside click', () => { - render( -
- - -
- ) - const button = screen.getByTestId('program-actions-button') - fireEvent.click(button) - expect(screen.getByText('Add Module')).toBeInTheDocument() - fireEvent.mouseDown(screen.getByTestId('outside')) - expect(screen.queryByText('Add Module')).not.toBeInTheDocument() - }) -}) diff --git a/frontend/__tests__/unit/components/ProgramCard.test.tsx b/frontend/__tests__/unit/components/ProgramCard.test.tsx index 288f0074ef..2218f4543f 100644 --- a/frontend/__tests__/unit/components/ProgramCard.test.tsx +++ b/frontend/__tests__/unit/components/ProgramCard.test.tsx @@ -1,28 +1,34 @@ -import { faEye } from '@fortawesome/free-regular-svg-icons' -import { faEdit } from '@fortawesome/free-solid-svg-icons' -import { fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { useRouter } from 'next/navigation' import React from 'react' import { ProgramStatusEnum } from 'types/__generated__/graphql' import type { Program } from 'types/mentorship' import ProgramCard from 'components/ProgramCard' -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: ({ icon, className }: { icon: unknown; className?: string }) => { - let iconName = 'unknown' - - if (icon === faEye) { - iconName = 'eye' - } else if (icon === faEdit) { - iconName = 'edit' - } +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), +})) - return - }, +jest.mock('react-icons/fa6', () => ({ + FaEye: (props: React.SVGProps) => ( + + ), + FaPencilAlt: (props: React.SVGProps) => ( + + ), })) -jest.mock('hooks/useUpdateProgramStatus', () => ({ - useUpdateProgramStatus: () => ({ updateProgramStatus: jest.fn() }), +// Mock IconWrapper to handle react-icons properly +jest.mock('wrappers/IconWrapper', () => ({ + IconWrapper: ({ + icon: IconComponent, + className, + }: { + icon: React.ComponentType<{ className?: string }> + className?: string + }) => { + return IconComponent ? : null + }, })) jest.mock('hooks/useUpdateProgramStatus', () => ({ @@ -46,8 +52,28 @@ jest.mock('@heroui/tooltip', () => ({ ), })) +jest.mock('components/EntityActions', () => jest.requireActual('components/EntityActions')) + +jest.mock('next/link', () => { + return ({ children, href }: { children: React.ReactNode; href: string }) => { + return
{children} + } +}) + describe('ProgramCard', () => { - const mockOnView = jest.fn() + const mockPush = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + ;(useRouter as jest.Mock).mockReturnValue({ + push: mockPush, + back: jest.fn(), + forward: jest.fn(), + refresh: jest.fn(), + replace: jest.fn(), + prefetch: jest.fn(), + }) + }) const baseMockProgram: Program = { id: '1', @@ -56,21 +82,17 @@ describe('ProgramCard', () => { description: 'This is a test program description', status: ProgramStatusEnum.Published, startedAt: '2024-01-01T00:00:00Z', - endedAt: '2024-12-31T23:59:59Z', + endedAt: '2024-12-31T12:00:00Z', userRole: 'admin', } - beforeEach(() => { - jest.clearAllMocks() - }) - describe('Basic Rendering', () => { it('renders program name correctly', () => { render( ) @@ -83,7 +105,7 @@ describe('ProgramCard', () => { ) @@ -97,7 +119,7 @@ describe('ProgramCard', () => { render( @@ -106,38 +128,45 @@ describe('ProgramCard', () => { expect(screen.getByText('admin')).toBeInTheDocument() }) - it('calls onView when Preview button is clicked', () => { - render( + it('renders Link with correct href', () => { + const { container } = render( ) - const previewButton = screen.getByText('Preview').closest('button') - fireEvent.click(previewButton!) - - expect(mockOnView).toHaveBeenCalledWith('test-program') + const link = container.querySelector('a[href="/my/mentorship/programs/test-program"]') + expect(link).toBeInTheDocument() }) - it('navigates to edit page when Edit Program is clicked', () => { - const router = useRouter() - + it('navigates to edit page when Edit is clicked', async () => { render( ) - fireEvent.click(screen.getByTestId('program-actions-button')) - fireEvent.click(screen.getByText('Edit Program')) + const actionsButton = screen.getByTestId('program-actions-button') + + await act(async () => { + fireEvent.click(actionsButton) + }) + + const editButton = await waitFor(() => { + return screen.getByText('Edit') + }) - expect(router.push).toHaveBeenCalledWith('/my/mentorship/programs/test-program/edit') + await act(async () => { + fireEvent.click(editButton) + }) + + expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/test-program/edit') }) }) @@ -147,7 +176,7 @@ describe('ProgramCard', () => { ) @@ -155,35 +184,35 @@ describe('ProgramCard', () => { expect(screen.queryByText('admin')).not.toBeInTheDocument() }) - it('shows only View Details button for user access', () => { + it('shows clickable card for user access', () => { render( ) - expect(screen.getByText('View Details')).toBeInTheDocument() + const link = document.querySelector('a[href="/test/path"]') + expect(link).toBeInTheDocument() expect(screen.queryByText('Preview')).not.toBeInTheDocument() expect(screen.queryByText('Edit')).not.toBeInTheDocument() + expect(screen.queryByText('View Details')).not.toBeInTheDocument() }) - it('calls onView when View Details button is clicked', () => { - render( + it('renders Link with correct href', () => { + const { container } = render( ) - const viewButton = screen.getByText('View Details').closest('button') - fireEvent.click(viewButton!) - - expect(mockOnView).toHaveBeenCalledWith('test-program') + const link = container.querySelector('a[href="/mentorship/programs/test-program"]') + expect(link).toBeInTheDocument() }) }) @@ -191,12 +220,7 @@ describe('ProgramCard', () => { it('applies admin role styling', () => { const adminProgram = { ...baseMockProgram, userRole: 'admin' } render( - + ) const badge = screen.getByText('admin') @@ -206,12 +230,7 @@ describe('ProgramCard', () => { it('applies mentor role styling', () => { const mentorProgram = { ...baseMockProgram, userRole: 'mentor' } render( - + ) const badge = screen.getByText('mentor') @@ -224,7 +243,7 @@ describe('ProgramCard', () => { ) @@ -236,12 +255,7 @@ describe('ProgramCard', () => { it('applies default styling when userRole is undefined', () => { const noRoleProgram = { ...baseMockProgram, userRole: undefined } render( - + ) // Should not render badge when userRole is undefined @@ -250,7 +264,7 @@ describe('ProgramCard', () => { }) describe('Description Handling', () => { - it('renders long descriptions with line-clamp-6 CSS class', () => { + it('renders long descriptions with line-clamp-8 CSS class', () => { const longDescription = 'A'.repeat(300) // Long enough to trigger line clamping const longDescProgram = { ...baseMockProgram, description: longDescription } @@ -258,15 +272,14 @@ describe('ProgramCard', () => { ) expect(screen.getByText(longDescription)).toBeInTheDocument() - expect(screen.getByText(longDescription)).toBeInTheDocument() - const descriptionElement = screen.getByText(longDescription) - expect(descriptionElement).toHaveClass('line-clamp-6') + const descriptionElement = screen.getByText(longDescription).closest('.md-wrapper') + expect(descriptionElement).toHaveClass('line-clamp-8') }) it('shows full description when short', () => { @@ -277,15 +290,15 @@ describe('ProgramCard', () => { ) expect(screen.getByText('Short description')).toBeInTheDocument() - const descriptionElement = screen.getByText('Short description') - expect(descriptionElement).toHaveClass('line-clamp-6') + const descriptionElement = screen.getByText('Short description').closest('.md-wrapper') + expect(descriptionElement).toHaveClass('line-clamp-8') }) it('shows fallback text when description is empty', () => { @@ -295,7 +308,7 @@ describe('ProgramCard', () => { ) @@ -308,12 +321,7 @@ describe('ProgramCard', () => { const noDescProgram = { ...baseMockProgram, description: undefined as any } render( - + ) expect(screen.getByText('No description available.')).toBeInTheDocument() @@ -326,7 +334,7 @@ describe('ProgramCard', () => { ) @@ -342,7 +350,7 @@ describe('ProgramCard', () => { ) @@ -357,7 +365,7 @@ describe('ProgramCard', () => { ) @@ -372,7 +380,7 @@ describe('ProgramCard', () => { ) @@ -382,59 +390,32 @@ describe('ProgramCard', () => { }) describe('Icons', () => { - it('renders eye icon for Preview button', () => { - render( - - ) - - expect(screen.getByTestId('icon-eye')).toBeInTheDocument() - }) - it('renders actions button for admin menu', () => { render( ) expect(screen.getByTestId('program-actions-button')).toBeInTheDocument() }) - - it('renders eye icon for View Details button', () => { - render( - - ) - - expect(screen.getByTestId('icon-eye')).toBeInTheDocument() - }) }) describe('Edge Cases', () => { - it('shows Edit Program in actions menu for admin access', () => { + it('shows actions button for admin access', () => { render( ) - fireEvent.click(screen.getByTestId('program-actions-button')) - expect(screen.getByText('Edit Program')).toBeInTheDocument() + expect(screen.getByTestId('program-actions-button')).toBeInTheDocument() }) it('handles program with minimal data', () => { @@ -452,7 +433,7 @@ describe('ProgramCard', () => { ) diff --git a/frontend/__tests__/unit/components/ProjectTypeDashboardCard.test.tsx b/frontend/__tests__/unit/components/ProjectTypeDashboardCard.test.tsx index 578af52472..1f59806a0c 100644 --- a/frontend/__tests__/unit/components/ProjectTypeDashboardCard.test.tsx +++ b/frontend/__tests__/unit/components/ProjectTypeDashboardCard.test.tsx @@ -1,7 +1,9 @@ -import { faHeartPulse, faExclamationTriangle, faSkull } from '@fortawesome/free-solid-svg-icons' import { render, screen, fireEvent } from '@testing-library/react' -import '@testing-library/jest-dom' import React from 'react' +import type { IconType } from 'react-icons' +import '@testing-library/jest-dom' +import { FaExclamationTriangle } from 'react-icons/fa' +import { FaHeartPulse, FaSkull } from 'react-icons/fa6' import type { ProjectHealthType } from 'types/project' import ProjectTypeDashboardCard from 'components/ProjectTypeDashboardCard' @@ -34,16 +36,17 @@ jest.mock('components/SecondaryCard', () => { ...props }: { title: string - icon: unknown + icon: IconType className?: string children: React.ReactNode [key: string]: unknown }) { + const iconName = icon?.name ?? (icon && typeof icon) ?? '' return (
@@ -58,7 +61,7 @@ describe('ProjectTypeDashboardCard', () => { const baseProps = { type: 'healthy' as const, count: 42, - icon: faHeartPulse, + icon: FaHeartPulse, } beforeEach(() => { @@ -67,7 +70,7 @@ describe('ProjectTypeDashboardCard', () => { const expectValidTypeRendersWithoutError = (type: ProjectHealthType) => { expect(() => { - render() + render() }).not.toThrow() } @@ -102,21 +105,21 @@ describe('ProjectTypeDashboardCard', () => { describe('Conditional Rendering Logic', () => { it('renders correct title for healthy type', () => { - render() + render() expect(screen.getByTestId('card-title')).toHaveTextContent('Healthy') }) it('renders correct title for needsAttention type', () => { render( - + ) expect(screen.getByTestId('card-title')).toHaveTextContent('Need Attention') }) it('renders correct title for unhealthy type', () => { - render() + render() expect(screen.getByTestId('card-title')).toHaveTextContent('Unhealthy') }) @@ -135,13 +138,13 @@ describe('ProjectTypeDashboardCard', () => { }) it('passes different icons to SecondaryCard correctly', () => { - const { rerender } = render() + const { rerender } = render() let secondaryCard = screen.getByTestId('secondary-card') - expect(secondaryCard).toHaveAttribute('data-icon', JSON.stringify(faHeartPulse)) + expect(secondaryCard).toHaveAttribute('data-icon', 'FaHeartPulse') - rerender() + rerender() secondaryCard = screen.getByTestId('secondary-card') - expect(secondaryCard).toHaveAttribute('data-icon', JSON.stringify(faExclamationTriangle)) + expect(secondaryCard).toHaveAttribute('data-icon', 'FaExclamationTriangle') }) it('generates correct href for each type', () => { @@ -217,7 +220,7 @@ describe('ProjectTypeDashboardCard', () => { for (const type of types) { const { unmount } = render( - + ) expect(screen.getByTestId('secondary-card')).toBeInTheDocument() unmount() @@ -264,7 +267,7 @@ describe('ProjectTypeDashboardCard', () => { }) it('applies conditional CSS classes based on type prop - healthy', () => { - render() + render() const secondaryCard = screen.getByTestId('secondary-card') const className = secondaryCard.getAttribute('class') @@ -282,7 +285,7 @@ describe('ProjectTypeDashboardCard', () => { }) it('applies conditional CSS classes based on type prop - unhealthy', () => { - render() + render() const secondaryCard = screen.getByTestId('secondary-card') const className = secondaryCard.getAttribute('class') @@ -301,7 +304,7 @@ describe('ProjectTypeDashboardCard', () => { it('applies conditional CSS classes based on type prop - needsAttention', () => { render( - + ) const secondaryCard = screen.getByTestId('secondary-card') @@ -333,7 +336,7 @@ describe('ProjectTypeDashboardCard', () => { const secondaryCard = screen.getByTestId('secondary-card') expect(secondaryCard).toHaveAttribute('data-title', 'Healthy') - expect(secondaryCard).toHaveAttribute('data-icon', JSON.stringify(faHeartPulse)) + expect(secondaryCard).toHaveAttribute('data-icon', 'FaHeartPulse') }) it('renders SecondaryCard with correct content structure', () => { @@ -370,7 +373,7 @@ describe('ProjectTypeDashboardCard', () => { }) it('handles different icon types correctly', () => { - const icons = [faHeartPulse, faExclamationTriangle, faSkull] + const icons = [FaHeartPulse, FaExclamationTriangle, FaSkull] for (const icon of icons) { const { unmount } = render( @@ -399,7 +402,7 @@ describe('ProjectTypeDashboardCard', () => { const types: Array = ['healthy', 'needsAttention', 'unhealthy'] for (const [index, type] of types.entries()) { - rerender() + rerender() expect(screen.getByTestId('secondary-card')).toBeInTheDocument() } }) diff --git a/frontend/__tests__/unit/components/ProjectsDashboardDropDown.test.tsx b/frontend/__tests__/unit/components/ProjectsDashboardDropDown.test.tsx index 40a66d20d0..19a5b7cbeb 100644 --- a/frontend/__tests__/unit/components/ProjectsDashboardDropDown.test.tsx +++ b/frontend/__tests__/unit/components/ProjectsDashboardDropDown.test.tsx @@ -1,17 +1,32 @@ -import { faFilter, faSort } from '@fortawesome/free-solid-svg-icons' import { render, screen, fireEvent } from '@testing-library/react' import React from 'react' +import { FaFilter, FaSort } from 'react-icons/fa6' import ProjectsDashboardDropDown from 'components/ProjectsDashboardDropDown' -// Mock FontAwesome components -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: ({ - icon, - ...props - }: { - icon?: { iconName?: string } - [key: string]: unknown - }) => , +jest.mock('react-icons/fa6', () => ({ + FaArrowDownWideShort: (props: React.SVGProps) => ( + + ), + FaArrowUpShortWide: (props: React.SVGProps) => ( + + ), + FaFilter: (props: React.SVGProps) => ( + + ), + FaSort: (props: React.SVGProps) => ( + + ), +})) + +// Mock IconWrapper to handle react-icons properly +jest.mock('wrappers/IconWrapper', () => ({ + IconWrapper: ({ icon: IconComponent }: { icon?: React.ComponentType }) => { + return IconComponent ? ( + + ) : ( + + ) + }, })) // Mock HeroUI components @@ -60,20 +75,14 @@ jest.mock('@heroui/react', () => ({
), DropdownSection: ({ children, title }: { children: React.ReactNode; title: string }) => ( -
-
+
+ {title} -
+ {children} -
+ ), DropdownItem: (props: { children: React.ReactNode }) => { - // Since key is not accessible in props, we'll use the children text as identifier const itemText = typeof props.children === 'string' ? props.children : 'item' return ( @@ -115,7 +124,7 @@ describe('ProjectsDashboardDropDown Component', () => { buttonDisplayName: 'Filter', onAction: jest.fn(), selectedKeys: ['Active'], - selectedLabels: ['Selected Item'], // Changed from 'Active' to avoid conflicts + selectedLabels: ['Selected Item'], selectionMode: 'single' as 'single' | 'multiple', sections: [ { @@ -133,7 +142,7 @@ describe('ProjectsDashboardDropDown Component', () => { ], }, ], - icon: faFilter, + icon: FaFilter, isOrdering: false, } @@ -143,7 +152,6 @@ describe('ProjectsDashboardDropDown Component', () => { mockOnAction = jest.fn() jest.clearAllMocks() - // Suppress React warnings that are not relevant to our tests jest.spyOn(console, 'error').mockImplementation((...args) => { const message = typeof args[0] === 'string' ? args[0] : String(args[0] || '') if ( @@ -153,7 +161,6 @@ describe('ProjectsDashboardDropDown Component', () => { ) { return } - // For test environment, we can ignore other console errors return }) }) @@ -175,7 +182,7 @@ describe('ProjectsDashboardDropDown Component', () => { it('renders with icon when provided', () => { render( - + ) expect(screen.getByTestId('font-awesome-icon')).toBeInTheDocument() @@ -187,7 +194,7 @@ describe('ProjectsDashboardDropDown Component', () => { ) const icon = screen.getByTestId('font-awesome-icon') - expect(icon).toHaveAttribute('data-icon', 'default') + expect(icon).toHaveAttribute('data-icon', 'arrow-down-wide-short') // Default fallback }) }) @@ -212,7 +219,7 @@ describe('ProjectsDashboardDropDown Component', () => { {...defaultProps} onAction={mockOnAction} isOrdering={false} - icon={faSort} + icon={FaSort} /> ) @@ -241,7 +248,6 @@ describe('ProjectsDashboardDropDown Component', () => { /> ) - // Check that selected labels are not rendered when undefined expect(screen.queryByText('Selected Item')).not.toBeInTheDocument() }) }) @@ -289,17 +295,12 @@ describe('ProjectsDashboardDropDown Component', () => { describe('Event handling', () => { it('calls onAction when dropdown item is clicked', () => { render( - + ) const items = screen.getAllByTestId('dropdown-item') const activeItem = items.find((item) => item.textContent === 'Active') - // Use non-conditional expect expect(activeItem).toBeDefined() fireEvent.click(activeItem!) @@ -322,11 +323,7 @@ describe('ProjectsDashboardDropDown Component', () => { it('handles multiple clicks correctly', () => { render( - + ) const items = screen.getAllByTestId('dropdown-item') @@ -337,7 +334,6 @@ describe('ProjectsDashboardDropDown Component', () => { expect(inactiveItem).toBeDefined() fireEvent.click(activeItem!) - fireEvent.click(inactiveItem!) expect(mockOnAction).toHaveBeenCalledTimes(2) @@ -411,7 +407,6 @@ describe('ProjectsDashboardDropDown Component', () => { /> ) - // Should not render selected labels when array is empty const labelsContainer = screen.queryByText(/,/) expect(labelsContainer).not.toBeInTheDocument() }) @@ -427,17 +422,12 @@ describe('ProjectsDashboardDropDown Component', () => { it('renders all dropdown items correctly', () => { render( - + ) const items = screen.getAllByTestId('dropdown-item') expect(items).toHaveLength(4) - // Check by getting text content from dropdown items specifically const itemTexts = items.map((item) => item.textContent) expect(itemTexts).toContain('Active') expect(itemTexts).toContain('Inactive') @@ -528,7 +518,6 @@ describe('ProjectsDashboardDropDown Component', () => { /> ) - // Check that both main text and subtitle are present for screen readers expect(screen.getByText('Filter')).toBeInTheDocument() expect(screen.getByText('Selected')).toBeInTheDocument() }) @@ -572,7 +561,6 @@ describe('ProjectsDashboardDropDown Component', () => { const items = screen.getAllByTestId('dropdown-item') expect(items).toHaveLength(4) - // Check content without ambiguity const itemTexts = items.map((item) => item.textContent) expect(itemTexts).toContain('Active') expect(itemTexts).toContain('Inactive') @@ -611,7 +599,6 @@ describe('ProjectsDashboardDropDown Component', () => { /> ) - // Check that the flex column structure exists const flexContainer = screen.getByText('Filter').parentElement expect(flexContainer).toHaveClass('flex', 'flex-col', 'items-center') }) diff --git a/frontend/__tests__/unit/components/RecentIssues.test.tsx b/frontend/__tests__/unit/components/RecentIssues.test.tsx index 32c8d6cd86..87d931b3aa 100644 --- a/frontend/__tests__/unit/components/RecentIssues.test.tsx +++ b/frontend/__tests__/unit/components/RecentIssues.test.tsx @@ -96,12 +96,12 @@ describe('', () => { it('shows avatar when showAvatar is true', () => { render() - expect(screen.getByAltText('User One')).toBeInTheDocument() + expect(screen.getByAltText("User One's avatar")).toBeInTheDocument() }) it('hides avatar when showAvatar is false', () => { render() - expect(screen.queryByAltText('User One')).not.toBeInTheDocument() + expect(screen.queryByAltText("User One's avatar")).not.toBeInTheDocument() }) it('renders repositoryName and navigates on click', () => { @@ -140,7 +140,7 @@ describe('', () => { render() expect(screen.getByText('Recent Issues')).toBeInTheDocument() expect(screen.getByText('repo')).toBeInTheDocument() - expect(screen.getByAltText('User One')).toBeInTheDocument() + expect(screen.getByAltText("User One's avatar")).toBeInTheDocument() }) it('has accessible roles and labels', () => { @@ -194,6 +194,19 @@ describe('', () => { it('defaults to showing avatar when showAvatar is not provided', () => { render() - expect(screen.getByAltText('User One')).toBeInTheDocument() + expect(screen.getByAltText("User One's avatar")).toBeInTheDocument() + }) + + it('uses fallback alt text when author name and login are missing', () => { + const issueWithEmptyAuthor = { + ...baseIssue, + author: { + ...baseIssue.author, + name: '', + login: '', + }, + } + render() + expect(screen.getByAltText("Author's avatar")).toBeInTheDocument() }) }) diff --git a/frontend/__tests__/unit/components/RecentRelease.test.tsx b/frontend/__tests__/unit/components/RecentRelease.test.tsx index cbed308e81..c0f2d432d2 100644 --- a/frontend/__tests__/unit/components/RecentRelease.test.tsx +++ b/frontend/__tests__/unit/components/RecentRelease.test.tsx @@ -209,7 +209,29 @@ describe('RecentReleases Component', () => { // Should still render the release name expect(screen.getByText('v1.0 The First Release')).toBeInTheDocument() // Should handle missing author gracefully - expect(screen.getByAltText('testuser')).toBeInTheDocument() + expect(screen.getByAltText("testuser's avatar")).toBeInTheDocument() + }) + + it('should handle releases with author object but missing name and login', () => { + const releasesWithEmptyAuthor = [ + { + ...mockReleases[0], + author: { + ...mockReleases[0].author, + name: '', + login: '', + }, + }, + ] + + act(() => { + render() + }) + + // Should still render the release name + expect(screen.getByText('v1.0 The First Release')).toBeInTheDocument() + // Should render fallback alt text because both name and login are missing + expect(screen.getByAltText('Release author avatar')).toBeInTheDocument() }) it('should handle releases with missing repository information', () => { @@ -278,7 +300,7 @@ describe('RecentReleases Component', () => { }) // Check for proper alt text on images - const authorImage = screen.getByAltText('Test User') + const authorImage = screen.getByAltText("Test User's avatar") expect(authorImage).toBeInTheDocument() // Check for proper link roles diff --git a/frontend/__tests__/unit/components/Release.test.tsx b/frontend/__tests__/unit/components/Release.test.tsx index d8d36401e1..7e324c56c9 100644 --- a/frontend/__tests__/unit/components/Release.test.tsx +++ b/frontend/__tests__/unit/components/Release.test.tsx @@ -128,7 +128,7 @@ describe('Release Component', () => { it('renders author avatar when showAvatar is true and author exists', () => { render() - const avatar = screen.getByAltText('Test User') + const avatar = screen.getByAltText("Test User's avatar") expect(avatar).toBeInTheDocument() expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.png') }) @@ -190,7 +190,7 @@ describe('Release Component', () => { it('renders author name in tooltip when hovering over avatar', () => { render() - const tooltip = screen.getByAltText('Test User').closest('div') + const tooltip = screen.getByAltText("Test User's avatar").closest('div') expect(tooltip).toHaveAttribute('id', 'avatar-tooltip-0') }) @@ -202,7 +202,18 @@ describe('Release Component', () => { render() // The login should be in the alt attribute of the image - const avatar = screen.getByAltText('testuser') + const avatar = screen.getByAltText("testuser's avatar") + expect(avatar).toBeInTheDocument() + }) + + it('renders fallback alt text when author exists but name and login are missing', () => { + const releaseWithEmptyAuthor = { + ...mockReleases[0], + author: { ...mockReleases[0].author, name: '', login: '' }, + } + render() + + const avatar = screen.getByAltText('Release author avatar') expect(avatar).toBeInTheDocument() }) @@ -229,8 +240,8 @@ describe('Release Component', () => {
) - const tooltip1 = screen.getByAltText('Test User').closest('div') - const tooltip2 = screen.getByAltText('Jane Doe').closest('div') + const tooltip1 = screen.getByAltText("Test User's avatar").closest('div') + const tooltip2 = screen.getByAltText("Jane Doe's avatar").closest('div') expect(tooltip1).toHaveAttribute('id', 'avatar-tooltip-0') expect(tooltip2).toHaveAttribute('id', 'avatar-tooltip-1') @@ -267,7 +278,7 @@ describe('Release Component', () => { expect(screen.getByText('v1.0 The First Release')).toBeInTheDocument() // Avatar should show by default, so we should find the author avatar - expect(screen.getByAltText('Test User')).toBeInTheDocument() + expect(screen.getByAltText("Test User's avatar")).toBeInTheDocument() }) it('handles long release names with truncation', () => { diff --git a/frontend/__tests__/unit/components/Search.test.tsx b/frontend/__tests__/unit/components/Search.test.tsx index 91c2e81995..3b378b6e32 100644 --- a/frontend/__tests__/unit/components/Search.test.tsx +++ b/frontend/__tests__/unit/components/Search.test.tsx @@ -21,7 +21,7 @@ jest.mock('lodash/debounce', () => { describe('SearchBar Component', () => { const mockOnSearch = jest.fn() const defaultProps = { - isLoaded: false, + isLoaded: true, onSearch: mockOnSearch, placeholder: 'Search projects...', } @@ -52,8 +52,8 @@ describe('SearchBar Component', () => { }) describe('Conditional rendering logic', () => { - it('shows skeleton when isLoaded is true', () => { - const { container } = render() + it('shows skeleton when isLoaded is false', () => { + const { container } = render() const skeleton = container.querySelector('.h-12.rounded-lg:not(input)') const input = screen.queryByPlaceholderText('Search projects...') expect(input).not.toBeInTheDocument() @@ -61,8 +61,8 @@ describe('SearchBar Component', () => { expect(skeleton).toHaveClass('h-12 rounded-lg') }) - it('shows input when isLoaded is false', () => { - const { container } = render() + it('shows input when isLoaded is true', () => { + const { container } = render() const skeleton = container.querySelector('.h-12.rounded-lg:not(input)') const input = screen.getByPlaceholderText('Search projects...') expect(skeleton).not.toBeInTheDocument() @@ -70,18 +70,18 @@ describe('SearchBar Component', () => { }) it('does not show clear button when searchQuery is empty', () => { - const { container } = render() + const { container } = render() const input = screen.getByPlaceholderText('Search projects...') expect(input).toHaveValue('') - const clearButton = container.querySelector('button.absolute.rounded-full[class*="right-2"]') + const clearButton = container.querySelector('button.absolute.rounded-md[class*="right-2"]') expect(clearButton).not.toBeInTheDocument() }) it('shows clear button when searchQuery is not empty', () => { - const { container } = render() + const { container } = render() const input = screen.getByPlaceholderText('Search projects...') fireEvent.change(input, { target: { value: 'test' } }) - const clearButton = container.querySelector('button.absolute.rounded-full[class*="right-2"]') + const clearButton = container.querySelector('button.absolute.rounded-md[class*="right-2"]') expect(clearButton).toBeInTheDocument() }) }) @@ -89,7 +89,7 @@ describe('SearchBar Component', () => { describe('Prop-based behavior – different props affect output', () => { it('uses initialValue prop as default search query', async () => { const initialValue = 'default search' - render() + render() const input = screen.getByPlaceholderText('Search projects...') expect(input).toHaveValue(initialValue) @@ -111,24 +111,24 @@ describe('SearchBar Component', () => { describe('Auto-focus functionality', () => { it('should auto-focus on initial render', () => { - render() + render() const input = screen.getByPlaceholderText('Search projects...') expect(input).toHaveFocus() }) it('should not lose focus on re-renders', () => { - const { rerender } = render() + const { rerender } = render() const input = screen.getByPlaceholderText('Search projects...') expect(input).toHaveFocus() - rerender() + rerender() expect(input).toHaveFocus() }) }) describe('Event handling – simulate user actions and verify callbacks', () => { it('calls onSearch with debounced input value', async () => { - render() + render() const input = screen.getByPlaceholderText('Search projects...') fireEvent.change(input, { target: { value: 'test' } }) @@ -141,17 +141,17 @@ describe('SearchBar Component', () => { }) it('clears input when clear button is clicked', async () => { - const { container } = render() + const { container } = render() const input = screen.getByPlaceholderText('Search projects...') fireEvent.change(input, { target: { value: 'test' } }) expect(input).toHaveValue('test') - const clearButton = container.querySelector('button.absolute.rounded-full[class*="right-2"]') + const clearButton = container.querySelector('button.absolute.rounded-md[class*="right-2"]') fireEvent.click(clearButton) expect(input).toHaveValue('') }) it('calls onSearch when input value changes', async () => { - render() + render() const input = screen.getByPlaceholderText('Search projects...') fireEvent.change(input, { target: { value: 'test' } }) @@ -176,7 +176,7 @@ describe('SearchBar Component', () => { }) it('sends GTM event when search query is not empty', async () => { - render() + render() const input = screen.getByPlaceholderText('Search projects...') fireEvent.change(input, { target: { value: 'test' } }) @@ -193,7 +193,7 @@ describe('SearchBar Component', () => { describe('State changes / internal logic', () => { it('updates searchQuery state when input value changes', async () => { - render() + render() const input = screen.getByPlaceholderText('Search projects...') fireEvent.change(input, { target: { value: 'new query' } }) @@ -220,7 +220,7 @@ describe('SearchBar Component', () => { }) it('maintains internal state correctly during rapid input changes', () => { - render() + render() const input = screen.getByPlaceholderText('Search projects...') fireEvent.change(input, { target: { value: 'a' } }) @@ -233,14 +233,14 @@ describe('SearchBar Component', () => { describe('Default values and fallbacks', () => { it('uses default empty string when no initialValue provided', () => { - render() + render() const input = screen.getByPlaceholderText('Search projects...') expect(input).toHaveValue('') }) it('handles undefined props gracefully', () => { const minimalProps = { - isLoaded: false, + isLoaded: true, onSearch: mockOnSearch, placeholder: 'Search projects...', } @@ -253,7 +253,7 @@ describe('SearchBar Component', () => { describe('Text and content rendering', () => { it('renders the correct placeholder text', () => { - render() + render() const input = screen.getByPlaceholderText('Search projects...') expect(input).toBeInTheDocument() expect(input).toHaveAttribute('placeholder', 'Search projects...') @@ -264,7 +264,7 @@ describe('SearchBar Component', () => { }) it('displays typed text correctly in input', () => { - render() + render() const input = screen.getByPlaceholderText('Search projects...') const testValue = 'search term' @@ -275,7 +275,7 @@ describe('SearchBar Component', () => { describe('Handles edge cases and invalid inputs', () => { it('handles empty input gracefully', () => { - render() + render() const input = screen.getByPlaceholderText('Search projects...') fireEvent.change(input, { target: { value: '' } }) @@ -286,7 +286,7 @@ describe('SearchBar Component', () => { }) it('handles special characters in input', () => { - render() + render() const input = screen.getByPlaceholderText('Search projects...') const specialChars = '!@#$%^&*()_+-=[]{}|;:,.<>?' @@ -296,7 +296,7 @@ describe('SearchBar Component', () => { }) it('handles very long input strings', () => { - render() + render() const input = screen.getByPlaceholderText('Search projects...') const longString = 'balance'.repeat(140) @@ -306,13 +306,13 @@ describe('SearchBar Component', () => { }) it('cancels pending debounced search when clear button is clicked', async () => { - const { container } = render() + const { container } = render() const input = screen.getByPlaceholderText('Search projects...') fireEvent.change(input, { target: { value: 'edge case' } }) expect(mockOnSearch).not.toHaveBeenCalled() - const clearButton = container.querySelector('button.absolute.rounded-full[class*="right-2"]') + const clearButton = container.querySelector('button.absolute.rounded-md[class*="right-2"]') fireEvent.click(clearButton) jest.advanceTimersByTime(750) @@ -328,19 +328,19 @@ describe('SearchBar Component', () => { describe('Accessibility roles and labels', () => { it('has the correct accessibility labels', () => { - const { container } = render() + const { container } = render() const input = screen.getByPlaceholderText('Search projects...') expect(input).toBeInTheDocument() expect(input).toHaveAttribute('type', 'text') - let clearButton = container.querySelector('button.absolute.rounded-full[class*="right-2"]') + let clearButton = container.querySelector('button.absolute.rounded-md[class*="right-2"]') expect(clearButton).not.toBeInTheDocument() fireEvent.change(input, { target: { value: 'test' } }) - clearButton = container.querySelector('button.absolute.rounded-full[class*="right-2"]') + clearButton = container.querySelector('button.absolute.rounded-md[class*="right-2"]') expect(clearButton).toBeInTheDocument() }) it('provides proper ARIA attributes for screen readers', () => { - render() + render() const input = screen.getByPlaceholderText('Search projects...') expect(input).toHaveAttribute('type', 'text') expect(input).toHaveAttribute('placeholder', 'Search projects...') @@ -349,41 +349,41 @@ describe('SearchBar Component', () => { describe('DOM structure / classNames / styles', () => { it('has the correct class names and styles for input', () => { - render() + render() const input = screen.getByPlaceholderText('Search projects...') expect(input).toHaveClass( - 'h-12 w-full rounded-lg border-1 border-gray-300 pl-10 pr-10 text-lg text-black focus:border-blue-500 focus:outline-hidden focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:focus:border-blue-300 dark:focus:ring-blue-300' + 'h-12 w-full rounded-lg border-1 border-gray-300 bg-white pr-10 pl-10 text-lg text-black focus:ring-1 focus:ring-blue-500 focus:outline-hidden dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:focus:ring-blue-300' ) }) it('has the correct class names for skeleton', () => { - const { container } = render() + const { container } = render() const skeleton = container.querySelector('.h-12.rounded-lg:not(input)') expect(skeleton).toHaveClass('h-12 rounded-lg') }) it('has the correct class names for clear button', () => { - const { container } = render() + const { container } = render() const input = screen.getByPlaceholderText('Search projects...') fireEvent.change(input, { target: { value: 'test' } }) - const clearButton = container.querySelector('button.absolute.rounded-full[class*="right-2"]') + const clearButton = container.querySelector('button.absolute.rounded-md[class*="right-2"]') expect(clearButton).toHaveClass( - 'absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1 hover:bg-gray-100 focus:outline-hidden focus:ring-2 focus:ring-gray-300' + 'absolute top-1/2 right-2 h-8 w-8 -translate-y-1/2 rounded-md p-1 text-gray-400 hover:bg-gray-400 hover:text-gray-200 focus:ring-2 focus:ring-gray-300 focus:outline-hidden dark:hover:bg-gray-600' ) }) it('has the correct class names for search icon', () => { - render() - const searchIcon = screen.getByRole('img', { hidden: true }) - expect(searchIcon).toHaveClass( - 'pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400' - ) + render() + const searchIcon = screen.getByTestId('search-icon') + expect(searchIcon).toBeInTheDocument() }) it('maintains proper DOM structure with all elements', () => { - render() + render() const input = screen.getByPlaceholderText('Search projects...') - const searchIcon = screen.getByRole('img', { hidden: true }) + const searchIcon = document.querySelector( + String.raw`svg.pointer-events-none.absolute.left-3.top-1\/2.h-4.w-4.-translate-y-1\/2.text-gray-400` + ) expect(input).toBeInTheDocument() expect(searchIcon).toBeInTheDocument() diff --git a/frontend/__tests__/unit/components/SecondaryCard.test.tsx b/frontend/__tests__/unit/components/SecondaryCard.test.tsx index 25a39876b0..20d9942e3e 100644 --- a/frontend/__tests__/unit/components/SecondaryCard.test.tsx +++ b/frontend/__tests__/unit/components/SecondaryCard.test.tsx @@ -1,11 +1,11 @@ -import { faUser } from '@fortawesome/free-solid-svg-icons' import { render, screen } from '@testing-library/react' +import { FaUser } from 'react-icons/fa6' import SecondaryCard from 'components/SecondaryCard' describe('SecondaryCard Component', () => { const defaultProps = { title: 'Test Title', - icon: faUser, + icon: FaUser, children:

Test children

, className: 'custom-class', } @@ -51,7 +51,7 @@ describe('SecondaryCard Component', () => { it('renders a title and an icon when both are provided', () => { render() expect(screen.getByText(defaultProps.title)).toBeInTheDocument() - expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument() + expect(document.querySelector('svg.h-5.w-5')).toBeInTheDocument() }) it('renders children content correctly', () => { @@ -135,7 +135,7 @@ describe('SecondaryCard Component', () => { it('has the correct className for the icon', () => { render() - const iconElement = screen.getByRole('img', { hidden: true }) + const iconElement = document.querySelector('svg.h-5.w-5') expect(iconElement).toHaveClass('h-5', 'w-5') }) }) diff --git a/frontend/__tests__/unit/components/SingleModuleCard.test.tsx b/frontend/__tests__/unit/components/SingleModuleCard.test.tsx index d56a026ca6..454273d9c7 100644 --- a/frontend/__tests__/unit/components/SingleModuleCard.test.tsx +++ b/frontend/__tests__/unit/components/SingleModuleCard.test.tsx @@ -1,6 +1,5 @@ -import { faUsers } from '@fortawesome/free-solid-svg-icons' import { screen } from '@testing-library/react' -import { useRouter } from 'next/navigation' +import { usePathname, useRouter } from 'next/navigation' import { useSession } from 'next-auth/react' import React from 'react' import { render } from 'wrappers/testUtil' @@ -11,6 +10,7 @@ import SingleModuleCard from 'components/SingleModuleCard' // Mock dependencies jest.mock('next/navigation', () => ({ useRouter: jest.fn(), + usePathname: jest.fn(), })) jest.mock('next-auth/react', () => ({ @@ -47,9 +47,9 @@ jest.mock('next/link', () => ({ ), })) -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: ({ icon, className }: { icon: unknown; className?: string }) => ( - +jest.mock('react-icons/hi', () => ({ + HiUserGroup: (props: React.SVGProps) => ( + ), })) @@ -80,6 +80,7 @@ jest.mock('components/TopContributorsList', () => ({ const mockPush = jest.fn() const mockUseRouter = useRouter as jest.MockedFunction +const mockUsePathname = usePathname as jest.MockedFunction const mockUseSession = useSession as jest.MockedFunction // Test data @@ -122,6 +123,7 @@ describe('SingleModuleCard', () => { replace: jest.fn(), prefetch: jest.fn(), }) + mockUsePathname.mockReturnValue('/my/mentorship/programs/test-program') mockUseSession.mockReturnValue({ data: null, status: 'unauthenticated', @@ -166,7 +168,10 @@ describe('SingleModuleCard', () => { render() const moduleLink = screen.getByTestId('module-link') - expect(moduleLink).toHaveAttribute('href', '//modules/test-module') + expect(moduleLink).toHaveAttribute( + 'href', + '/my/mentorship/programs/test-program/modules/test-module' + ) expect(moduleLink).toHaveAttribute('target', '_blank') expect(moduleLink).toHaveAttribute('rel', 'noopener noreferrer') }) @@ -183,7 +188,10 @@ describe('SingleModuleCard', () => { // Should have clickable title for navigation const moduleLink = screen.getByTestId('module-link') - expect(moduleLink).toHaveAttribute('href', '//modules/test-module') + expect(moduleLink).toHaveAttribute( + 'href', + '/my/mentorship/programs/test-program/modules/test-module' + ) }) }) @@ -197,14 +205,7 @@ describe('SingleModuleCard', () => { it('ignores admin-related props since menu is removed', () => { // These props are now ignored but should not cause errors - render( - - ) + render() expect(screen.getByText('Test Module')).toBeInTheDocument() }) @@ -225,7 +226,7 @@ describe('SingleModuleCard', () => { }) it('handles undefined admins array gracefully', () => { - render() + render() // Should render without errors even with admin props expect(screen.getByText('Test Module')).toBeInTheDocument() @@ -238,7 +239,10 @@ describe('SingleModuleCard', () => { const moduleLink = screen.getByTestId('module-link') expect(moduleLink).toBeInTheDocument() - expect(moduleLink).toHaveAttribute('href', '//modules/test-module') + expect(moduleLink).toHaveAttribute( + 'href', + '/my/mentorship/programs/test-program/modules/test-module' + ) expect(moduleLink).toHaveAttribute('target', '_blank') expect(moduleLink).toHaveAttribute('rel', 'noopener noreferrer') }) diff --git a/frontend/__tests__/unit/components/SortBy.test.tsx b/frontend/__tests__/unit/components/SortBy.test.tsx index 3aa5d8db56..fe12b25e66 100644 --- a/frontend/__tests__/unit/components/SortBy.test.tsx +++ b/frontend/__tests__/unit/components/SortBy.test.tsx @@ -56,8 +56,8 @@ describe('', () => { render() }) // Look for the icon that indicates ascending order - const sortIcon = screen.getByRole('img', { hidden: true }) - expect(sortIcon.classList.contains('fa-arrow-up-wide-short')).toBe(true) + const sortIcon = screen.getByLabelText(/Sort in ascending order/i).querySelector('svg') + expect(sortIcon).toBeInTheDocument() }) it('renders descending icon and tooltip when order is "desc"', async () => { @@ -65,8 +65,8 @@ describe('', () => { render() }) // Look for the icon that indicates descending order - const sortIcon = screen.getByRole('img', { hidden: true }) - expect(sortIcon.classList.contains('fa-arrow-down-wide-short')).toBe(true) + const sortIcon = screen.getByLabelText(/Sort in descending order/i).querySelector('svg') + expect(sortIcon).toBeInTheDocument() }) it('toggles order when the button is clicked', async () => { diff --git a/frontend/__tests__/unit/components/StatusBadge.test.tsx b/frontend/__tests__/unit/components/StatusBadge.test.tsx index 6be83e3692..d98c6720b7 100644 --- a/frontend/__tests__/unit/components/StatusBadge.test.tsx +++ b/frontend/__tests__/unit/components/StatusBadge.test.tsx @@ -1,9 +1,13 @@ import { render, screen } from '@testing-library/react' +import React from 'react' import StatusBadge from 'components/StatusBadge' -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: ({ className }: { className: string }) => ( - +jest.mock('react-icons/fa', () => ({ + FaArchive: (props: React.SVGProps) => ( + + ), + FaBan: (props: React.SVGProps) => ( + ), })) diff --git a/frontend/__tests__/unit/components/ToggleableList.test.tsx b/frontend/__tests__/unit/components/ToggleableList.test.tsx index 93fb027baf..e6be542cad 100644 --- a/frontend/__tests__/unit/components/ToggleableList.test.tsx +++ b/frontend/__tests__/unit/components/ToggleableList.test.tsx @@ -1,12 +1,8 @@ -import { faUser } from '@fortawesome/free-solid-svg-icons' import { render, screen, fireEvent } from '@testing-library/react' +import React from 'react' +import { FaUser } from 'react-icons/fa' import ToggleableList from 'components/ToggleableList' -interface MockFontAwesomeIconProps { - icon: unknown - className?: string -} - const mockPush = jest.fn() jest.mock('next/navigation', () => ({ useRouter: () => ({ @@ -23,11 +19,13 @@ jest.mock('components/ShowMoreButton', () => ({ ), })) -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: ({ icon, className }: MockFontAwesomeIconProps) => ( - - {String(icon)} - +jest.mock('wrappers/IconWrapper', () => ({ + IconWrapper: ({ + icon: Icon, + className, + ...props + }: { icon: React.ComponentType<{ className?: string }> } & React.SVGProps) => ( + ), })) @@ -53,9 +51,9 @@ describe('ToggleableList', () => { }) it('renders with an icon', () => { - render() + render() - const iconElement = screen.getByTestId('font-awesome-icon') + const iconElement = screen.getByTestId('react-icon') expect(iconElement).toBeInTheDocument() expect(iconElement).toHaveClass('mr-2', 'h-5', 'w-5') }) diff --git a/frontend/__tests__/unit/components/TopContributorsList.test.tsx b/frontend/__tests__/unit/components/TopContributorsList.test.tsx index 0b0d867380..83d374cf7f 100644 --- a/frontend/__tests__/unit/components/TopContributorsList.test.tsx +++ b/frontend/__tests__/unit/components/TopContributorsList.test.tsx @@ -1,6 +1,6 @@ -import { faUsers } from '@fortawesome/free-solid-svg-icons' import { fireEvent, screen } from '@testing-library/react' import React from 'react' +import { FaUsers } from 'react-icons/fa6' import { render } from 'wrappers/testUtil' import type { Contributor } from 'types/contributor' import TopContributorsList from 'components/TopContributorsList' @@ -49,23 +49,6 @@ jest.mock('next/image', () => ({ ), })) -// Mock FontAwesome icons -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: ({ - icon, - className, - ...props - }: { - icon: { iconName: string } - className?: string - [key: string]: unknown - }) => ( - - {icon.iconName} - - ), -})) - // Mock utility functions jest.mock('utils/urlFormatter', () => ({ getMemberUrl: (login: string) => `/members/${login}`, @@ -94,22 +77,22 @@ jest.mock('components/SecondaryCard', () => ({ default: ({ children, title, - icon, + icon: Icon, className, ...props }: { children: React.ReactNode title?: React.ReactNode - icon?: { iconName: string } + icon?: React.ComponentType className?: string [key: string]: unknown }) => (
{title && (

- {icon && ( + {Icon && ( - {icon.iconName} + )} {title} @@ -120,6 +103,57 @@ jest.mock('components/SecondaryCard', () => ({ ), })) +jest.mock('components/ShowMoreButton', () => ({ + __esModule: true, + default: function ShowMoreButtonMock({ onToggle }: { onToggle: () => void }) { + const [isExpanded, setIsExpanded] = React.useState(false) + + const handleClick = () => { + setIsExpanded(!isExpanded) + onToggle() + } + + return ( +
+ +
+ ) + }, +})) + +jest.mock('react-icons/fa6', () => ({ + FaChevronUp: (props: React.HTMLAttributes) => ( + + chevron-up + + ), + FaChevronDown: (props: React.HTMLAttributes) => ( + + chevron-down + + ), + FaUsers: (props: React.HTMLAttributes) => users, +})) + const mockContributors: Contributor[] = [ { avatarUrl: 'https://github.com/developer1.avatar', @@ -190,7 +224,7 @@ describe('TopContributorsList Component', () => { expect(screen.queryByRole('button')).not.toBeInTheDocument() // Test with many contributors (should show button) - const manyContributors = Array(15) + const manyContributors = Array.from({ length: 15 }) .fill(null) .map((_, index) => ({ ...mockContributors[0], @@ -204,7 +238,7 @@ describe('TopContributorsList Component', () => { }) it('displays correct number of contributors based on maxInitialDisplay', () => { - const manyContributors = Array(15) + const manyContributors = Array.from({ length: 15 }) .fill(null) .map((_, index) => ({ ...mockContributors[0], @@ -257,7 +291,7 @@ describe('TopContributorsList Component', () => { }) it('respects custom maxInitialDisplay prop', () => { - const manyContributors = Array(10) + const manyContributors = Array.from({ length: 10 }) .fill(null) .map((_, index) => ({ ...mockContributors[0], @@ -272,21 +306,19 @@ describe('TopContributorsList Component', () => { }) it('displays icon when provided', () => { - render() - + render() expect(screen.getByTestId('card-icon')).toBeInTheDocument() }) it('does not display icon when not provided', () => { render() - expect(screen.queryByTestId('card-icon')).not.toBeInTheDocument() }) }) describe('Event handling', () => { it('toggles contributors display when show more/less button is clicked', () => { - const manyContributors = Array(15) + const manyContributors = Array.from({ length: 15 }) .fill(null) .map((_, index) => ({ ...mockContributors[0], @@ -319,7 +351,7 @@ describe('TopContributorsList Component', () => { }) it('calls toggle function correctly on multiple clicks', () => { - const manyContributors = Array(10) + const manyContributors = Array.from({ length: 10 }) .fill(null) .map((_, index) => ({ ...mockContributors[0], @@ -345,7 +377,7 @@ describe('TopContributorsList Component', () => { describe('State changes / internal logic', () => { it('manages showAllContributors state correctly', () => { - const manyContributors = Array(10) + const manyContributors = Array.from({ length: 10 }) .fill(null) .map((_, index) => ({ ...mockContributors[0], @@ -368,7 +400,7 @@ describe('TopContributorsList Component', () => { }) it('correctly slices contributors array based on state', () => { - const contributors = Array(8) + const contributors = Array.from({ length: 8 }) .fill(null) .map((_, index) => ({ ...mockContributors[0], @@ -388,7 +420,7 @@ describe('TopContributorsList Component', () => { describe('Default values and fallbacks', () => { it('uses default maxInitialDisplay value when not provided', () => { - const contributors = Array(15) + const contributors = Array.from({ length: 15 }) .fill(null) .map((_, index) => ({ ...mockContributors[0], @@ -445,7 +477,7 @@ describe('TopContributorsList Component', () => { }) it('renders correct button text based on state', () => { - const manyContributors = Array(15) + const manyContributors = Array.from({ length: 15 }) .fill(null) .map((_, index) => ({ ...mockContributors[0], @@ -521,11 +553,11 @@ describe('TopContributorsList Component', () => { render() const avatars = screen.getAllByTestId('contributor-avatar') - expect(avatars[0]).toHaveAttribute('alt', 'Alex Developer') + expect(avatars[0]).toHaveAttribute('alt', "Alex Developer's avatar") expect(avatars[0]).toHaveAttribute('title', 'Alex Developer') - expect(avatars[1]).toHaveAttribute('alt', 'Jane Developer') + expect(avatars[1]).toHaveAttribute('alt', "Jane Developer's avatar") expect(avatars[1]).toHaveAttribute('title', 'Jane Developer') - expect(avatars[2]).toHaveAttribute('alt', '') + expect(avatars[2]).toHaveAttribute('alt', 'Contributor avatar') expect(avatars[2]).toHaveAttribute('title', 'user3') }) @@ -542,7 +574,7 @@ describe('TopContributorsList Component', () => { }) it('renders button with proper role', () => { - const manyContributors = Array(15) + const manyContributors = Array.from({ length: 15 }) .fill(null) .map((_, index) => ({ ...mockContributors[0], diff --git a/frontend/__tests__/unit/components/UserCard.test.tsx b/frontend/__tests__/unit/components/UserCard.test.tsx index c25948e47a..dca1fa0266 100644 --- a/frontend/__tests__/unit/components/UserCard.test.tsx +++ b/frontend/__tests__/unit/components/UserCard.test.tsx @@ -53,23 +53,20 @@ jest.mock('@heroui/button', () => { } }) -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: ({ - icon, - className, - ...props - }: { - icon: { iconName: string } - className?: string - [key: string]: unknown - }) => , +jest.mock('react-icons/fa6', () => ({ + FaChevronRight: (props: React.SVGProps) => ( + + ), + FaFolderOpen: (props: React.SVGProps) => ( + + ), + FaMedal: (props: React.SVGProps) => , + FaUser: (props: React.SVGProps) => , })) -jest.mock('@heroui/tooltip', () => ({ - Tooltip: ({ children, content }: { children: React.ReactNode; content: string }) => ( -
- {children} -
+jest.mock('react-icons/hi', () => ({ + HiUserGroup: (props: React.SVGProps) => ( + ), })) @@ -157,7 +154,7 @@ describe('UserCard', () => { const avatarImage = screen.getByTestId('user-avatar') expect(avatarImage).toBeInTheDocument() expect(avatarImage).toHaveAttribute('src', 'https://example.com/avatar.jpg&s=160') - expect(avatarImage).toHaveAttribute('alt', 'John Doe') + expect(avatarImage).toHaveAttribute('alt', "John Doe's profile picture") }) it('renders default user icon when avatar is empty string', () => { @@ -185,6 +182,12 @@ describe('UserCard', () => { expect(screen.getByText('john@example.com')).toBeInTheDocument() }) + it('renders login when company and location and email are not provided', () => { + render() + + expect(screen.getByText('login')).toBeInTheDocument() + }) + it('prioritizes company over location and email', () => { render( { ) - expect(screen.getByTestId('user-avatar')).toHaveAttribute('alt', 'Jane Smith') + expect(screen.getByTestId('user-avatar')).toHaveAttribute( + 'alt', + "Jane Smith's profile picture" + ) }) it('uses fallback alt text when name is not provided', () => { render() - expect(screen.getByTestId('user-avatar')).toHaveAttribute('alt', 'user') + expect(screen.getByTestId('user-avatar')).toHaveAttribute('alt', 'User profile picture') }) it('displays View Profile text', () => { @@ -325,7 +331,7 @@ describe('UserCard', () => { render() const avatar = screen.getByTestId('user-avatar') - expect(avatar).toHaveAttribute('alt', 'John Doe') + expect(avatar).toHaveAttribute('alt', "John Doe's profile picture") }) it('maintains semantic heading structure', () => { diff --git a/frontend/__tests__/unit/components/UserMenu.test.tsx b/frontend/__tests__/unit/components/UserMenu.test.tsx index 7637ca8be4..b30cf964dd 100644 --- a/frontend/__tests__/unit/components/UserMenu.test.tsx +++ b/frontend/__tests__/unit/components/UserMenu.test.tsx @@ -2,6 +2,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { useDjangoSession } from 'hooks/useDjangoSession' import { useLogout } from 'hooks/useLogout' import { signIn } from 'next-auth/react' +import React from 'react' import { ExtendedSession } from 'types/auth' import UserMenu from 'components/UserMenu' @@ -41,11 +42,9 @@ jest.mock('next/image', () => ({ ), })) -// Mock FontAwesome icons -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: ({ icon }: { icon: unknown }) => ( - - ), +// Add this react-icons mock for FaGithub: +jest.mock('react-icons/fa', () => ({ + FaGithub: (props: React.SVGProps) => , })) describe('UserMenu Component', () => { @@ -622,7 +621,8 @@ describe('UserMenu Component', () => { await waitFor(() => { const dropdownId = avatarButton.getAttribute('aria-controls') - const dropdown = document.getElementById(dropdownId!) + expect(dropdownId).not.toBeNull() + const dropdown = document.getElementById(dropdownId) expect(dropdown).toBeInTheDocument() }) }) @@ -709,7 +709,8 @@ describe('UserMenu Component', () => { await waitFor(() => { const dropdownId = avatarButton.getAttribute('aria-controls') - const dropdown = document.getElementById(dropdownId!) + expect(dropdownId).not.toBeNull() + const dropdown = document.getElementById(dropdownId) expect(dropdown).toHaveClass( 'absolute', 'right-0', diff --git a/frontend/__tests__/unit/global-error.test.tsx b/frontend/__tests__/unit/global-error.test.tsx index 83105b058d..eade658650 100644 --- a/frontend/__tests__/unit/global-error.test.tsx +++ b/frontend/__tests__/unit/global-error.test.tsx @@ -105,6 +105,8 @@ describe('GlobalError component', () => { expect(Sentry.captureException).toHaveBeenCalledWith(error) expect(screen.getByText('Server Error')).toBeInTheDocument() + const button = screen.getByRole('button', { name: 'Return to Home' }) + expect(button).toHaveClass('bg-owasp-blue', 'dark:bg-slate-800') }) }) diff --git a/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx b/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx new file mode 100644 index 0000000000..e24e0bb50b --- /dev/null +++ b/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx @@ -0,0 +1,115 @@ +import { renderHook, act } from '@testing-library/react' +import { BreadcrumbRoot, registerBreadcrumb } from 'contexts/BreadcrumbContext' +import { useBreadcrumbs } from 'hooks/useBreadcrumbs' +import { usePathname } from 'next/navigation' +import React from 'react' + +jest.mock('next/navigation', () => ({ + usePathname: jest.fn(), +})) + +// Helper wrapper with BreadcrumbRoot +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +describe('useBreadcrumbs', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Hybrid Pattern (registered + auto-generated)', () => { + test('returns Home for root path', () => { + ;(usePathname as jest.Mock).mockReturnValue('/') + + const { result } = renderHook(() => useBreadcrumbs(), { wrapper }) + + expect(result.current).toEqual([{ title: 'Home', path: '/' }]) + }) + + test('auto-generates breadcrumbs from URL when no items registered', () => { + ;(usePathname as jest.Mock).mockReturnValue('/members') + + const { result } = renderHook(() => useBreadcrumbs(), { wrapper }) + + expect(result.current).toEqual([ + { title: 'Home', path: '/' }, + { title: 'Members', path: '/members' }, + ]) + }) + + test('uses registered title when available', () => { + ;(usePathname as jest.Mock).mockReturnValue('/projects/test-project') + + const { result } = renderHook(() => useBreadcrumbs(), { wrapper }) + + let unregister: () => void + act(() => { + unregister = registerBreadcrumb({ title: 'Test Project', path: '/projects/test-project' }) + }) + + expect(result.current).toEqual([ + { title: 'Home', path: '/' }, + { title: 'Projects', path: '/projects' }, + { title: 'Test Project', path: '/projects/test-project' }, + ]) + + act(() => { + unregister() + }) + }) + + test('merges registered and auto-generated items', () => { + ;(usePathname as jest.Mock).mockReturnValue('/organizations/test-org/repositories/test-repo') + + const { result } = renderHook(() => useBreadcrumbs(), { wrapper }) + + const unregisterFns: (() => void)[] = [] + act(() => { + unregisterFns.push( + registerBreadcrumb({ title: 'Test Organization', path: '/organizations/test-org' }), + registerBreadcrumb({ + title: 'Test Repository', + path: '/organizations/test-org/repositories/test-repo', + }) + ) + }) + + // Note: 'repositories' segment is hidden (in HIDDEN_SEGMENTS) + expect(result.current).toEqual([ + { title: 'Home', path: '/' }, + { title: 'Organizations', path: '/organizations' }, + { title: 'Test Organization', path: '/organizations/test-org' }, + { title: 'Test Repository', path: '/organizations/test-org/repositories/test-repo' }, + ]) + + act(() => { + for (const fn of unregisterFns) { + fn() + } + }) + }) + }) + + describe('Edge cases', () => { + test('handles null pathname', () => { + ;(usePathname as jest.Mock).mockReturnValue(null) + + const { result } = renderHook(() => useBreadcrumbs(), { wrapper }) + + expect(result.current).toEqual([{ title: 'Home', path: '/' }]) + }) + + test('formats hyphenated URL segments', () => { + ;(usePathname as jest.Mock).mockReturnValue('/test-page/test-section') + + const { result } = renderHook(() => useBreadcrumbs(), { wrapper }) + + expect(result.current).toEqual([ + { title: 'Home', path: '/' }, + { title: 'Test Page', path: '/test-page' }, + { title: 'Test Section', path: '/test-page/test-section' }, + ]) + }) + }) +}) diff --git a/frontend/__tests__/unit/pages/About.test.tsx b/frontend/__tests__/unit/pages/About.test.tsx index 161ee17f44..9c0542ecd2 100644 --- a/frontend/__tests__/unit/pages/About.test.tsx +++ b/frontend/__tests__/unit/pages/About.test.tsx @@ -3,7 +3,7 @@ import { addToast } from '@heroui/toast' import { fireEvent, screen, waitFor, within } from '@testing-library/react' import { mockAboutData } from '@unit/data/mockAboutData' import { useRouter } from 'next/navigation' -import { act } from 'react' +import React, { act } from 'react' import { render } from 'wrappers/testUtil' import About from 'app/about/page' import { @@ -20,13 +20,32 @@ jest.mock('@apollo/client/react', () => ({ const mockRouter = { push: jest.fn(), } + jest.mock('next/navigation', () => ({ ...jest.requireActual('next/navigation'), useRouter: jest.fn(() => mockRouter), })) -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: () => , +jest.mock('react-icons/fa', () => ({ + FaMapSigns: () => , + FaTools: () => , +})) + +jest.mock('react-icons/fa6', () => ({ + FaCircleCheck: () => , + FaClock: () => , + FaScroll: () => , + FaBullseye: () => , + FaUser: () => , + FaUsersGear: () => , + FaLink: () => , + FaChevronRight: () => , + FaFolderOpen: () => , + FaMedal: () => , +})) + +jest.mock('react-icons/hi', () => ({ + HiUserGroup: () => , })) jest.mock('@heroui/toast', () => ({ @@ -105,6 +124,78 @@ jest.mock('components/MarkdownWrapper', () => ({ default: ({ content }) =>
{content}
, })) +jest.mock('components/AnchorTitle', () => ({ + __esModule: true, + default: ({ title }: { title: string }) => {title}, +})) + +jest.mock('components/UserCard', () => ({ + __esModule: true, + default: ({ + name, + credentials, + description, + button, + }: { + name?: string + credentials?: string + description?: string + button?: { label?: string; onclick?: () => void } + }) => ( +
+ {name && {name}} + {credentials && {credentials}} + {description && {description}} + {button?.label && ( + + )} +
+ ), +})) + +jest.mock('components/ShowMoreButton', () => ({ + __esModule: true, + default: function ShowMoreButtonMock({ onToggle }: { onToggle: () => void }) { + const [isExpanded, setIsExpanded] = React.useState(false) + + const handleClick = () => { + setIsExpanded(!isExpanded) + onToggle() + } + + return ( +
+ +
+ ) + }, +})) + const mockUserData = (username) => ({ data: { user: mockAboutData.users[username] }, loading: false, @@ -374,8 +465,10 @@ describe('About Component', () => { } ;(useQuery as unknown as jest.Mock).mockImplementation((query, options) => { - if (options?.variables?.key === 'nest') { + if (query === GetProjectMetadataDocument && options?.variables?.key === 'nest') { return mockProjectData + } else if (query === GetTopContributorsDocument && options?.variables?.key === 'nest') { + return mockTopContributorsData } else if (options?.variables?.key === 'arkid15r') { return partialUserData } else if (options?.variables?.key === 'kasya' || options?.variables?.key === 'mamicidal') { @@ -411,9 +504,11 @@ describe('About Component', () => { render() }) await waitFor(() => { - // Look for the element with alt text "Loading indicator" - const spinner = screen.getAllByAltText('Loading indicator') - expect(spinner.length).toBeGreaterThan(0) + // Check for skeleton loading state by looking for skeleton containers + const skeletonContainers = document.querySelectorAll( + String.raw`.bg-gray-100.dark\:bg-gray-800` + ) + expect(skeletonContainers.length).toBeGreaterThan(0) }) }) diff --git a/frontend/__tests__/unit/pages/ApiKeysPage.test.tsx b/frontend/__tests__/unit/pages/ApiKeysPage.test.tsx index cae9497c3d..5aed8d3781 100644 --- a/frontend/__tests__/unit/pages/ApiKeysPage.test.tsx +++ b/frontend/__tests__/unit/pages/ApiKeysPage.test.tsx @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { useQuery, useMutation } from '@apollo/client/react' +import { addToast } from '@heroui/toast' import { screen, waitFor, fireEvent, within } from '@testing-library/react' import { mockApiKeys, mockCreateApiKeyResult } from '@unit/data/mockApiKeysData' import { format, addDays } from 'date-fns' @@ -21,7 +22,7 @@ jest.mock('@heroui/modal', () => { const Stub = ({ children }: { children: React.ReactNode }) => <>{children} return { Modal: ({ isOpen, children }: { isOpen: boolean; children: React.ReactNode }) => - isOpen ?
{children}
: null, + isOpen ? {children} : null, ModalContent: Stub, ModalHeader: Stub, ModalBody: Stub, @@ -33,206 +34,418 @@ jest.mock('@heroui/toast', () => ({ addToast: jest.fn(), })) -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: () => , +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: jest.fn() }), })) beforeAll(() => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2025-12-04T00:00:00.000Z')) + Object.defineProperty(navigator, 'clipboard', { - value: { writeText: jest.fn() }, + value: { writeText: jest.fn().mockResolvedValue(undefined) }, writable: true, }) }) -jest.mock('next/navigation', () => ({ - useRouter: () => ({ - push: jest.fn(), - }), -})) +afterAll(() => { + jest.useRealTimers() +}) describe('ApiKeysPage Component', () => { const mockUseQuery = useQuery as unknown as jest.Mock const mockUseMutation = useMutation as unknown as jest.Mock const mockRefetch = jest.fn() - const mockCreateMutation = jest.fn().mockResolvedValue(mockCreateApiKeyResult) - const mockRevokeMutation = jest - .fn() - .mockResolvedValue({ data: { revokeApiKey: { success: true } } }) + const mockCreateMutation = jest.fn() + const mockRevokeMutation = jest.fn() + + const createMutationFn = ( + mockFn: jest.Mock, + options?: { onCompleted?: (data: unknown) => void } + ) => { + return jest.fn(async (vars) => { + const result = await mockFn(vars) + if (options?.onCompleted) { + options.onCompleted(result.data) + } + return result + }) + } + + const setupMocks = (overrides = {}, mutationLoading?: { create: boolean; revoke: boolean }) => { + const loading = mutationLoading ?? { create: false, revoke: false } - beforeEach(() => { mockUseQuery.mockReturnValue({ data: mockApiKeys, loading: false, error: null, refetch: mockRefetch, + ...overrides, }) - mockUseMutation.mockImplementation((mutation) => { + mockUseMutation.mockImplementation((mutation, options) => { if (mutation === CreateApiKeyDocument) { - return [mockCreateMutation, { loading: false }] + return [createMutationFn(mockCreateMutation, options), { loading: loading.create }] } if (mutation === RevokeApiKeyDocument) { - return [mockRevokeMutation, { loading: false }] + return [createMutationFn(mockRevokeMutation, options), { loading: loading.revoke }] } return [jest.fn(), { loading: false }] }) - }) - afterEach(() => { - jest.clearAllMocks() - }) + mockCreateMutation.mockResolvedValue(mockCreateApiKeyResult) + mockRevokeMutation.mockResolvedValue({ data: { revokeApiKey: { success: true } } }) + } - test('renders loading skeleton initially', async () => { - mockUseQuery.mockReturnValue({ - data: null, - loading: true, - error: null, - refetch: mockRefetch, + const openCreateModal = async () => { + fireEvent.click(screen.getByText(/Create New Key/)) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + } + + const fillKeyForm = (name: string, expiry?: string) => { + fireEvent.change(screen.getByLabelText('API Key Name'), { target: { value: name } }) + if (expiry) { + fireEvent.change(screen.getByLabelText('Expiration Date'), { target: { value: expiry } }) + } + } + + beforeEach(() => setupMocks()) + afterEach(() => jest.clearAllMocks()) + + describe('Loading and Data States', () => { + test('renders loading skeleton initially', () => { + setupMocks({ data: null, loading: true }) + render() + expect(screen.queryByText('API Key Management')).not.toBeInTheDocument() + // Note: Using direct DOM query as skeleton components don't have semantic roles + expect(document.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0) }) - render() + test('displays API keys list when data is fetched', async () => { + render() + await waitFor(() => { + expect(screen.getByText('mock key 1')).toBeInTheDocument() + expect(screen.getByText('mock key 2')).toBeInTheDocument() + }) + }) - expect(screen.queryByText('API Key Management')).not.toBeInTheDocument() + test('displays empty state message when no API keys exist', async () => { + setupMocks({ data: { apiKeys: [], activeApiKeyCount: 0 } }) + render() + await waitFor(() => { + expect(screen.getByText("You don't have any API keys yet.")).toBeInTheDocument() + }) + }) - const skeletonElements = document.querySelectorAll('.animate-pulse') - expect(skeletonElements.length).toBeGreaterThan(0) + test('displays error message when query fails', async () => { + setupMocks({ data: null, error: new Error('Failed to fetch') }) + render() + await waitFor(() => { + expect(screen.getByText('Error loading API keys')).toBeInTheDocument() + }) + }) }) - test('displays the list of API keys when data is fetched', async () => { - const activeKeys = { - apiKeys: mockApiKeys.apiKeys.filter((key) => !key.isRevoked), - } + describe('API Key Creation', () => { + test('creates API key with default 30-day expiry', async () => { + render() + await openCreateModal() + fillKeyForm('Test New Key') + fireEvent.click(screen.getByRole('button', { name: /create api key/i })) + + const expectedDate = format(addDays(new Date(), 30), 'yyyy-MM-dd') + const expectedIso = new Date(`${expectedDate}T00:00:00.000Z`).toISOString() + + await waitFor(() => { + expect(mockCreateMutation).toHaveBeenCalledWith({ + variables: { + name: 'Test New Key', + expiresAt: expectedIso, + }, + }) + }) + }) - mockUseQuery.mockReturnValue({ - data: activeKeys, - loading: false, - error: null, - refetch: mockRefetch, + test('creates API key with custom expiry date', async () => { + render() + await openCreateModal() + fillKeyForm('Custom Expiry Key', '2025-12-31') + fireEvent.click(screen.getByRole('button', { name: /create api key/i })) + + await waitFor(() => { + expect(mockCreateMutation).toHaveBeenCalledWith({ + variables: { + name: 'Custom Expiry Key', + expiresAt: new Date('2025-12-31T00:00:00.000Z').toISOString(), + }, + }) + }) }) - render() + test('uses quick expiry buttons (90 days and 1 year)', async () => { + render() + await openCreateModal() + + fireEvent.click(screen.getByRole('button', { name: /90 days/i })) + let expiryInput = screen.getByLabelText('Expiration Date') as HTMLInputElement + expect(expiryInput.value).toBe(format(addDays(new Date(), 90), 'yyyy-MM-dd')) - await waitFor(() => { - expect(screen.getByText('mock key 1')).toBeInTheDocument() - expect(screen.getByText('mock key 2')).toBeInTheDocument() - expect(screen.queryByText('revoked key')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /1 year/i })) + expiryInput = screen.getByLabelText('Expiration Date') as HTMLInputElement + expect(expiryInput.value).toBe(format(addDays(new Date(), 365), 'yyyy-MM-dd')) }) }) - test('displays a message when there are no API keys', async () => { - mockUseQuery.mockReturnValue({ - data: { apiKeys: [] }, - loading: false, - error: null, - refetch: mockRefetch, + describe('Form Validation', () => { + test('validates name length exceeds 100 characters', async () => { + render() + await openCreateModal() + fillKeyForm('a'.repeat(101)) + fireEvent.click(screen.getByRole('button', { name: /create api key/i })) + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith({ + title: 'Error', + description: 'Name must be less than 100 characters', + color: 'danger', + }) + expect(mockCreateMutation).not.toHaveBeenCalled() + }) }) - render() + test('validates name contains only allowed characters', async () => { + render() + await openCreateModal() + fillKeyForm('invalid@name!') + fireEvent.click(screen.getByRole('button', { name: /create api key/i })) + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith({ + title: 'Error', + description: 'Name can only contain letters, numbers, spaces, and hyphens', + color: 'danger', + }) + expect(mockCreateMutation).not.toHaveBeenCalled() + }) + }) - await waitFor(() => { - expect(screen.getByText("You don't have any API keys yet.")).toBeInTheDocument() + test('validates expiration date is required', async () => { + render() + await openCreateModal() + + const expiryInput = screen.getByLabelText('Expiration Date') as HTMLInputElement + fireEvent.change(expiryInput, { target: { value: '' } }) + fireEvent.change(screen.getByLabelText('API Key Name'), { target: { value: 'Valid Name' } }) + fireEvent.click(screen.getByRole('button', { name: /create api key/i })) + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith({ + title: 'Error', + description: 'Please select an expiration date', + color: 'danger', + }) + expect(mockCreateMutation).not.toHaveBeenCalled() + }) }) }) - test('displays error message when query fails', async () => { - const errorMessage = 'Failed to fetch API keys' - mockUseQuery.mockReturnValue({ - data: null, - loading: false, - error: new Error(errorMessage), - refetch: mockRefetch, + describe('API Key Revocation', () => { + test('revokes API key after confirmation', async () => { + render() + const row = (await screen.findByText('mock key 1')).closest('tr')! + fireEvent.click(within(row).getByRole('button')) + + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByText(/Are you sure you want to revoke/)).toBeInTheDocument() + + fireEvent.click(within(dialog).getByRole('button', { name: /Revoke Key/i })) + await waitFor(() => { + expect(mockRevokeMutation).toHaveBeenCalledWith({ variables: { uuid: '1' } }) + }) }) - render() + test('cancels revocation when cancel button is clicked', async () => { + render() + const row = (await screen.findByText('mock key 1')).closest('tr')! + fireEvent.click(within(row).getByRole('button')) + + const dialog = await screen.findByRole('dialog') + fireEvent.click(within(dialog).getByRole('button', { name: /Cancel/i })) - await waitFor(() => { - expect(screen.getByText('Error loading API keys')).toBeInTheDocument() + await waitFor(() => { + expect(mockRevokeMutation).not.toHaveBeenCalled() + }) }) }) - test('allows a user to create a new API key', async () => { - render() - fireEvent.click(screen.getByText(/Create New Key/)) + describe('Key Limits and UI State', () => { + test('disables create button and shows warning at maximum key limit', async () => { + setupMocks({ data: { apiKeys: mockApiKeys.apiKeys, activeApiKeyCount: 3 } }) + render() - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText(/Create New Key/)).toBeDisabled() + expect( + screen.getByText(/You've reached the maximum number of API keys/) + ).toBeInTheDocument() + }) }) - const nameInput = screen.getByLabelText('API Key Name') - fireEvent.change(nameInput, { target: { value: 'Test New Key' } }) - fireEvent.click(screen.getByRole('button', { name: /create api key/i })) - - await waitFor(() => { - const expectedExpiry = addDays(new Date(), 30) - const expectedVariables = { - name: 'Test New Key', - expiresAt: new Date(format(expectedExpiry, 'yyyy-MM-dd')), - } + test('displays correct active key count', async () => { + setupMocks({ data: { apiKeys: mockApiKeys.apiKeys, activeApiKeyCount: 2 } }) + render() + await waitFor(() => { + expect(screen.getByText(/2\s*\/\s*3\s*active keys/i)).toBeInTheDocument() + }) + }) - expect(mockCreateMutation).toHaveBeenCalledWith({ - variables: expect.objectContaining({ - name: expectedVariables.name, - expiresAt: expect.any(String), - }), + test('displays API key usage information', async () => { + render() + await waitFor(() => { + expect(screen.getByText('API Key Usage')).toBeInTheDocument() + expect(screen.getByText('X-API-Key')).toBeInTheDocument() + expect(screen.getByText(/Keep your API keys secure/)).toBeInTheDocument() }) }) }) - test('handles API key creation with expiry date', async () => { - render() - fireEvent.click(screen.getByText(/Create New Key/)) + describe('Edge Cases', () => { + test('disables create button when createLoading is true', async () => { + setupMocks({}, { create: true, revoke: false }) - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) + render() + await openCreateModal() + fillKeyForm('Test Key') - fireEvent.change(screen.getByLabelText('API Key Name'), { - target: { value: 'Test Key with Expiry' }, + const createButton = screen.getByRole('button', { name: /create api key/i }) + expect(createButton).toBeDisabled() }) - const expiryInput = screen.getByLabelText('Expiration Date') - fireEvent.change(expiryInput, { - target: { value: '2025-12-31' }, + + test('cancels modal and resets state', async () => { + render() + await openCreateModal() + fillKeyForm('Test Key', '2026-01-01') + + const cancelButton = screen.getByRole('button', { name: /Cancel/i }) + fireEvent.click(cancelButton) + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + fireEvent.click(screen.getByText(/Create New Key/)) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + const nameInput = screen.getByLabelText('API Key Name') as HTMLInputElement + expect(nameInput.value).toBe('') }) - fireEvent.click(screen.getByRole('button', { name: /create api key/i })) - await waitFor(() => { - expect(mockCreateMutation).toHaveBeenCalledWith({ - variables: { - name: 'Test Key with Expiry', - expiresAt: new Date('2025-12-31T00:00:00.000Z').toISOString(), + test('handles API keys with no expiration date', async () => { + setupMocks({ + data: { + apiKeys: [ + { + uuid: '1', + name: 'Never expires key', + isRevoked: false, + createdAt: '2025-07-11T08:17:45.406011+00:00', + expiresAt: null, + }, + ], + activeApiKeyCount: 1, }, }) + + render() + await waitFor(() => { + expect(screen.getByText('Never expires key')).toBeInTheDocument() + expect(screen.getByText('Never')).toBeInTheDocument() + }) }) - }) - test('displays API key usage information', async () => { - render() - await waitFor(() => { - expect(screen.getByText('API Key Usage')).toBeInTheDocument() - expect(screen.getByText(/Include your API key in the/)).toBeInTheDocument() - expect(screen.getByText('X-API-Key')).toBeInTheDocument() - expect(screen.getByText(/Keep your API keys secure/)).toBeInTheDocument() + test('handles exactly 100 character name (boundary case)', async () => { + render() + await openCreateModal() + + const exactlyHundred = 'a'.repeat(100) + fillKeyForm(exactlyHundred) + fireEvent.click(screen.getByRole('button', { name: /create api key/i })) + + await waitFor(() => { + expect(mockCreateMutation).toHaveBeenCalledWith({ + variables: expect.objectContaining({ + name: exactlyHundred, + }), + }) + }) }) - }) - test('handles API key revocation', async () => { - render() - const keyNameCell = await screen.findByText('mock key 1') - const row = keyNameCell.closest('tr') - expect(row).not.toBeNull() - const revokeButton = within(row!).getByRole('button') - fireEvent.click(revokeButton) - const dialog = await screen.findByRole('dialog') - expect(within(dialog).getByText('Revoke API Key')).toBeInTheDocument() - expect( - within(dialog).getByText(/Are you sure you want to revoke the key named/) - ).toBeInTheDocument() - const confirmRevokeButton = within(dialog).getByRole('button', { name: /Revoke Key/i }) - fireEvent.click(confirmRevokeButton) - await waitFor(() => { - expect(mockRevokeMutation).toHaveBeenCalledWith({ - variables: { uuid: '1' }, + test('displays button text with correct active key count', async () => { + setupMocks({ data: { apiKeys: mockApiKeys.apiKeys, activeApiKeyCount: 1 } }) + render() + await waitFor(() => { + expect(screen.getByText(/Create New Key \(1\/3\)/)).toBeInTheDocument() + }) + }) + + test('accepts valid name with hyphens and spaces', async () => { + render() + await openCreateModal() + + const validName = 'My API Key-123' + fillKeyForm(validName) + fireEvent.click(screen.getByRole('button', { name: /create api key/i })) + + await waitFor(() => { + expect(mockCreateMutation).toHaveBeenCalledWith({ + variables: expect.objectContaining({ + name: validName, + }), + }) + }) + }) + + test('displays multiple keys in table correctly', async () => { + setupMocks({ + data: { + apiKeys: [ + ...mockApiKeys.apiKeys, + { + uuid: '3', + name: 'third key', + isRevoked: false, + createdAt: '2025-07-10T08:17:45.406011+00:00', + expiresAt: '2025-12-31T00:00:00+00:00', + }, + ], + activeApiKeyCount: 3, + }, + }) + + render() + await waitFor(() => { + expect(screen.getByText('mock key 1')).toBeInTheDocument() + expect(screen.getByText('mock key 2')).toBeInTheDocument() + expect(screen.getByText('third key')).toBeInTheDocument() }) }) + + test('displays table with date columns', async () => { + render() + await waitFor(() => { + const table = screen.getByRole('table') + expect(table).toBeInTheDocument() + expect(screen.getByText('Created')).toBeInTheDocument() + expect(screen.getByText('Expires')).toBeInTheDocument() + }) + }) + + test('disables create button when name input is empty', async () => { + render() + await openCreateModal() + + const createButton = screen.getByRole('button', { name: /create api key/i }) + expect(createButton).toBeDisabled() + }) }) }) diff --git a/frontend/__tests__/unit/pages/ChapterDetails.test.tsx b/frontend/__tests__/unit/pages/ChapterDetails.test.tsx index a531ebaf19..6c34a7c4e3 100644 --- a/frontend/__tests__/unit/pages/ChapterDetails.test.tsx +++ b/frontend/__tests__/unit/pages/ChapterDetails.test.tsx @@ -9,10 +9,6 @@ jest.mock('@apollo/client/react', () => ({ useQuery: jest.fn(), })) -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: () => , -})) - jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useParams: () => ({ @@ -28,6 +24,7 @@ jest.mock('next/navigation', () => ({ ...jest.requireActual('next/navigation'), useRouter: jest.fn(() => mockRouter), useParams: () => ({ chapterKey: 'test-chapter' }), + usePathname: jest.fn(() => '/chapters/test-chapter'), })) describe('chapterDetailsPage Component', () => { diff --git a/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx b/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx index 6d1f0eb1b1..a24ad0447c 100644 --- a/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx +++ b/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx @@ -10,10 +10,6 @@ jest.mock('@apollo/client/react', () => ({ useQuery: jest.fn(), })) -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: () => , -})) - const mockRouter = { push: jest.fn(), } @@ -22,6 +18,7 @@ jest.mock('next/navigation', () => ({ ...jest.requireActual('next/navigation'), useRouter: jest.fn(() => mockRouter), useParams: () => ({ committeeKey: 'test-committee' }), + usePathname: jest.fn(() => '/committees/test-committee'), })) describe('CommitteeDetailsPage Component', () => { @@ -52,7 +49,7 @@ describe('CommitteeDetailsPage Component', () => { test('renders committee data correctly', async () => { render() await waitFor(() => { - expect(screen.getByText('Test Committee')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Test Committee' })).toBeInTheDocument() }) expect(screen.getByText('This is a test committee summary.')).toBeInTheDocument() expect(screen.getByText('Leader 1')).toBeInTheDocument() diff --git a/frontend/__tests__/unit/pages/CreateModule.test.tsx b/frontend/__tests__/unit/pages/CreateModule.test.tsx index 6368b8645e..79279a8f59 100644 --- a/frontend/__tests__/unit/pages/CreateModule.test.tsx +++ b/frontend/__tests__/unit/pages/CreateModule.test.tsx @@ -1,20 +1,25 @@ import { useMutation, useQuery, useApolloClient } from '@apollo/client/react' -import { screen, fireEvent, waitFor, act } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { useRouter, useParams } from 'next/navigation' import { useSession } from 'next-auth/react' import { render } from 'wrappers/testUtil' import CreateModulePage from 'app/my/mentorship/programs/[programKey]/modules/create/page' +// Mock dependencies to isolate the component +jest.mock('@heroui/toast', () => ({ addToast: jest.fn() })) +jest.mock('app/global-error', () => ({ + handleAppError: jest.fn(), + ErrorDisplay: ({ title }: { title: string }) =>
{title}
, +})) jest.mock('next-auth/react', () => ({ ...jest.requireActual('next-auth/react'), useSession: jest.fn(), })) - jest.mock('next/navigation', () => ({ useRouter: jest.fn(), useParams: jest.fn(), })) - jest.mock('@apollo/client/react', () => ({ useMutation: jest.fn(), useQuery: jest.fn(), @@ -26,26 +31,27 @@ describe('CreateModulePage', () => { const mockReplace = jest.fn() const mockCreateModule = jest.fn() + const mockQuery = jest.fn().mockResolvedValue({ + data: { + searchProjects: [{ id: '123', name: 'Awesome Project' }], + }, + }) + beforeEach(() => { - jest.useFakeTimers() ;(useRouter as jest.Mock).mockReturnValue({ push: mockPush, replace: mockReplace }) ;(useParams as jest.Mock).mockReturnValue({ programKey: 'test-program' }) ;(useApolloClient as jest.Mock).mockReturnValue({ - query: jest.fn().mockResolvedValue({ - data: { - searchProjects: [{ id: '123', name: 'Awesome Project' }], - }, - }), + query: mockQuery, }) }) afterEach(() => { - jest.runOnlyPendingTimers() - jest.useRealTimers() jest.clearAllMocks() }) it('submits the form and navigates to programs page', async () => { + const user = userEvent.setup() + ;(useSession as jest.Mock).mockReturnValue({ data: { user: { login: 'admin-user' } }, status: 'authenticated', @@ -59,50 +65,57 @@ describe('CreateModulePage', () => { loading: false, }) ;(useMutation as unknown as jest.Mock).mockReturnValue([ - mockCreateModule.mockResolvedValue({}), + mockCreateModule.mockResolvedValue({ + data: { + createModule: { + key: 'my-test-module', + }, + }, + }), { loading: false }, ]) render() // Fill all inputs - fireEvent.change(screen.getByLabelText(/Module Name/i), { - target: { value: 'My Test Module' }, - }) - fireEvent.change(screen.getByLabelText(/Description/i), { - target: { value: 'This is a test module' }, - }) - fireEvent.change(screen.getByLabelText(/Start Date/i), { - target: { value: '2025-07-15' }, - }) - fireEvent.change(screen.getByLabelText(/End Date/i), { - target: { value: '2025-08-15' }, - }) - fireEvent.change(screen.getByLabelText(/Domains/i), { - target: { value: 'AI, ML' }, - }) - fireEvent.change(screen.getByLabelText(/Tags/i), { - target: { value: 'react, graphql' }, - }) + await user.type(screen.getByLabelText('Name'), 'My Test Module') + await user.type(screen.getByLabelText(/Description/i), 'This is a test module') + await user.type(screen.getByLabelText(/Start Date/i), '2025-07-15') + await user.type(screen.getByLabelText(/End Date/i), '2025-08-15') + await user.type(screen.getByLabelText(/Domains/i), 'AI, ML') + await user.type(screen.getByLabelText(/Tags/i), 'react, graphql') - // Simulate project typing and suggestion click - fireEvent.change(screen.getByLabelText(/Project Name/i), { - target: { value: 'Awesome Project' }, + const projectInput = await waitFor(() => { + return screen.getByPlaceholderText('Start typing project name...') }) - // Run debounce - await act(async () => { - jest.runAllTimers() - }) + await user.type(projectInput, 'Aw') - const suggestionButton = await screen.findByRole('button', { - name: /Awesome Project/i, - }) + await waitFor( + () => { + expect(mockQuery).toHaveBeenCalled() + }, + { timeout: 2000 } + ) + + const projectOption = await waitFor( + () => { + return ( + screen.queryByRole('option', { name: /Awesome Project/i }) || + screen.queryByText('Awesome Project') || + document.querySelector('[data-key="123"]') + ) + }, + { timeout: 2000 } + ) - fireEvent.click(suggestionButton) + if (projectOption) { + await user.click(projectOption) + } else { + await user.type(projectInput, '{ArrowDown}{Enter}') + } - // Now the form should be valid → submit - fireEvent.click(screen.getByRole('button', { name: /Create Module/i })) + await user.click(screen.getByRole('button', { name: /Create Module/i })) await waitFor(() => { expect(mockCreateModule).toHaveBeenCalled() diff --git a/frontend/__tests__/unit/pages/CreateProgram.test.tsx b/frontend/__tests__/unit/pages/CreateProgram.test.tsx index b32394cec8..9cdac1d5a6 100644 --- a/frontend/__tests__/unit/pages/CreateProgram.test.tsx +++ b/frontend/__tests__/unit/pages/CreateProgram.test.tsx @@ -1,4 +1,4 @@ -import { useMutation } from '@apollo/client/react' +import { useMutation, useApolloClient } from '@apollo/client/react' import { addToast } from '@heroui/toast' import { fireEvent, screen, waitFor } from '@testing-library/react' import { useRouter as mockUseRouter } from 'next/navigation' @@ -9,6 +9,7 @@ import CreateProgramPage from 'app/my/mentorship/programs/create/page' jest.mock('@apollo/client/react', () => ({ ...jest.requireActual('@apollo/client/react'), useMutation: jest.fn(), + useApolloClient: jest.fn(), })) jest.mock('next/navigation', () => ({ @@ -29,12 +30,22 @@ jest.mock('@heroui/toast', () => ({ const mockRouterPush = jest.fn() const mockCreateProgram = jest.fn() +const mockQuery = jest.fn().mockResolvedValue({ + data: { + myPrograms: { + programs: [], + }, + }, +}) describe('CreateProgramPage (comprehensive tests)', () => { beforeEach(() => { jest.clearAllMocks() ;(mockUseRouter as jest.Mock).mockReturnValue({ push: mockRouterPush }) ;(useMutation as unknown as jest.Mock).mockReturnValue([mockCreateProgram, { loading: false }]) + ;(useApolloClient as jest.Mock).mockReturnValue({ + query: mockQuery, + }) }) test('redirects if unauthenticated', async () => { @@ -60,7 +71,7 @@ describe('CreateProgramPage (comprehensive tests)', () => { render() - expect(screen.queryByLabelText('Program Name *')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Name')).not.toBeInTheDocument() }) test('redirects with toast if not a project leader', async () => { @@ -103,7 +114,7 @@ describe('CreateProgramPage (comprehensive tests)', () => { render() - expect(await screen.findByLabelText('Program Name *')).toBeInTheDocument() + expect(await screen.findByLabelText('Name')).toBeInTheDocument() }) test('submits form and redirects on success', async () => { @@ -127,16 +138,16 @@ describe('CreateProgramPage (comprehensive tests)', () => { render() - fireEvent.change(screen.getByLabelText('Program Name *'), { + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Test Program' }, }) - fireEvent.change(screen.getByLabelText('Description *'), { + fireEvent.change(screen.getByLabelText(/^Description/), { target: { value: 'A description' }, }) - fireEvent.change(screen.getByLabelText('Start Date *'), { + fireEvent.change(screen.getByLabelText('Start Date'), { target: { value: '2025-01-01' }, }) - fireEvent.change(screen.getByLabelText('End Date *'), { + fireEvent.change(screen.getByLabelText('End Date'), { target: { value: '2025-12-31' }, }) fireEvent.change(screen.getByLabelText('Tags'), { @@ -146,7 +157,7 @@ describe('CreateProgramPage (comprehensive tests)', () => { target: { value: 'domain1, domain2' }, }) - fireEvent.submit(screen.getByText('Save').closest('form')!) + fireEvent.submit(screen.getByText('Save').closest('form')) await waitFor(() => { expect(mockCreateProgram).toHaveBeenCalledWith({ @@ -154,7 +165,7 @@ describe('CreateProgramPage (comprehensive tests)', () => { input: { name: 'Test Program', description: 'A description', - menteesLimit: 5, + menteesLimit: 0, startedAt: '2025-01-01', endedAt: '2025-12-31', tags: ['tag1', 'tag2'], @@ -186,20 +197,20 @@ describe('CreateProgramPage (comprehensive tests)', () => { render() - fireEvent.change(screen.getByLabelText('Program Name *'), { + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Test Program' }, }) - fireEvent.change(screen.getByLabelText('Description *'), { + fireEvent.change(screen.getByLabelText(/^Description/), { target: { value: 'A description' }, }) - fireEvent.change(screen.getByLabelText('Start Date *'), { + fireEvent.change(screen.getByLabelText('Start Date'), { target: { value: '2025-01-01' }, }) - fireEvent.change(screen.getByLabelText('End Date *'), { + fireEvent.change(screen.getByLabelText('End Date'), { target: { value: '2025-12-31' }, }) - fireEvent.submit(screen.getByText('Save').closest('form')!) + fireEvent.submit(screen.getByText('Save').closest('form')) await waitFor(() => { expect(addToast).toHaveBeenCalledWith( diff --git a/frontend/__tests__/unit/pages/EditModule.test.tsx b/frontend/__tests__/unit/pages/EditModule.test.tsx index b535b00259..4b8deef346 100644 --- a/frontend/__tests__/unit/pages/EditModule.test.tsx +++ b/frontend/__tests__/unit/pages/EditModule.test.tsx @@ -1,4 +1,5 @@ import { useMutation, useQuery, useApolloClient } from '@apollo/client/react' +import { addToast } from '@heroui/toast' import { screen, fireEvent, waitFor, act } from '@testing-library/react' import { useRouter, useParams } from 'next/navigation' import { useSession } from 'next-auth/react' @@ -23,6 +24,10 @@ jest.mock('@apollo/client/react', () => ({ useApolloClient: jest.fn(), })) +jest.mock('@heroui/toast', () => ({ + addToast: jest.fn(), +})) + describe('EditModulePage', () => { const mockPush = jest.fn() const mockReplace = jest.fn() @@ -86,8 +91,8 @@ describe('EditModulePage', () => { expect(await screen.findByDisplayValue('Existing Module')).toBeInTheDocument() // Modify values - fireEvent.change(screen.getByLabelText(/Module Name/i), { - target: { value: 'Updated Module Name' }, + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'Updated Name' }, }) fireEvent.change(screen.getByLabelText(/Description/i), { target: { value: 'Updated description' }, @@ -98,7 +103,8 @@ describe('EditModulePage', () => { fireEvent.change(screen.getByLabelText(/Tags/i), { target: { value: 'graphql, react' }, }) - fireEvent.change(screen.getByLabelText(/Project Name/i), { + const projectInput = screen.getByPlaceholderText(/Start typing project name/i) + fireEvent.change(projectInput, { target: { value: 'Awesome Project' }, }) @@ -110,7 +116,48 @@ describe('EditModulePage', () => { await waitFor(() => { expect(mockUpdateModule).toHaveBeenCalled() - expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/test-program') + expect(mockPush).toHaveBeenCalledWith( + '/my/mentorship/programs/test-program/modules/test-module' + ) + }) + }) + + it('shows access denied and redirects if user is not an admin', async () => { + ;(useSession as jest.Mock).mockReturnValue({ + data: { user: { login: 'non-admin-user' } }, + status: 'authenticated', + }) + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + loading: false, + data: { + getProgram: { + admins: [{ login: 'admin-user' }], // User is not in this list + }, + getModule: { + name: 'Existing Module', + }, + }, + }) + + render() + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith({ + title: 'Access Denied', + description: 'Only program admins can edit modules.', + color: 'danger', + variant: 'solid', + timeout: 4000, + }) + }) + + // Advance timers to trigger the redirect + act(() => { + jest.advanceTimersByTime(1500) + }) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/my/mentorship/programs/test-program') }) }) diff --git a/frontend/__tests__/unit/pages/EditProgram.test.tsx b/frontend/__tests__/unit/pages/EditProgram.test.tsx index a82e7a7bf6..938a62670d 100644 --- a/frontend/__tests__/unit/pages/EditProgram.test.tsx +++ b/frontend/__tests__/unit/pages/EditProgram.test.tsx @@ -1,4 +1,4 @@ -import { useQuery } from '@apollo/client/react' +import { useQuery, useApolloClient } from '@apollo/client/react' import { render, screen, waitFor } from '@testing-library/react' import { useParams, useRouter } from 'next/navigation' import { useSession } from 'next-auth/react' @@ -18,6 +18,7 @@ jest.mock('@apollo/client/react', () => { ...actual, useMutation: jest.fn(() => [jest.fn(), { loading: false }]), useQuery: jest.fn(), + useApolloClient: jest.fn(), } }) jest.mock('@heroui/toast', () => ({ @@ -27,10 +28,20 @@ jest.mock('@heroui/toast', () => ({ describe('EditProgramPage', () => { const mockPush = jest.fn() const mockReplace = jest.fn() + const mockQuery = jest.fn().mockResolvedValue({ + data: { + myPrograms: { + programs: [], + }, + }, + }) beforeEach(() => { ;(useRouter as jest.Mock).mockReturnValue({ push: mockPush, replace: mockReplace }) ;(useParams as jest.Mock).mockReturnValue({ programKey: 'program_1' }) + ;(useApolloClient as jest.Mock).mockReturnValue({ + query: mockQuery, + }) jest.clearAllMocks() }) @@ -88,7 +99,7 @@ describe('EditProgramPage', () => { render() - expect(await screen.findByLabelText('Program Name *')).toBeInTheDocument() + expect(await screen.findByLabelText('Name')).toBeInTheDocument() expect(screen.getByDisplayValue('Test')).toBeInTheDocument() }) }) diff --git a/frontend/__tests__/unit/pages/Header.test.tsx b/frontend/__tests__/unit/pages/Header.test.tsx index 79c9a04cf9..092454557c 100644 --- a/frontend/__tests__/unit/pages/Header.test.tsx +++ b/frontend/__tests__/unit/pages/Header.test.tsx @@ -32,19 +32,17 @@ jest.mock('next/link', () => { } }) -// Mock FontAwesome components with proper icon mapping -jest.mock('@fortawesome/react-fontawesome', () => ({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - FontAwesomeIcon: ({ icon, className }: any) => { - // Map icon names to test IDs based on the actual icons used - const iconMap: { [key: string]: string } = { - bars: 'icon-bars', - xmark: 'icon-xmark', - times: 'icon-times', - } - const testId = iconMap[icon.iconName] || `icon-${icon.iconName}` - return - }, +jest.mock('react-icons/fa', () => ({ + FaBars: (props: React.SVGProps) => , + FaTimes: (props: React.SVGProps) => , + FaRegHeart: (props: React.SVGProps) => , + FaRegStar: (props: React.SVGProps) => , + FaHeart: (props: React.SVGProps) => ( + + ), + FaStar: (props: React.SVGProps) => ( + + ), })) // Mock HeroUI Button @@ -220,7 +218,7 @@ describe('Header Component', () => { // Use getAllByRole for multiple elements const logoImages = screen.getAllByRole('img', { name: /owasp logo/i }) - expect(logoImages.length).toBe(4) // 2 in desktop header + 2 in mobile menu + expect(logoImages.length).toBe(2) // 1 in desktop header + 1 in mobile menu const brandTexts = screen.getAllByText('Nest') expect(brandTexts.length).toBe(2) // One in desktop header, one in mobile menu @@ -235,7 +233,7 @@ describe('Header Component', () => { expect(screen.getByRole('banner')).toBeInTheDocument() const logoImages = screen.getAllByRole('img', { name: /owasp logo/i }) - expect(logoImages.length).toBe(4) + expect(logoImages.length).toBe(2) const brandTexts = screen.getAllByText('Nest') expect(brandTexts.length).toBe(2) @@ -250,7 +248,7 @@ describe('Header Component', () => { renderWithSession(
) const logoImages = screen.getAllByRole('img', { name: /owasp logo/i }) - expect(logoImages.length).toBe(4) // 2 in desktop header + 2 in mobile menu + expect(logoImages.length).toBe(2) // 1 in desktop header + 1 in mobile menu for (const logo of logoImages) { expect(logo).toHaveAttribute('width', '64') @@ -259,6 +257,18 @@ describe('Header Component', () => { } }) + it('renders logo_dark.png image in both desktop and mobile header', () => { + renderWithSession(
) + + const logoImages = screen.getAllByRole('img', { name: /owasp logo/i }) + expect(logoImages.length).toBe(2) + + for (const logo of logoImages) { + expect(logo).toHaveAttribute('src', '/img/logo_dark.png') + expect(logo).toBeInTheDocument() + } + }) + it('renders Nest text branding', () => { renderWithSession(
) diff --git a/frontend/__tests__/unit/pages/Home.test.tsx b/frontend/__tests__/unit/pages/Home.test.tsx index 94e26deb09..8300ff8a4c 100644 --- a/frontend/__tests__/unit/pages/Home.test.tsx +++ b/frontend/__tests__/unit/pages/Home.test.tsx @@ -17,11 +17,6 @@ jest.mock('server/fetchAlgoliaData', () => ({ fetchAlgoliaData: jest.fn(), })) -jest.mock('wrappers/FontAwesomeIconWrapper', () => ({ - __esModule: true, - default: () => , -})) - jest.mock('@heroui/toast', () => ({ addToast: jest.fn(), })) @@ -48,7 +43,7 @@ jest.mock('components/Modal', () => { const ModalMock = jest.fn(({ isOpen, onClose, title, summary, button, description }) => { if (!isOpen) return null return ( -
+

{title}

{summary}

{description}

@@ -56,7 +51,7 @@ jest.mock('components/Modal', () => { Close {button.label} -
+ ) }) return ModalMock @@ -181,17 +176,6 @@ describe('Home', () => { }) }) - test('renders AnimatedCounter components', async () => { - render() - - await waitFor(() => { - expect(screen.getByText('Active Projects')).toBeInTheDocument() - expect(screen.getByText('Contributors')).toBeInTheDocument() - expect(screen.getByText('Local Chapters')).toBeInTheDocument() - expect(screen.getByText('Countries')).toBeInTheDocument() - }) - }) - test('handles missing data gracefully', async () => { ;(useQuery as unknown as jest.Mock).mockReturnValue({ data: mockGraphQLData, @@ -271,18 +255,21 @@ describe('Home', () => { ] const stats = mockGraphQLData.statsOverview - await waitFor(() => { - for (const header of headers) { - expect(screen.getByText(header)).toBeInTheDocument() - } - }) + const statTexts = [ + millify(stats.activeProjectsStats) + '+', + millify(stats.activeChaptersStats) + '+', + millify(stats.contributorsStats) + '+', + millify(stats.countriesStats) + '+', + millify(stats.slackWorkspaceStats) + '+', + ] - // Wait for animated counters to complete (2 seconds animation) - // Note: The "+" is rendered separately from the number, so we check for the number only await waitFor( () => { - for (const value of Object.values(stats)) { - expect(screen.getByText(millify(value), { exact: false })).toBeInTheDocument() + for (const stat of statTexts) { + expect(screen.getByText(stat)).toBeInTheDocument() + } + for (const header of headers) { + expect(screen.getByText(header)).toBeInTheDocument() } }, { timeout: 3000 } diff --git a/frontend/__tests__/unit/pages/IssuesPage.test.tsx b/frontend/__tests__/unit/pages/IssuesPage.test.tsx new file mode 100644 index 0000000000..16d097b9d7 --- /dev/null +++ b/frontend/__tests__/unit/pages/IssuesPage.test.tsx @@ -0,0 +1,264 @@ +import { useQuery } from '@apollo/client/react' +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react' +import { useParams, useRouter, useSearchParams } from 'next/navigation' +import { handleAppError } from 'app/global-error' +import IssuesPage from 'app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page' + +// Mock dependencies +jest.mock('@apollo/client/react', () => ({ + ...jest.requireActual('@apollo/client/react'), + useQuery: jest.fn(), +})) +jest.mock('next/navigation', () => ({ + useParams: jest.fn(), + useRouter: jest.fn(), + useSearchParams: jest.fn(), +})) + +const mockUseQuery = useQuery as unknown as jest.Mock +const mockUseParams = useParams as jest.Mock +const mockUseRouter = useRouter as jest.Mock +const mockUseSearchParams = useSearchParams as jest.Mock +const mockPush = jest.fn() +const mockReplace = jest.fn() + +const mockModuleData = { + getModule: { + name: 'Test Module', + issues: [ + { + id: '1', + objectID: '1', + number: 101, + title: 'First Issue Title', + state: 'open', + isMerged: false, + labels: ['bug'], + assignees: [ + { + avatarUrl: 'http://example.com/avatar.png', + login: 'user1', + name: 'User One', + }, + ], + }, + ], + issuesCount: 1, + availableLabels: ['bug', 'feature-request', 'documentation'], + }, +} + +jest.mock('app/global-error', () => ({ + handleAppError: jest.fn(), + ErrorDisplay: ({ title }: { title: string }) =>
{title}
, +})) + +const mockHandleAppError = handleAppError as jest.Mock + +describe('IssuesPage', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseParams.mockReturnValue({ programKey: 'prog1', moduleKey: 'mod1' }) + mockUseRouter.mockReturnValue({ push: mockPush, replace: mockReplace }) + mockUseSearchParams.mockReturnValue(new URLSearchParams()) + }) + + it('renders a loading spinner while data is being fetched', () => { + mockUseQuery.mockReturnValue({ data: undefined, loading: true, error: undefined }) + render() + expect(screen.getAllByAltText('Loading indicator').length).toBeGreaterThan(0) + }) + + it('calls handleAppError on query error', () => { + const error = new Error('Test error') + mockUseQuery.mockReturnValue({ data: undefined, loading: false, error }) + render() + expect(mockHandleAppError).toHaveBeenCalledWith(error) + }) + + it('renders a 404 error if the module is not found', () => { + mockUseQuery.mockReturnValue({ data: { getModule: null }, loading: false, error: undefined }) + render() + expect(screen.getByText('Module Not Found')).toBeInTheDocument() + }) + + it('displays a "no issues found" message when there are no issues', () => { + mockUseQuery.mockReturnValue({ + data: { getModule: { ...mockModuleData.getModule, issues: [], issuesCount: 0 } }, + loading: false, + error: undefined, + }) + render() + expect(screen.getAllByText('No issues found for the selected filter.')).toHaveLength(1) + }) + + it('renders the list of issues successfully', () => { + mockUseQuery.mockReturnValue({ data: mockModuleData, loading: false, error: undefined }) + render() + expect(screen.getByText('Test Module Issues')).toBeInTheDocument() + expect(screen.getAllByText('First Issue Title')[0]).toBeInTheDocument() + expect(screen.getAllByText('Open')[0]).toBeInTheDocument() + expect(screen.getAllByText('bug')[0]).toBeInTheDocument() + expect(screen.getAllByText('user1')[0]).toBeInTheDocument() + }) + + it('navigates to the correct issue details page on click', () => { + mockUseQuery.mockReturnValue({ data: mockModuleData, loading: false, error: undefined }) + render() + const issueTitleButton = screen.getAllByRole('button', { name: /First Issue Title/i })[0] + fireEvent.click(issueTitleButton) + expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/prog1/modules/mod1/issues/101') + }) + + it('filters issues based on the selected label', async () => { + mockUseQuery.mockReturnValue({ data: mockModuleData, loading: false, error: undefined }) + render() + + const selectTrigger = screen.getByRole('button', { name: /Label/i }) + fireEvent.click(selectTrigger) + + const listbox = await screen.findByRole('listbox') + const optionToSelect = within(listbox).getByText('bug') + fireEvent.click(optionToSelect) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('?label=bug') + }) + }) + + it('resets to page 1 when a new label is selected', async () => { + mockUseQuery.mockReturnValue({ data: mockModuleData, loading: false, error: undefined }) + render() + + const selectTrigger = screen.getByRole('button', { name: /Label/i }) + fireEvent.click(selectTrigger) + + const listbox = await screen.findByRole('listbox') + const optionToSelect = within(listbox).getByText('documentation') + fireEvent.click(optionToSelect) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('?label=documentation') + }) + }) + + it('clears the filter when "All" is selected', async () => { + mockUseQuery.mockReturnValue({ data: mockModuleData, loading: false, error: undefined }) + mockUseSearchParams.mockReturnValue(new URLSearchParams('?label=bug')) + render() + + const selectTrigger = screen.getByRole('button', { name: /Label/i }) + fireEvent.click(selectTrigger) + + const listbox = await screen.findByRole('listbox') + const optionToSelect = within(listbox).getByText('All') + fireEvent.click(optionToSelect) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('?') + }) + }) + + it('handles pagination correctly', async () => { + const twentyFiveIssues = { + getModule: { + ...mockModuleData.getModule, + issues: Array.from({ length: 25 }, (_, i) => ({ + ...mockModuleData.getModule.issues[0], + id: `${i + 1}`, + objectID: `${i + 1}`, + number: 100 + i, + title: `Issue ${i + 1}`, + })), + issuesCount: 25, + }, + } + mockUseQuery.mockReturnValue({ data: twentyFiveIssues, loading: false, error: undefined }) + render() + + const pageTwoButton = screen.getByRole('button', { name: /go to page 2/i }) + fireEvent.click(pageTwoButton) + + await waitFor(() => { + expect(mockUseQuery).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + variables: expect.objectContaining({ + offset: 20, + }), + }) + ) + }) + }) + + describe.each([ + { state: 'closed', isMerged: true, expectedText: 'Merged' }, + { state: 'closed', isMerged: false, expectedText: 'Closed' }, + ])('issue states', ({ state, isMerged, expectedText }) => { + it(`renders ${expectedText} issues correctly`, () => { + const issue = { + ...mockModuleData.getModule.issues[0], + state, + isMerged, + } + mockUseQuery.mockReturnValue({ + data: { + getModule: { ...mockModuleData.getModule, issues: [issue] }, + }, + loading: false, + error: undefined, + }) + render() + const desktopTable = screen.getByRole('table') + expect(within(desktopTable).getByText(expectedText)).toBeInTheDocument() + }) + }) + + it('renders remaining labels count if there are more than 5 labels', () => { + const manyLabelsIssue = { + ...mockModuleData.getModule.issues[0], + labels: ['bug', 'feature', 'docs', 'help', 'question', 'wontfix'], + } + mockUseQuery.mockReturnValue({ + data: { + getModule: { ...mockModuleData.getModule, issues: [manyLabelsIssue] }, + }, + loading: false, + error: undefined, + }) + render() + expect(screen.getByText('+1 more')).toBeInTheDocument() + }) + + it('renders multiple assignees correctly', () => { + const multipleAssignees = [ + { + avatarUrl: 'http://example.com/avatar.png', + login: 'user1', + name: 'User One', + }, + { + avatarUrl: 'http://example.com/avatar2.png', + login: 'user2', + name: 'User Two', + }, + ] + const multipleAssigneesIssue = { + ...mockModuleData.getModule.issues[0], + assignees: multipleAssignees, + } + mockUseQuery.mockReturnValue({ + data: { + getModule: { + ...mockModuleData.getModule, + issues: [multipleAssigneesIssue], + }, + }, + loading: false, + error: undefined, + }) + render() + const plusOneElements = screen.getAllByText(/\+1/) + expect(plusOneElements.length).toBeGreaterThan(0) + }) +}) diff --git a/frontend/__tests__/unit/pages/MenteeProfilePage.test.tsx b/frontend/__tests__/unit/pages/MenteeProfilePage.test.tsx new file mode 100644 index 0000000000..95c7256f9d --- /dev/null +++ b/frontend/__tests__/unit/pages/MenteeProfilePage.test.tsx @@ -0,0 +1,287 @@ +import { useQuery } from '@apollo/client/react' +import { render, screen, fireEvent, within } from '@testing-library/react' +import { useParams } from 'next/navigation' +import React from 'react' +import { handleAppError } from 'app/global-error' +import MenteeProfilePage from 'app/my/mentorship/programs/[programKey]/modules/[moduleKey]/mentees/[menteeKey]/page' + +// Mock dependencies +jest.mock('@apollo/client/react', () => ({ + ...jest.requireActual('@apollo/client/react'), + useQuery: jest.fn(), +})) + +jest.mock('next/navigation', () => ({ + useParams: jest.fn(), + useRouter: () => ({ + push: jest.fn(), + }), +})) + +jest.mock('app/global-error', () => ({ + handleAppError: jest.fn(), + ErrorDisplay: ({ title }: { title: string }) =>
{title}
, +})) + +// Mock components +jest.mock('components/LabelList', () => ({ + LabelList: ({ labels }: { labels: string[] }) => ( +
{labels.join(', ')}
+ ), +})) + +jest.mock('next/image', () => ({ + __esModule: true, + default: (props: Record) => { + // eslint-disable-next-line @next/next/no-img-element + return {(props.alt + }, +})) + +jest.mock('@heroui/tooltip', () => ({ + Tooltip: ({ + children, + content, + isDisabled, + }: { + children: React.ReactNode + content: string + isDisabled?: boolean + }) => { + if (isDisabled) { + return <>{children} + } + return ( +
+ {children} +
+ ) + }, +})) + +jest.mock('@heroui/select', () => { + return { + Select: ({ + children, + selectedKeys, + onSelectionChange, + 'aria-label': ariaLabel, + classNames: _classNames, + size: _size, + ...props + }: { + children: React.ReactNode + selectedKeys: Set + onSelectionChange?: (keys: Set) => void + 'aria-label'?: string + classNames?: Record + size?: string + }) => { + const [isOpen, setIsOpen] = React.useState(false) + const selectedKey = Array.from(selectedKeys)[0] || 'all' + + const handleItemClick = (key: string) => { + if (onSelectionChange) { + onSelectionChange(new Set([key])) + } + setIsOpen(false) + } + + return ( +
+ + {isOpen && ( +
+ {React.Children.map(children, (child: React.ReactElement) => { + const itemKey = String(child.key ?? '') + return React.cloneElement(child, { + 'data-key': itemKey, + onClick: () => handleItemClick(itemKey), + } as Partial) + })} +
+ )} +
+ ) + }, + SelectItem: ({ + children, + onClick, + 'data-key': dataKey, + classNames: _classNames, + ...props + }: { + children: React.ReactNode + onClick?: () => void + 'data-key'?: string + classNames?: Record + }) => ( + + ), + } +}) + +const mockUseQuery = useQuery as unknown as jest.Mock +const mockUseParams = useParams as jest.Mock +const mockHandleAppError = handleAppError as jest.Mock + +const mockMenteeData = { + getMenteeDetails: { + id: 'mentee1', + login: 'test-mentee', + name: 'Test Mentee', + avatarUrl: 'http://example.com/avatar.png', + bio: 'A test bio.', + domains: ['frontend', 'backend'], + tags: ['react', 'nodejs'], + }, + getMenteeModuleIssues: [ + { + id: 'issue1', + number: 101, + title: 'Open Issue 1', + state: 'open', + url: 'http://example.com/issue1', + labels: ['bug'], + }, + { + id: 'issue2', + number: 102, + title: 'Closed Issue 1', + state: 'closed', + url: 'http://example.com/issue2', + labels: ['feature'], + }, + { + id: 'issue3', + number: 103, + title: 'Open Issue 2', + state: 'open', + url: 'http://example.com/issue3', + labels: ['docs'], + }, + ], +} + +describe('MenteeProfilePage', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseParams.mockReturnValue({ + programKey: 'prog1', + moduleKey: 'mod1', + menteeKey: 'test-mentee', + }) + }) + + it('renders a loading spinner while data is being fetched', () => { + mockUseQuery.mockReturnValue({ data: undefined, loading: true, error: undefined }) + render() + expect(screen.getAllByAltText('Loading indicator')[0]).toBeInTheDocument() + }) + + it('calls handleAppError on query error', () => { + const error = new Error('Test error') + mockUseQuery.mockReturnValue({ data: undefined, loading: false, error }) + render() + expect(mockHandleAppError).toHaveBeenCalledWith(error) + }) + + it('renders a 404 error if the mentee is not found', () => { + mockUseQuery.mockReturnValue({ + data: { getMenteeDetails: null }, + loading: false, + error: undefined, + }) + render() + expect(screen.getByText('Mentee Not Found')).toBeInTheDocument() + }) + + it('renders the mentee profile and issues successfully', () => { + mockUseQuery.mockReturnValue({ data: mockMenteeData, loading: false, error: undefined }) + render() + + // Check header + expect(screen.getByText('Test Mentee')).toBeInTheDocument() + expect(screen.getByText('@test-mentee')).toBeInTheDocument() + expect(screen.getByText('A test bio.')).toBeInTheDocument() + + // Check domains and skills + const domainsHeading = screen.getByRole('heading', { name: /Domains/i }) + const domainsContainer = domainsHeading.parentElement + if (!domainsContainer) { + throw new Error('Domains container not found') + } + expect(within(domainsContainer).getByTestId('label-list')).toHaveTextContent( + 'frontend, backend' + ) + + const skillsHeading = screen.getByRole('heading', { name: /Skills & Technologies/i }) + const skillsContainer = skillsHeading.parentElement + if (!skillsContainer) { + throw new Error('Skills container not found') + } + expect(within(skillsContainer).getByTestId('label-list')).toHaveTextContent('react, nodejs') + + // Check issues (appear in both desktop and mobile views) + const openIssue1Elements = screen.getAllByText('Open Issue 1') + const closedIssue1Elements = screen.getAllByText('Closed Issue 1') + const openIssue2Elements = screen.getAllByText('Open Issue 2') + expect(openIssue1Elements.length).toBeGreaterThan(0) + expect(closedIssue1Elements.length).toBeGreaterThan(0) + expect(openIssue2Elements.length).toBeGreaterThan(0) + }) + + it('filters issues correctly when the dropdown is used', () => { + mockUseQuery.mockReturnValue({ data: mockMenteeData, loading: false, error: undefined }) + render() + + const filterSelect = screen.getByTestId('select-trigger') + + // Filter for open issues + fireEvent.click(filterSelect) + const openOption = screen.getByText('Open (2)') + fireEvent.click(openOption) + const openIssue1Elements = screen.getAllByText('Open Issue 1') + const openIssue2Elements = screen.getAllByText('Open Issue 2') + expect(openIssue1Elements.length).toBeGreaterThan(0) + expect(openIssue2Elements.length).toBeGreaterThan(0) + expect(screen.queryByText('Closed Issue 1')).not.toBeInTheDocument() + + // Filter for closed issues + fireEvent.click(filterSelect) + const closedOption = screen.getByText('Closed (1)') + fireEvent.click(closedOption) + const closedIssue1Elements = screen.getAllByText('Closed Issue 1') + expect(closedIssue1Elements.length).toBeGreaterThan(0) + expect(screen.queryByText('Open Issue 1')).not.toBeInTheDocument() + expect(screen.queryByText('Open Issue 2')).not.toBeInTheDocument() + }) + + it('shows a message when no issues are assigned', () => { + const noIssuesData = { + ...mockMenteeData, + getMenteeModuleIssues: [], + } + mockUseQuery.mockReturnValue({ data: noIssuesData, loading: false, error: undefined }) + render() + const emptyMessages = screen.getAllByText('No issues found for the selected filter.') + expect(emptyMessages.length).toBeGreaterThan(0) + }) +}) diff --git a/frontend/__tests__/unit/pages/ModuleIssueDetailsPage.test.tsx b/frontend/__tests__/unit/pages/ModuleIssueDetailsPage.test.tsx new file mode 100644 index 0000000000..5793746f3c --- /dev/null +++ b/frontend/__tests__/unit/pages/ModuleIssueDetailsPage.test.tsx @@ -0,0 +1,407 @@ +import { useQuery } from '@apollo/client/react' +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react' +import { useIssueMutations } from 'hooks/useIssueMutations' +import { useParams } from 'next/navigation' +import ModuleIssueDetailsPage from 'app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/[issueId]/page' + +// Mock dependencies +jest.mock('@apollo/client/react', () => ({ + ...jest.requireActual('@apollo/client/react'), + useQuery: jest.fn(), +})) + +jest.mock('next/navigation', () => ({ + useParams: jest.fn(), + useRouter: jest.fn(() => ({ + push: jest.fn(), + replace: jest.fn(), + })), +})) + +jest.mock('components/MarkdownWrapper', () => { + return jest.fn(({ content }: { content: string }) => ( +
{content}
+ )) +}) + +jest.mock('hooks/useIssueMutations') + +const mockUseQuery = useQuery as unknown as jest.Mock +const mockUseParams = useParams as jest.Mock +const mockUseIssueMutations = useIssueMutations as unknown as jest.Mock + +const mockAssignIssue = jest.fn() +const mockUnassignIssue = jest.fn() +const mockSetTaskDeadline = jest.fn() +const mockClearTaskDeadline = jest.fn() + +const mockIssueData = { + getModule: { + issueByNumber: { + id: '1', + title: 'Test Issue Title', + body: 'This is the issue body.', + number: 123, + state: 'open', + isMerged: false, + organizationName: 'org', + repositoryName: 'repo', + url: 'https://github.com/issue/123', + assignees: [ + { + id: 'assignee1', + login: 'user1', + name: 'User One', + avatarUrl: 'https://example.com/avatar1.png', + }, + ], + labels: ['bug', 'critical'], + pullRequests: [ + { + id: 'pr1', + title: 'Fix for test issue', + url: 'https://github.com/pr/1', + state: 'open', + mergedAt: null, + createdAt: new Date().toISOString(), + author: { + login: 'dev1', + avatarUrl: 'https://example.com/dev-avatar1.png', + }, + }, + ], + }, + taskAssignedAt: new Date().toISOString(), + taskDeadline: null, + interestedUsers: [ + { + id: 'user2', + login: 'user2', + avatarUrl: 'https://example.com/avatar2.png', + }, + ], + }, +} + +describe('ModuleIssueDetailsPage', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseParams.mockReturnValue({ + programKey: 'prog1', + moduleKey: 'mod1', + issueId: '123', + }) + mockUseIssueMutations.mockReturnValue({ + assignIssue: mockAssignIssue, + unassignIssue: mockUnassignIssue, + setTaskDeadlineMutation: mockSetTaskDeadline, + clearTaskDeadlineMutation: mockClearTaskDeadline, + assigning: false, + unassigning: false, + settingDeadline: false, + clearingDeadline: false, + isEditingDeadline: false, + setIsEditingDeadline: jest.fn(), + deadlineInput: '', + setDeadlineInput: jest.fn(), + }) + }) + + it('renders a loading spinner while data is being fetched', () => { + mockUseQuery.mockReturnValue({ data: undefined, loading: true, error: undefined }) + render() + expect(screen.getAllByAltText('Loading indicator')[0]).toBeInTheDocument() + }) + + it('renders an error display on query error', () => { + const error = new Error('Test error') + mockUseQuery.mockReturnValue({ data: undefined, loading: false, error }) + render() + expect(screen.getByText('Error Loading Issue')).toBeInTheDocument() + expect(screen.getByText(error.message)).toBeInTheDocument() + }) + + it('renders a 404 error if the issue is not found', () => { + mockUseQuery.mockReturnValue({ + data: { getModule: { issueByNumber: null } }, + loading: false, + error: undefined, + }) + render() + expect(screen.getByText('Issue Not Found')).toBeInTheDocument() + }) + + it('renders the issue details successfully', () => { + mockUseQuery.mockReturnValue({ data: mockIssueData, loading: false, error: undefined }) + render() + expect(screen.getByText('Test Issue Title')).toBeInTheDocument() + expect(screen.getByText('This is the issue body.')).toBeInTheDocument() + expect(screen.getByText('org/repo • #123')).toBeInTheDocument() + expect(screen.getAllByText('Open')[0]).toBeInTheDocument() + expect(screen.getByText('bug')).toBeInTheDocument() + expect(screen.getByText('user1')).toBeInTheDocument() + expect(screen.getByText('Fix for test issue')).toBeInTheDocument() + expect(screen.getByText('@user2')).toBeInTheDocument() + }) + + it('calls assignIssue when assigning an interested user', async () => { + mockUseQuery.mockReturnValue({ data: mockIssueData, loading: false, error: undefined }) + render() + const interestedUsersHeading = screen.getByRole('heading', { name: /Interested Users/i }) + const userGrid = interestedUsersHeading.nextElementSibling + expect(userGrid).not.toBeNull() + const assignButton = within(userGrid as HTMLElement).getByRole('button', { name: /Assign/i }) + fireEvent.click(assignButton) + + await waitFor(() => { + expect(mockAssignIssue).toHaveBeenCalledWith({ + variables: { + programKey: 'prog1', + moduleKey: 'mod1', + issueNumber: 123, + userLogin: 'user2', + }, + }) + }) + }) + + it('calls unassignIssue when unassigning a user', async () => { + mockUseQuery.mockReturnValue({ data: mockIssueData, loading: false, error: undefined }) + render() + const unassignButton = screen.getByRole('button', { name: /Unassign @user1/i }) + fireEvent.click(unassignButton) + + await waitFor(() => { + expect(mockUnassignIssue).toHaveBeenCalledWith({ + variables: { + programKey: 'prog1', + moduleKey: 'mod1', + issueNumber: 123, + userLogin: 'user1', + }, + }) + }) + }) + + it('shows "No linked pull requests" when there are none', () => { + const noPrData = { + getModule: { + ...mockIssueData.getModule, + issueByNumber: { + ...mockIssueData.getModule.issueByNumber, + pullRequests: [], + }, + }, + } + mockUseQuery.mockReturnValue({ data: noPrData, loading: false, error: undefined }) + render() + expect(screen.getByText('No linked pull requests.')).toBeInTheDocument() + }) + + it('shows "No interested users yet" when there are none', () => { + const noInterestedData = { + getModule: { + ...mockIssueData.getModule, + interestedUsers: [], + }, + } + mockUseQuery.mockReturnValue({ data: noInterestedData, loading: false, error: undefined }) + render() + expect(screen.getByText('No interested users yet.')).toBeInTheDocument() + }) + + describe('Task Timeline and Deadline', () => { + it.each([ + { dayOffset: -1, expectedText: '(overdue)', expectedColor: 'text-[#DA3633]' }, + { dayOffset: 2, expectedText: '(2 days left)', expectedColor: 'text-[#F59E0B]' }, + { + dayOffset: 10, + expectedText: '(10 days left)', + expectedColor: 'text-gray-600 dark:text-gray-300', + }, + { + dayOffset: null, + expectedText: 'No deadline set', + expectedColor: 'text-gray-600 dark:text-gray-300', + }, + ])( + 'renders deadline text "$expectedText" for deadline with offset $dayOffset', + ({ dayOffset, expectedText, expectedColor }) => { + const today = new Date() + let deadline = null + if (dayOffset !== null) { + const deadlineDate = new Date(today) + deadlineDate.setDate(today.getDate() + dayOffset) + deadline = deadlineDate.toISOString() + } + + const dataWithDeadline = { + ...mockIssueData, + getModule: { ...mockIssueData.getModule, taskDeadline: deadline }, + } + mockUseQuery.mockReturnValue({ data: dataWithDeadline, loading: false, error: undefined }) + render() + const deadlineElement = screen.getByText( + new RegExp(expectedText.replaceAll(/[()]/g, String.raw`\$&`)) + ) + expect(deadlineElement).toBeInTheDocument() + expect(deadlineElement).toHaveClass(expectedColor) + } + ) + + it('disables the deadline button when there are no assignees', () => { + const noAssigneesData = { + ...mockIssueData, + getModule: { + ...mockIssueData.getModule, + issueByNumber: { + ...mockIssueData.getModule.issueByNumber, + assignees: [], + }, + }, + } + mockUseQuery.mockReturnValue({ data: noAssigneesData, loading: false, error: undefined }) + render() + const deadlineButton = screen.getByRole('button', { name: /No deadline set/i }) + expect(deadlineButton).toBeDisabled() + }) + + it('switches to an input field when the deadline is clicked', async () => { + const setIsEditingDeadline = jest.fn() + const baseMocks = (useIssueMutations as jest.Mock)() + mockUseIssueMutations.mockReturnValue({ + ...baseMocks, + setIsEditingDeadline, + }) + mockUseQuery.mockReturnValue({ data: mockIssueData, loading: false, error: undefined }) + render() + const deadlineButton = screen.getByRole('button', { name: /No deadline set/i }) + fireEvent.click(deadlineButton) + await waitFor(() => { + expect(setIsEditingDeadline).toHaveBeenCalledWith(true) + }) + }) + + it('calls setTaskDeadlineMutation when a new date is selected', async () => { + const setTaskDeadlineMutation = jest.fn() + const baseMocks = (useIssueMutations as jest.Mock)() + mockUseIssueMutations.mockReturnValue({ + ...baseMocks, + isEditingDeadline: true, // Mock that we are in editing mode + setTaskDeadlineMutation, + deadlineInput: '', // Ensure input is controlled and can be found + }) + mockUseQuery.mockReturnValue({ data: mockIssueData, loading: false, error: undefined }) + render() + + const dateInput = screen.getByDisplayValue('') + fireEvent.change(dateInput, { target: { value: '2025-12-25' } }) + + await waitFor(() => { + expect(setTaskDeadlineMutation).toHaveBeenCalled() + }) + }) + }) + + describe('issue states', () => { + it.each([ + { state: 'closed', isMerged: true, expectedText: 'Merged' }, + { state: 'closed', isMerged: false, expectedText: 'Closed' }, + { state: 'open', isMerged: false, expectedText: 'Open' }, + ])('renders issue state as "$expectedText"', ({ state, isMerged, expectedText }) => { + const issueWithState = { + ...mockIssueData, + getModule: { + ...mockIssueData.getModule, + issueByNumber: { + ...mockIssueData.getModule.issueByNumber, + state, + isMerged, + }, + }, + } + mockUseQuery.mockReturnValue({ data: issueWithState, loading: false, error: undefined }) + render() + // The issue status is the first badge of its kind. + expect(screen.getAllByText(expectedText)[0]).toBeInTheDocument() + }) + }) + + it('renders correctly with missing optional data', () => { + const dataWithMissingFields = { + getModule: { + ...mockIssueData.getModule, + taskAssignedAt: null, + issueByNumber: { + ...mockIssueData.getModule.issueByNumber, + body: null, + assignees: [{ id: 'a1', login: 'user1', name: null, avatarUrl: null }], + pullRequests: [ + { + id: 'pr1', + title: 'PR', + url: '', + state: 'open', + mergedAt: null, + createdAt: new Date().toISOString(), + author: null, + }, + ], + }, + interestedUsers: [{ id: 'u1', login: 'user2', avatarUrl: null }], + }, + } + mockUseQuery.mockReturnValue({ data: dataWithMissingFields, loading: false, error: undefined }) + render() + expect(screen.getByText('No description.')).toBeInTheDocument() + expect(screen.getByText('Not assigned')).toBeInTheDocument() + expect(screen.getByText(/by Unknown/)).toBeInTheDocument() + expect(screen.getByText('user1')).toBeInTheDocument() + expect(screen.getByText('@user2')).toBeInTheDocument() + }) + + it('calls clearTaskDeadlineMutation when the date input is cleared', async () => { + const clearTaskDeadlineMutation = jest.fn() + const baseMocks = (useIssueMutations as jest.Mock)() + mockUseIssueMutations.mockReturnValue({ + ...baseMocks, + isEditingDeadline: true, // Mock that we are in editing mode + clearTaskDeadlineMutation, + deadlineInput: '2025-12-25', // Mock an existing value + }) + mockUseQuery.mockReturnValue({ data: mockIssueData, loading: false, error: undefined }) + render() + + const dateInput = screen.getByDisplayValue('2025-12-25') + fireEvent.change(dateInput, { target: { value: '' } }) + + await waitFor(() => { + expect(clearTaskDeadlineMutation).toHaveBeenCalledWith({ + variables: { + programKey: 'prog1', + moduleKey: 'mod1', + issueNumber: 123, + }, + }) + }) + }) + + it('disables assign/unassign buttons when mutations are in progress', () => { + const baseMocks = (useIssueMutations as jest.Mock)() + mockUseIssueMutations.mockReturnValue({ + ...baseMocks, + assigning: true, + unassigning: true, + }) + mockUseQuery.mockReturnValue({ data: mockIssueData, loading: false, error: undefined }) + render() + + const interestedUsersHeading = screen.getByRole('heading', { name: /Interested Users/i }) + const userGrid = interestedUsersHeading.nextElementSibling + const assignButton = within(userGrid as HTMLElement).getByRole('button', { name: /Assign/i }) + expect(assignButton).toBeDisabled() + + const unassignButton = screen.getByRole('button', { name: /Unassign @user1/i }) + expect(unassignButton).toBeDisabled() + }) +}) diff --git a/frontend/__tests__/unit/pages/MyMentorship.test.tsx b/frontend/__tests__/unit/pages/MyMentorship.test.tsx index 024416f0ea..38da7d95bb 100644 --- a/frontend/__tests__/unit/pages/MyMentorship.test.tsx +++ b/frontend/__tests__/unit/pages/MyMentorship.test.tsx @@ -1,10 +1,15 @@ import { useQuery } from '@apollo/client/react' +import { addToast } from '@heroui/toast' import { screen, waitFor, fireEvent } from '@testing-library/react' import { useRouter as useRouterMock } from 'next/navigation' import { useSession as mockUseSession } from 'next-auth/react' import { render } from 'wrappers/testUtil' import MyMentorshipPage from 'app/my/mentorship/page' +jest.mock('@heroui/toast', () => ({ + addToast: jest.fn(), +})) + jest.mock('@apollo/client/react', () => { const actual = jest.requireActual('@apollo/client/react') return { @@ -35,6 +40,7 @@ jest.mock('hooks/useUpdateProgramStatus', () => ({ const mockUseQuery = useQuery as unknown as jest.Mock const mockPush = jest.fn() +const mockAddToast = addToast as jest.Mock beforeEach(() => { jest.clearAllMocks() @@ -171,4 +177,37 @@ describe('MyMentorshipPage', () => { expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/create') }) }) + + it('shows an error toast when GraphQL query fails', async () => { + ;(mockUseSession as jest.Mock).mockReturnValue({ + data: { + user: { + name: 'User', + email: 'user@example.com', + login: 'User', + isLeader: true, + }, + expires: '2099-01-01T00:00:00.000Z', + }, + status: 'authenticated', + }) + + mockUseQuery.mockReturnValue({ + data: undefined, + loading: false, + error: new Error('GraphQL error'), + }) + + render() + + await waitFor(() => { + expect(mockAddToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'GraphQL Error', + description: 'Failed to fetch your programs', + color: 'danger', + }) + ) + }) + }) }) diff --git a/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx b/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx index a8575915ef..da43bcf345 100644 --- a/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx +++ b/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx @@ -20,14 +20,11 @@ jest.mock('@heroui/toast', () => ({ addToast: jest.fn(), })) -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: () => , -})) - jest.mock('next/navigation', () => ({ ...jest.requireActual('next/navigation'), useRouter: jest.fn(() => mockRouter), useParams: () => ({ repositoryKey: 'test-org' }), + usePathname: jest.fn(() => '/organizations/test-org'), })) const mockError = { @@ -50,14 +47,15 @@ describe('OrganizationDetailsPage', () => { test('renders loading state', async () => { ;(useQuery as unknown as jest.Mock).mockReturnValue({ data: null, + loading: true, error: null, }) render() - const loadingSpinner = screen.getAllByAltText('Loading indicator') + // Use semantic role query instead of CSS selectors for better stability await waitFor(() => { - expect(loadingSpinner.length).toBeGreaterThan(0) + expect(screen.getByTestId('org-loading-skeleton')).toBeInTheDocument() }) }) @@ -70,7 +68,7 @@ describe('OrganizationDetailsPage', () => { render() await waitFor(() => { - expect(screen.getByText('Test Organization')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Test Organization' })).toBeInTheDocument() }) expect(screen.getByText('@test-org')).toBeInTheDocument() @@ -200,6 +198,7 @@ describe('OrganizationDetailsPage', () => { }) }) }) + test('does not render sponsor block', async () => { ;(useQuery as unknown as jest.Mock).mockReturnValue({ data: mockOrganizationDetailsData, diff --git a/frontend/__tests__/unit/pages/Program.test.tsx b/frontend/__tests__/unit/pages/Program.test.tsx index 68aef54469..f46b6107ab 100644 --- a/frontend/__tests__/unit/pages/Program.test.tsx +++ b/frontend/__tests__/unit/pages/Program.test.tsx @@ -61,7 +61,7 @@ describe('ProgramsPage Component', () => { }) expect(screen.getByText('This is a summary of Program 1.')).toBeInTheDocument() - expect(screen.getByText('View Details')).toBeInTheDocument() + // Card is now clickable, no separate "View Details" button }) test('shows empty message when no programs found', async () => { @@ -91,14 +91,14 @@ describe('ProgramsPage Component', () => { }) }) - test('navigates to program detail page on View Details click', async () => { + test('navigates to program detail page on card click', async () => { render() await waitFor(() => { - const viewButton = screen.getByText('View Details') - fireEvent.click(viewButton) + expect(screen.getByText('Program 1')).toBeInTheDocument() }) - expect(mockRouter.push).toHaveBeenCalledWith('/mentorship/programs/program_1') + const card = screen.getByRole('link', { name: /Program 1/i }) + expect(card).toHaveAttribute('href', '/mentorship/programs/program_1') }) }) diff --git a/frontend/__tests__/unit/pages/ProgramDetailsMentorship.test.tsx b/frontend/__tests__/unit/pages/ProgramDetailsMentorship.test.tsx index 9fb20d92e3..7c405b8d88 100644 --- a/frontend/__tests__/unit/pages/ProgramDetailsMentorship.test.tsx +++ b/frontend/__tests__/unit/pages/ProgramDetailsMentorship.test.tsx @@ -1,9 +1,11 @@ -import { useQuery } from '@apollo/client/react' -import { screen, waitFor } from '@testing-library/react' +import { useQuery, useMutation } from '@apollo/client/react' +import { screen, waitFor, fireEvent } from '@testing-library/react' import mockProgramDetailsData from '@unit/data/mockProgramData' +import { useSession } from 'next-auth/react' import { render } from 'wrappers/testUtil' import ProgramDetailsPage from 'app/my/mentorship/programs/[programKey]/page' import '@testing-library/jest-dom' +import { ProgramStatusEnum } from 'types/__generated__/graphql' jest.mock('@apollo/client/react', () => ({ ...jest.requireActual('@apollo/client/react'), @@ -18,6 +20,11 @@ jest.mock('next/navigation', () => ({ useSearchParams: () => new URLSearchParams(), })) +jest.mock('next-auth/react', () => ({ + ...jest.requireActual('next-auth/react'), + useSession: jest.fn(), +})) + describe('ProgramDetailsPage', () => { beforeEach(() => { ;(useQuery as unknown as jest.Mock).mockReturnValue({ @@ -25,6 +32,10 @@ describe('ProgramDetailsPage', () => { loading: false, refetch: jest.fn(), }) + ;(useSession as jest.Mock).mockReturnValue({ + data: { user: { login: 'test-user' } }, + status: 'authenticated', + }) }) afterEach(() => { @@ -36,6 +47,7 @@ describe('ProgramDetailsPage', () => { loading: true, data: null, }) + ;(useSession as jest.Mock).mockReturnValue({ data: null, status: 'loading' }) render() @@ -70,4 +82,70 @@ describe('ProgramDetailsPage', () => { expect(screen.getByText('Beginner, Intermediate')).toBeInTheDocument() }) }) + + test('renders program details correctly for a non-admin', async () => { + ;(useSession as jest.Mock).mockReturnValue({ + data: { user: { login: 'non-admin' } }, + status: 'authenticated', + }) + render() + await waitFor(() => { + expect(screen.getByText('Test Program')).toBeInTheDocument() + expect(screen.getByText('Sample summary')).toBeInTheDocument() + expect(screen.getByText('Draft')).toBeInTheDocument() + expect(screen.queryByTestId('program-actions-button')).not.toBeInTheDocument() + }) + }) + + test('renders N/A if experienceLevels is null', async () => { + const mockDataWithoutLevels = { + getProgram: { ...mockProgramDetailsData.getProgram, experienceLevels: null }, + } + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + loading: false, + data: mockDataWithoutLevels, + }) + render() + await waitFor(() => { + expect(screen.getByText('N/A')).toBeInTheDocument() + }) + }) + + describe('Admin Functionality', () => { + const mockUpdateProgram = jest.fn() + + beforeEach(() => { + ;(useSession as jest.Mock).mockReturnValue({ + data: { user: { login: 'admin-user' } }, // Matches admin in mock data + status: 'authenticated', + }) + ;(useMutation as unknown as jest.Mock).mockReturnValue([ + mockUpdateProgram, + { loading: false }, + ]) + }) + + test('successfully updates status from Draft to Published', async () => { + mockUpdateProgram.mockResolvedValue({}) + render() + + const actionsButton = await screen.findByTestId('program-actions-button') + fireEvent.click(actionsButton) + + const publishButton = await screen.findByRole('menuitem', { name: 'Publish' }) + fireEvent.click(publishButton) + + await waitFor(() => { + expect(mockUpdateProgram).toHaveBeenCalledWith({ + variables: { + inputData: { + key: 'test-program', + name: 'Test Program', + status: ProgramStatusEnum.Published, + }, + }, + }) + }) + }) + }) }) diff --git a/frontend/__tests__/unit/pages/ProjectDetails.test.tsx b/frontend/__tests__/unit/pages/ProjectDetails.test.tsx index e27d14a161..000c4238bb 100644 --- a/frontend/__tests__/unit/pages/ProjectDetails.test.tsx +++ b/frontend/__tests__/unit/pages/ProjectDetails.test.tsx @@ -14,10 +14,6 @@ jest.mock('@heroui/toast', () => ({ addToast: jest.fn(), })) -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: () => , -})) - jest.mock('react-apexcharts', () => { return { __esModule: true, diff --git a/frontend/__tests__/unit/pages/ProjectHealthDashboardMetricsDetails.test.tsx b/frontend/__tests__/unit/pages/ProjectHealthDashboardMetricsDetails.test.tsx index 63444cb2ce..0a7d7fd480 100644 --- a/frontend/__tests__/unit/pages/ProjectHealthDashboardMetricsDetails.test.tsx +++ b/frontend/__tests__/unit/pages/ProjectHealthDashboardMetricsDetails.test.tsx @@ -27,10 +27,6 @@ jest.mock('hooks/useDjangoSession', () => ({ }), })) -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: () => , -})) - const mockError = { error: new Error('GraphQL error'), } diff --git a/frontend/__tests__/unit/pages/Projects.test.tsx b/frontend/__tests__/unit/pages/Projects.test.tsx index 8f1f8597e2..245c249fa9 100644 --- a/frontend/__tests__/unit/pages/Projects.test.tsx +++ b/frontend/__tests__/unit/pages/Projects.test.tsx @@ -27,10 +27,6 @@ jest.mock('components/Pagination', () =>

)) ) -jest.mock('wrappers/FontAwesomeIconWrapper', () => ({ - __esModule: true, - default: () => , -})) jest.mock('@/components/MarkdownWrapper', () => { return ({ content, className }: { content: string; className?: string }) => ( diff --git a/frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx b/frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx index ba32623bf5..b4ec1e4a95 100644 --- a/frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx +++ b/frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx @@ -12,10 +12,6 @@ jest.mock('@heroui/toast', () => ({ addToast: jest.fn(), })) -jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: () => , -})) - const createDropDownMockItem = (item, onAction) => ( diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 844eb03bc8..5a0f390bd5 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -16,6 +16,50 @@ .dark ::selection { @apply bg-blue-500 text-white; } + + .module-form-container [data-slot='base'], + .program-form-container [data-slot='base'] { + max-width: 100% !important; + width: 100% !important; + } + .module-form-container [data-slot='helperWrapper'], + .program-form-container [data-slot='helperWrapper'] { + max-width: 100% !important; + width: 100% !important; + overflow: hidden !important; + } + .module-form-container [data-slot='errorMessage'], + .program-form-container [data-slot='errorMessage'] { + max-width: 100% !important; + word-wrap: break-word !important; + overflow-wrap: anywhere !important; + word-break: break-word !important; + width: 100% !important; + box-sizing: border-box !important; + } + + .module-config-grid, + .config-grid { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + @media (min-width: 768px) { + .module-config-grid, + .config-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + } + } + @media (min-width: 1024px) { + .module-config-grid, + .config-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)) !important; + } + } + .module-config-grid > *, + .config-grid > * { + min-width: 0 !important; + max-width: 100% !important; + overflow: hidden !important; + } } @layer base { @@ -32,8 +76,8 @@ --card-foreground: 0 0% 3.9%; --popover: 0 0% 100%; --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; - --primary-foreground: 0 0% 98%; + --primary: 210 76% 48%; + --primary-foreground: 0 0% 100%; --secondary: 0 0% 96.1%; --secondary-foreground: 0 0% 9%; --muted: 0 0% 96.1%; @@ -65,8 +109,8 @@ --card-foreground: 0 0% 98%; --popover: 0 0% 3.9%; --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; + --primary: 210 76% 48%; + --primary-foreground: 0 0% 100%; --secondary: 0 0% 14.9%; --secondary-foreground: 0 0% 98%; --muted: 0 0% 14.9%; @@ -131,6 +175,28 @@ color: #1d7bd7; } + a .md-wrapper { + color: rgb(75 85 99); /* gray-600 */ + } + + .dark a .md-wrapper { + color: rgb(156 163 175); /* gray-400 */ + } + + a .md-wrapper a { + color: #1d7bd7; + } + + .dark a .md-wrapper a { + color: #1d7bd7; + } + + a .md-wrapper ul, + a .md-wrapper ol, + a .md-wrapper li { + color: inherit; + } + .navlink { color: inherit; } @@ -253,6 +319,24 @@ transition: transform 1s ease; } + .md-wrapper ul, + .md-wrapper ol { + margin: 0 0 1em 1.5em; + padding-left: 1.5em; + } + + .md-wrapper ul { + list-style-type: disc; + } + + .md-wrapper ol { + list-style-type: decimal; + } + + .md-wrapper li { + margin: 0.25em 0; + } + .flip-container:hover .icon-flip { transform: rotateY(180deg); } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 62b643fa3f..82c8c40abc 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,4 +1,5 @@ import { GoogleAnalytics } from '@next/third-parties/google' +import { BreadcrumbRoot } from 'contexts/BreadcrumbContext' import type { Metadata } from 'next' import { Geist, Geist_Mono } from 'next/font/google' import React from 'react' @@ -6,7 +7,7 @@ import { Providers } from 'wrappers/provider' import { GTM_ID } from 'utils/env.client' import { IS_GITHUB_AUTH_ENABLED } from 'utils/env.server' import AutoScrollToTop from 'components/AutoScrollToTop' -import BreadCrumbs from 'components/BreadCrumbs' +import BreadCrumbsWrapper from 'components/BreadCrumbsWrapper' import Footer from 'components/Footer' import Header from 'components/Header' import ScrollToTop from 'components/ScrollToTop' @@ -28,9 +29,9 @@ const geistMono = Geist_Mono({ export const metadata: Metadata = { description: 'Your gateway to OWASP. Discover, engage, and help shape the future!', icons: { - apple: 'https://owasp.org/www--site-theme/favicon.ico', - icon: 'https://owasp.org/www--site-theme/favicon.ico', - shortcut: 'https://owasp.org/www--site-theme/favicon.ico', + apple: '/img/favicon.ico', + icon: '/img/favicon.ico', + shortcut: '/img/favicon.ico', }, openGraph: { description: 'Your gateway to OWASP. Discover, engage, and help shape the future!', @@ -38,7 +39,7 @@ export const metadata: Metadata = { { alt: 'OWASP logo', height: 630, - url: 'https://nest.owasp.org/img/owasp_icon_white_background.png', + url: 'https://nest.owasp.org/img/nest_1200x630_light.png', width: 1200, }, ], @@ -53,7 +54,7 @@ export const metadata: Metadata = { card: 'summary_large_image', creator: '@owasp', description: 'Your gateway to OWASP. Discover, engage, and help shape the future!', - images: ['https://nest.owasp.org/img/owasp_icon_white_background.png'], + images: ['https://nest.owasp.org/img/nest_1200x630_light.png'], site: '@owasp', title: 'Home – OWASP Nest', }, @@ -71,14 +72,16 @@ export default function RootLayout({ style={{ minHeight: '100vh' }} > -
- -
- -
{children}
-
- -
+ +
+ +
+ +
{children}
+
+ +
+
diff --git a/frontend/src/app/members/[memberKey]/layout.tsx b/frontend/src/app/members/[memberKey]/layout.tsx index d65eb3d121..f71eb14531 100644 --- a/frontend/src/app/members/[memberKey]/layout.tsx +++ b/frontend/src/app/members/[memberKey]/layout.tsx @@ -7,6 +7,7 @@ import { } from 'types/__generated__/userQueries.generated' import { generateSeoMetadata } from 'utils/metaconfig' import { generateProfilePageStructuredData } from 'utils/structuredData' +import PageLayout from 'components/PageLayout' import StructuredDataScript from 'components/StructuredDataScript' export async function generateMetadata({ @@ -55,9 +56,9 @@ export default async function UserDetailsLayout({ } return ( - <> + {children} - + ) } diff --git a/frontend/src/app/members/[memberKey]/page.tsx b/frontend/src/app/members/[memberKey]/page.tsx index e4a56e148d..bc0b61be66 100644 --- a/frontend/src/app/members/[memberKey]/page.tsx +++ b/frontend/src/app/members/[memberKey]/page.tsx @@ -1,16 +1,11 @@ 'use client' import { useQuery } from '@apollo/client/react' -import { - faCodeMerge, - faFolderOpen, - faPersonWalkingArrowRight, - faUserPlus, -} from '@fortawesome/free-solid-svg-icons' import Image from 'next/image' import Link from 'next/link' import { useParams } from 'next/navigation' import { useTheme } from 'next-themes' import React, { useState, useEffect, useRef } from 'react' +import { FaCodeMerge, FaFolderOpen, FaPersonWalkingArrowRight, FaUserPlus } from 'react-icons/fa6' import { handleAppError, ErrorDisplay } from 'app/global-error' import { GetUserDataDocument } from 'types/__generated__/userQueries.generated' @@ -25,7 +20,7 @@ import { formatDate } from 'utils/dateFormatter' import { drawContributions, fetchHeatmapData, HeatmapData } from 'utils/helpers/githubHeatmap' import Badges from 'components/Badges' import DetailsCard from 'components/CardDetailsPage' -import LoadingSpinner from 'components/LoadingSpinner' +import MemberDetailsPageSkeleton from 'components/skeletons/MemberDetailsPageSkeleton' const UserDetailsPage: React.FC = () => { const { memberKey } = useParams<{ memberKey: string }>() @@ -76,7 +71,6 @@ const UserDetailsPage: React.FC = () => { }, [memberKey, user]) const formattedBio = user?.bio?.split(' ').map((word, index) => { - // Regex to match GitHub usernames, but if last character is not a word character or @, it's a punctuation const mentionMatch = word.match(/^@([\w-]+(?:\.[\w-]+)*)([^\w@])?$/) if (mentionMatch && mentionMatch.length > 1) { const username = mentionMatch[1] @@ -100,7 +94,11 @@ const UserDetailsPage: React.FC = () => { }) if (isLoading) { - return + return ( +
+ +
+ ) } if (!isLoading && user == null) { @@ -121,15 +119,15 @@ const UserDetailsPage: React.FC = () => { ] const userStats = [ - { icon: faPersonWalkingArrowRight, value: user?.followersCount || 0, unit: 'Follower' }, - { icon: faUserPlus, value: user?.followingCount || 0, unit: 'Following' }, + { icon: FaPersonWalkingArrowRight, value: user?.followersCount || 0, unit: 'Follower' }, + { icon: FaUserPlus, value: user?.followingCount || 0, unit: 'Following' }, { - icon: faFolderOpen, + icon: FaFolderOpen, pluralizedName: 'Repositories', unit: 'Repository', value: user?.publicRepositoriesCount ?? 0, }, - { icon: faCodeMerge, value: user?.contributionsCount || 0, unit: 'Contribution' }, + { icon: FaCodeMerge, value: user?.contributionsCount || 0, unit: 'Contribution' }, ] const Heatmap = () => { @@ -211,7 +209,7 @@ const UserDetailsPage: React.FC = () => { diff --git a/frontend/src/app/members/page.tsx b/frontend/src/app/members/page.tsx index 88ca788835..5c40f5b7a7 100644 --- a/frontend/src/app/members/page.tsx +++ b/frontend/src/app/members/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useSearchPage } from 'hooks/useSearchPage' import { useRouter } from 'next/navigation' -import FontAwesomeIconWrapper from 'wrappers/FontAwesomeIconWrapper' +import { FaRightToBracket } from 'react-icons/fa6' import type { User } from 'types/user' import SearchPageLayout from 'components/SearchPageLayout' import UserCard from 'components/UserCard' @@ -29,7 +29,7 @@ const UsersPage = () => { const renderUserCard = (user: User) => { const submitButton = { label: 'View Details', - icon: , + icon: , onclick: () => handleButtonClick(user), } @@ -65,7 +65,9 @@ const UsersPage = () => { totalPages={totalPages} >
- {users && users.map((user) =>
{renderUserCard(user)}
)} + {users?.map((user) => ( +
{renderUserCard(user)}
+ ))}
) diff --git a/frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx b/frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx index c986ddf5ce..c6a7ad7b00 100644 --- a/frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx +++ b/frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useQuery } from '@apollo/client/react' -import upperFirst from 'lodash/upperFirst' +import capitalize from 'lodash/capitalize' import { useParams } from 'next/navigation' import { useEffect, useState } from 'react' import { ErrorDisplay, handleAppError } from 'app/global-error' @@ -49,7 +49,7 @@ const ModuleDetailsPage = () => { } const moduleDetails = [ - { label: 'Experience Level', value: upperFirst(module.experienceLevel) }, + { label: 'Experience Level', value: capitalize(module.experienceLevel) }, { label: 'Start Date', value: formatDate(module.startedAt) }, { label: 'End Date', value: formatDate(module.endedAt) }, { @@ -63,7 +63,6 @@ const ModuleDetailsPage = () => { admins={admins} details={moduleDetails} domains={module.domains} - labels={module.labels} mentors={module.mentors} summary={module.description} tags={module.tags} diff --git a/frontend/src/app/mentorship/programs/[programKey]/page.tsx b/frontend/src/app/mentorship/programs/[programKey]/page.tsx index a80f54e179..23f25d0451 100644 --- a/frontend/src/app/mentorship/programs/[programKey]/page.tsx +++ b/frontend/src/app/mentorship/programs/[programKey]/page.tsx @@ -13,7 +13,7 @@ import DetailsCard from 'components/CardDetailsPage' import LoadingSpinner from 'components/LoadingSpinner' const ProgramDetailsPage = () => { - const { programKey } = useParams() as { programKey: string } + const { programKey } = useParams<{ programKey: string }>() const searchParams = useSearchParams() const router = useRouter() const shouldRefresh = searchParams.get('refresh') === 'true' diff --git a/frontend/src/app/mentorship/programs/page.tsx b/frontend/src/app/mentorship/programs/page.tsx index d02205b9b3..56ac5c4cd5 100644 --- a/frontend/src/app/mentorship/programs/page.tsx +++ b/frontend/src/app/mentorship/programs/page.tsx @@ -1,7 +1,6 @@ 'use client' import { useSearchPage } from 'hooks/useSearchPage' -import { useRouter } from 'next/navigation' import { ProgramStatusEnum } from 'types/__generated__/graphql' import { Program } from 'types/mentorship' import ProgramCard from 'components/ProgramCard' @@ -22,19 +21,13 @@ const ProgramsPage = () => { hitsPerPage: 24, }) - const router = useRouter() - const renderProgramCard = (program: Program) => { - const handleButtonClick = () => { - router.push(`/mentorship/programs/${program.key}`) - } - return ( ) @@ -54,7 +47,9 @@ const ProgramsPage = () => { >
{programs && - programs.filter((p) => p.status === ProgramStatusEnum.Published).map(renderProgramCard)} + programs + .filter((p) => p.status?.toUpperCase() === ProgramStatusEnum.Published) + .map(renderProgramCard)}
) diff --git a/frontend/src/app/my/mentorship/page.tsx b/frontend/src/app/my/mentorship/page.tsx index 8a93993fa7..f622a68a66 100644 --- a/frontend/src/app/my/mentorship/page.tsx +++ b/frontend/src/app/my/mentorship/page.tsx @@ -1,13 +1,12 @@ 'use client' import { useQuery } from '@apollo/client/react' -import { faPlus, faGraduationCap } from '@fortawesome/free-solid-svg-icons' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { addToast } from '@heroui/toast' import { debounce } from 'lodash' import { useRouter, useSearchParams } from 'next/navigation' import { useSession } from 'next-auth/react' import React, { useEffect, useMemo, useState } from 'react' +import { FaPlus, FaGraduationCap } from 'react-icons/fa6' import { GetMyProgramsDocument } from 'types/__generated__/programsQueries.generated' import type { ExtendedSession } from 'types/auth' @@ -81,7 +80,6 @@ const MyMentorshipPage: React.FC = () => { }, [error]) const handleCreate = () => router.push('/my/mentorship/programs/create') - const handleView = (key: string) => router.push(`/my/mentorship/programs/${key}`) if (!username) { return @@ -90,7 +88,7 @@ const MyMentorshipPage: React.FC = () => { if (!isProjectLeader) { return (
- +

Access Denied

Only project leaders can access this page. @@ -107,7 +105,7 @@ const MyMentorshipPage: React.FC = () => {

Programs you've created or joined

- + {'Create Program'}
@@ -139,7 +137,7 @@ const MyMentorshipPage: React.FC = () => { accessLevel="admin" isAdmin={p?.userRole === 'admin'} key={p.id} - onView={handleView} + href={`/my/mentorship/programs/${p.key}`} program={p} /> )) diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/edit/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/edit/page.tsx index d4d0b8bc0b..dde49d9f1a 100644 --- a/frontend/src/app/my/mentorship/programs/[programKey]/edit/page.tsx +++ b/frontend/src/app/my/mentorship/programs/[programKey]/edit/page.tsx @@ -1,4 +1,5 @@ 'use client' + import { useMutation, useQuery } from '@apollo/client/react' import { addToast } from '@heroui/toast' import { useRouter, useParams } from 'next/navigation' @@ -12,12 +13,11 @@ import { GetProgramDetailsDocument } from 'types/__generated__/programsQueries.g import type { ExtendedSession } from 'types/auth' import { formatDateForInput } from 'utils/dateFormatter' import { parseCommaSeparated } from 'utils/parser' -import slugify from 'utils/slugify' import LoadingSpinner from 'components/LoadingSpinner' import ProgramForm from 'components/ProgramForm' const EditProgramPage = () => { const router = useRouter() - const { programKey } = useParams() as { programKey: string } + const { programKey } = useParams<{ programKey: string }>() const { data: session, status: sessionStatus } = useSession() const [updateProgram, { loading: mutationLoading }] = useMutation(UpdateProgramDocument) const { @@ -32,7 +32,7 @@ const EditProgramPage = () => { const [formData, setFormData] = useState({ name: '', description: '', - menteesLimit: 5, + menteesLimit: 0, startedAt: '', endedAt: '', tags: '', @@ -74,7 +74,7 @@ const EditProgramPage = () => { setFormData({ name: program.name || '', description: program.description || '', - menteesLimit: program.menteesLimit ?? 5, + menteesLimit: program.menteesLimit ?? 0, startedAt: formatDateForInput(program.startedAt), endedAt: formatDateForInput(program.endedAt), tags: (program.tags || []).join(', '), @@ -104,7 +104,8 @@ const EditProgramPage = () => { status: formData.status, } - await updateProgram({ variables: { input } }) + const result = await updateProgram({ variables: { input } }) + const updatedProgramKey = result.data?.updateProgram?.key || programKey addToast({ title: 'Program Updated', @@ -114,7 +115,7 @@ const EditProgramPage = () => { timeout: 3000, }) - router.push(`/my/mentorship/programs/${slugify(formData.name)}`) + router.push(`/my/mentorship/programs/${updatedProgramKey}`) } catch (err) { addToast({ title: 'Update Failed', @@ -147,6 +148,7 @@ const EditProgramPage = () => { title="Edit Program" submitText="Save" isEdit={true} + currentProgramKey={programKey} /> ) } diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx index 18d30c86c3..cd9c85450f 100644 --- a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx @@ -1,4 +1,5 @@ 'use client' + import { useMutation, useQuery } from '@apollo/client/react' import { addToast } from '@heroui/toast' import { useParams, useRouter } from 'next/navigation' @@ -16,7 +17,7 @@ import LoadingSpinner from 'components/LoadingSpinner' import ModuleForm from 'components/ModuleForm' const EditModulePage = () => { - const { programKey, moduleKey } = useParams() as { programKey: string; moduleKey: string } + const { programKey, moduleKey } = useParams<{ programKey: string; moduleKey: string }>() const router = useRouter() const { data: sessionData, status: sessionStatus } = useSession() @@ -110,7 +111,8 @@ const EditModulePage = () => { tags: parseCommaSeparated(formData.tags), } - await updateModule({ variables: { input } }) + const result = await updateModule({ variables: { input } }) + const updatedModuleKey = result.data?.updateModule?.key || moduleKey addToast({ title: 'Module Updated', @@ -119,7 +121,7 @@ const EditModulePage = () => { variant: 'solid', timeout: 3000, }) - router.push(`/my/mentorship/programs/${programKey}`) + router.push(`/my/mentorship/programs/${programKey}/modules/${updatedModuleKey}`) } catch (err) { handleAppError(err) } diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/[issueId]/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/[issueId]/page.tsx index cb406b4704..666cd9a937 100644 --- a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/[issueId]/page.tsx +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/[issueId]/page.tsx @@ -1,19 +1,12 @@ 'use client' import { useQuery } from '@apollo/client/react' -import { - faCodeBranch, - faLink, - faPlus, - faTags, - faUsers, - faXmark, -} from '@fortawesome/free-solid-svg-icons' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { useIssueMutations } from 'hooks/useIssueMutations' import Image from 'next/image' import Link from 'next/link' import { useParams } from 'next/navigation' +import { FaCodeBranch, FaLink, FaPlus, FaTags, FaXmark } from 'react-icons/fa6' +import { HiUserGroup } from 'react-icons/hi' import { ErrorDisplay } from 'app/global-error' import { GetModuleIssueViewDocument } from 'types/__generated__/issueQueries.generated' import ActionButton from 'components/ActionButton' @@ -24,7 +17,7 @@ import SecondaryCard from 'components/SecondaryCard' import { TruncatedText } from 'components/TruncatedText' const ModuleIssueDetailsPage = () => { - const params = useParams() as { programKey: string; moduleKey: string; issueId: string } + const params = useParams<{ programKey: string; moduleKey: string; issueId: string }>() const { programKey, moduleKey, issueId } = params const formatDeadline = (deadline: string | null) => { @@ -43,21 +36,29 @@ const ModuleIssueDetailsPage = () => { const isOverdue = deadlineUTC < todayUTC const daysLeft = Math.ceil((deadlineUTC.getTime() - todayUTC.getTime()) / (1000 * 60 * 60 * 24)) - const statusText = isOverdue - ? '(overdue)' - : daysLeft === 0 - ? '(today)' - : `(${daysLeft} days left)` + let statusText: string + if (isOverdue) { + statusText = '(overdue)' + } else if (daysLeft === 0) { + statusText = '(today)' + } else { + statusText = `(${daysLeft} days left)` + } const displayDate = deadlineDate.toLocaleDateString() + let color: string + if (isOverdue) { + color = 'text-[#DA3633]' + } else if (daysLeft <= 3) { + color = 'text-[#F59E0B]' + } else { + color = 'text-gray-600 dark:text-gray-300' + } + return { text: `${displayDate} ${statusText}`, - color: isOverdue - ? 'text-[#DA3633]' - : daysLeft <= 3 - ? 'text-[#F59E0B]' - : 'text-gray-600 dark:text-gray-300', + color, } } const { data, loading, error } = useQuery(GetModuleIssueViewDocument, { @@ -108,6 +109,47 @@ const ModuleIssueDetailsPage = () => { const remainingLabels = labels.length - visibleLabels.length const canEditDeadline = assignees.length > 0 + let issueStatusClass: string + let issueStatusLabel: string + if (issue.state === 'open') { + issueStatusClass = 'bg-[#238636] text-white' + issueStatusLabel = 'Open' + } else if (issue.isMerged) { + issueStatusClass = 'bg-[#8657E5] text-white' + issueStatusLabel = 'Merged' + } else { + issueStatusClass = 'bg-[#DA3633] text-white' + issueStatusLabel = 'Closed' + } + + const getPRStatus = (pr: Exclude[0]) => { + let backgroundColor: string + let label: string + if (pr.state === 'closed' && pr.mergedAt) { + backgroundColor = '#8657E5' + label = 'Merged' + } else if (pr.state === 'closed') { + backgroundColor = '#DA3633' + label = 'Closed' + } else { + backgroundColor = '#238636' + label = 'Open' + } + return { backgroundColor, label } + } + + const getAssignButtonTitle = (assigning: boolean) => { + let title: string + if (!issueId) { + title = 'Loading issue…' + } else if (assigning) { + title = 'Assigning…' + } else { + title = 'Assign to this user' + } + return title + } + return (
@@ -121,20 +163,14 @@ const ModuleIssueDetailsPage = () => { {issue.organizationName}/{issue.repositoryName} • #{issue.number} - {issue.state === 'open' ? 'Open' : issue.isMerged ? 'Merged' : 'Closed'} + {issueStatusLabel}
- View on GitHub + View on GitHub
@@ -232,7 +268,7 @@ const ModuleIssueDetailsPage = () => {

- +
Labels
@@ -254,7 +290,7 @@ const ModuleIssueDetailsPage = () => {

- +
Assignees
@@ -266,7 +302,7 @@ const ModuleIssueDetailsPage = () => { className="flex items-center justify-between gap-2 rounded-lg bg-gray-200 p-3 dark:bg-gray-700" > {a.avatarUrl ? ( @@ -300,7 +336,7 @@ const ModuleIssueDetailsPage = () => { className={getButtonClassName(!issueId || unassigning)} title={unassigning ? 'Unassigning…' : `Unassign @${a.login}`} > - +

))} @@ -308,7 +344,7 @@ const ModuleIssueDetailsPage = () => { )} - +
{issue.pullRequests?.length ? ( issue.pullRequests.map((pr) => ( @@ -347,28 +383,17 @@ const ModuleIssueDetailsPage = () => {
- {pr.state === 'closed' && pr.mergedAt ? ( - - Merged - - ) : pr.state === 'closed' ? ( - - Closed - - ) : ( - - Open - - )} + {(() => { + const { backgroundColor, label } = getPRStatus(pr) + return ( + + {label} + + ) + })()}
)) @@ -382,7 +407,7 @@ const ModuleIssueDetailsPage = () => {

- +
Interested Users
@@ -422,11 +447,9 @@ const ModuleIssueDetailsPage = () => { }) }} className={`${getButtonClassName(!issueId || assigning)} px-3 py-1`} - title={ - !issueId ? 'Loading issue…' : assigning ? 'Assigning…' : 'Assign to this user' - } + title={getAssignButtonTitle(assigning)} > - + Assign diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx index d24ea54c66..21934208c5 100644 --- a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx @@ -2,21 +2,19 @@ import { useQuery } from '@apollo/client/react' import { Select, SelectItem } from '@heroui/select' -import { Tooltip } from '@heroui/tooltip' -import Image from 'next/image' import { useParams, useRouter, useSearchParams } from 'next/navigation' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { ErrorDisplay, handleAppError } from 'app/global-error' import { GetModuleIssuesDocument } from 'types/__generated__/moduleQueries.generated' +import IssuesTable, { type IssueRow } from 'components/IssuesTable' import LoadingSpinner from 'components/LoadingSpinner' import Pagination from 'components/Pagination' const ITEMS_PER_PAGE = 20 const LABEL_ALL = 'all' -const MAX_VISIBLE_LABELS = 5 const IssuesPage = () => { - const { programKey, moduleKey } = useParams() as { programKey: string; moduleKey: string } + const { programKey, moduleKey } = useParams<{ programKey: string; moduleKey: string }>() const router = useRouter() const searchParams = useSearchParams() const [selectedLabel, setSelectedLabel] = useState(searchParams.get('label') || LABEL_ALL) @@ -39,17 +37,8 @@ const IssuesPage = () => { }, [error]) const moduleData = data?.getModule - type ModuleIssueRow = { - objectID: string - number: number - title: string - state: string - isMerged: boolean - labels: string[] - assignees: Array<{ avatarUrl: string; login: string; name: string }> - } - const moduleIssues: ModuleIssueRow[] = useMemo(() => { + const moduleIssues: IssueRow[] = useMemo(() => { return (moduleData?.issues || []).map((i) => ({ objectID: i.id, number: i.number, @@ -92,9 +81,14 @@ const IssuesPage = () => { setCurrentPage(page) } - const handleIssueClick = (issueNumber: number) => { - router.push(`/my/mentorship/programs/${programKey}/modules/${moduleKey}/issues/${issueNumber}`) - } + const handleIssueClick = useCallback( + (issueNumber: number) => { + router.push( + `/my/mentorship/programs/${programKey}/modules/${moduleKey}/issues/${issueNumber}` + ) + }, + [router, programKey, moduleKey] + ) if (loading) return if (!moduleData) @@ -137,210 +131,12 @@ const IssuesPage = () => { - {/* Desktop Table - unchanged */} -
- - - - - - - - - - - {moduleIssues.map((issue) => ( - - - - - - - ))} - {moduleIssues.length === 0 && ( - - - - )} - -
- Title - - Status - - Labels - - Assignee -
- 50 ? false : true} - > - - - -
- - {issue.state === 'open' ? 'Open' : issue.isMerged ? 'Merged' : 'Closed'} - -
-
-
- {(() => { - const labels = issue.labels || [] - const visible = labels.slice(0, MAX_VISIBLE_LABELS) - const remaining = labels.length - visible.length - return ( - <> - {visible.map((label) => ( - - {label} - - ))} - {remaining > 0 && ( - - +{remaining} more - - )} - - ) - })()} -
-
- {issue.assignees?.length ? ( -
-
- {issue.assignees[0].login} - - {issue.assignees[0].login || issue.assignees[0].name} - -
- {issue.assignees.length > 1 && ( -
- +{issue.assignees.length - 1} -
- )} -
- ) : null} -
- No issues found for the selected filter. -
-
- - {/* Mobile & Tablet Cards */} -
- {moduleIssues.map((issue) => ( -
-
- - - {issue.state === 'open' ? 'Open' : issue.isMerged ? 'Merged' : 'Closed'} - -
- - {issue.labels?.length > 0 && ( -
- {issue.labels.slice(0, 3).map((label) => ( - - {label} - - ))} - {issue.labels.length > 3 && ( - - +{issue.labels.length - 3} - - )} -
- )} - - {issue.assignees?.length > 0 && ( -
- {issue.assignees[0].login} - - {issue.assignees[0].login || issue.assignees[0].name} - {issue.assignees.length > 1 && ` +${issue.assignees.length - 1}`} - -
- )} -
- ))} - - {moduleIssues.length === 0 && ( -
-

- No issues found for the selected filter. -

-
- )} -
+ {/* Pagination Controls */} { - const { programKey, moduleKey, menteeHandle } = useParams() as { - programKey: string - moduleKey: string - menteeHandle: string - } - - const [menteeDetails, setMenteeDetails] = useState(null) - const [menteeIssues, setMenteeIssues] = useState([]) - - const [isLoading, setIsLoading] = useState(true) - const [statusFilter, setStatusFilter] = useState('all') - - const { data, error } = useQuery(GetModuleMenteeDetailsDocument, { - variables: { - programKey, - moduleKey, - menteeHandle, - }, - skip: !programKey || !moduleKey || !menteeHandle, - fetchPolicy: 'cache-and-network', - }) - - useEffect(() => { - if (data) { - setMenteeDetails(data.getMenteeDetails ?? null) - setMenteeIssues(data.getMenteeModuleIssues ?? []) - } - if (error) { - handleAppError(error) - } - if (data || error) { - setIsLoading(false) - } - }, [data, error]) - - if (isLoading) return - - if (!menteeDetails) { - return ( - - ) - } - - const openIssues = menteeIssues.filter((issue) => issue.state.toLowerCase() === 'open') - const closedIssues = menteeIssues.filter((issue) => issue.state.toLowerCase() === 'closed') - - const filteredIssues = - statusFilter === 'all' ? menteeIssues : statusFilter === 'open' ? openIssues : closedIssues - - return ( -
-
- {/* Header */} - -
- {`${menteeDetails.name -
-

- {menteeDetails.name || menteeDetails.login} -

-

@{menteeDetails.login}

- {menteeDetails.bio && ( -

{menteeDetails.bio}

- )} -
-
-
- - {/* Stats */} -
- -
-
- {menteeIssues.length} -
-
-
- - -
-
- {openIssues.length} -
-
-
- - -
-
- {closedIssues.length} -
-
-
-
- - {/* Mentee Information */} -
- -

- No completed levels data available yet -

-
- - -

- No penalties data available yet -

-
-
- - {/* Domains and Skills */} - {(menteeDetails.domains?.length > 0 || menteeDetails.tags?.length > 0) && ( -
- {menteeDetails.domains && menteeDetails.domains.length > 0 && ( - - - - )} - - {menteeDetails.tags && menteeDetails.tags.length > 0 && ( - - - - )} -
- )} - - {/* Issues - moved to the end */} - - {menteeIssues.length === 0 ? ( -

- No issues assigned to this mentee in this module -

- ) : ( -
- {/* Filter Dropdown */} -
- -
- -
- {filteredIssues.map((issue) => ( -
- - - {issue.labels && issue.labels.length > 0 && ( -
- -
- )} -
- ))} -
-
- )} -
-
-
- ) -} - -export default MenteeProfilePage diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/mentees/[menteeKey]/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/mentees/[menteeKey]/page.tsx new file mode 100644 index 0000000000..a111ab1e57 --- /dev/null +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/mentees/[menteeKey]/page.tsx @@ -0,0 +1,231 @@ +'use client' + +import { useQuery } from '@apollo/client/react' +import { Select, SelectItem } from '@heroui/select' +import Image from 'next/image' +import { useParams } from 'next/navigation' +import { useMemo, useEffect, useState } from 'react' +import { ErrorDisplay, handleAppError } from 'app/global-error' +import { + GetModuleMenteeDetailsDocument, + type GetModuleMenteeDetailsQuery, +} from 'types/__generated__/menteeQueries.generated' +import { MenteeDetails } from 'types/mentorship' +import IssuesTable, { type IssueRow } from 'components/IssuesTable' +import { LabelList } from 'components/LabelList' +import LoadingSpinner from 'components/LoadingSpinner' +import Pagination from 'components/Pagination' +import SecondaryCard from 'components/SecondaryCard' + +const ITEMS_PER_PAGE = 20 + +const MenteeProfilePage = () => { + const { programKey, moduleKey, menteeKey } = useParams<{ + programKey: string + moduleKey: string + menteeKey: string + }>() + + const [menteeDetails, setMenteeDetails] = useState(null) + const [menteeIssuesData, setMenteeIssuesData] = useState< + GetModuleMenteeDetailsQuery['getMenteeModuleIssues'] + >([]) + + const [isLoading, setIsLoading] = useState(true) + const [statusFilter, setStatusFilter] = useState('all') + const [currentPage, setCurrentPage] = useState(1) + + const { data, error } = useQuery(GetModuleMenteeDetailsDocument, { + variables: { + programKey, + moduleKey, + menteeKey, + }, + skip: !programKey || !moduleKey || !menteeKey, + fetchPolicy: 'cache-and-network', + }) + + useEffect(() => { + if (data) { + setMenteeDetails(data.getMenteeDetails ?? null) + setMenteeIssuesData(data.getMenteeModuleIssues ?? []) + } + if (error) { + handleAppError(error) + } + if (data || error) { + setIsLoading(false) + } + }, [data, error]) + + const menteeIssues: IssueRow[] = useMemo(() => { + return menteeIssuesData.map((issue) => ({ + objectID: issue.id, + number: issue.number, + title: issue.title, + state: issue.state, + isMerged: issue.isMerged, + labels: issue.labels || [], + assignees: issue.assignees || [], + url: issue.url, + })) + }, [menteeIssuesData]) + + if (isLoading) return + + if (!menteeDetails) { + return ( + + ) + } + + const openIssues = menteeIssues.filter((issue) => issue.state.toLowerCase() === 'open') + const closedIssues = menteeIssues.filter((issue) => issue.state.toLowerCase() === 'closed') + + const issueMap: Record = { + all: menteeIssues, + open: openIssues, + closed: closedIssues, + } + const filteredIssues = issueMap[statusFilter] || menteeIssues + + const totalPages = Math.ceil(filteredIssues.length / ITEMS_PER_PAGE) + const paginatedIssues = filteredIssues.slice( + (currentPage - 1) * ITEMS_PER_PAGE, + currentPage * ITEMS_PER_PAGE + ) + + const statusFilterOptions = [ + { key: 'all', label: 'All', count: menteeIssues.length }, + { key: 'open', label: 'Open', count: openIssues.length }, + { key: 'closed', label: 'Closed', count: closedIssues.length }, + ] + + const handleIssueClick = (issueNumber: number) => { + const issue = menteeIssues.find((i) => i.number === issueNumber) + if (issue?.url) { + window.open(issue.url, '_blank', 'noopener,noreferrer') + } + } + + return ( +
+
+ {/* Header */} + +
+ {`${menteeDetails.name +
+

+ {menteeDetails.name || menteeDetails.login} +

+

@{menteeDetails.login}

+ {menteeDetails.bio && ( +

{menteeDetails.bio}

+ )} +
+
+
+ + {/* Mentee Information */} +
+ +

+ No completed levels data available yet +

+
+ + +

+ No penalties data available yet +

+
+
+ + {/* Domains and Skills */} + {(menteeDetails.domains?.length > 0 || menteeDetails.tags?.length > 0) && ( +
+ {menteeDetails.domains && menteeDetails.domains.length > 0 && ( + + + + )} + + {menteeDetails.tags && menteeDetails.tags.length > 0 && ( + + + + )} +
+ )} + + {/* Issues */} + +
+
+

Assigned Issues

+
+
+ +
+
+
+ + + + {/* Pagination Controls */} + +
+
+
+
+ ) +} + +export default MenteeProfilePage diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx index 957aba1023..989c30deae 100644 --- a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx @@ -12,7 +12,7 @@ import LoadingSpinner from 'components/LoadingSpinner' import { getSimpleDuration } from 'components/ModuleCard' const ModuleDetailsPage = () => { - const { programKey, moduleKey } = useParams() as { programKey: string; moduleKey: string } + const { programKey, moduleKey } = useParams<{ programKey: string; moduleKey: string }>() const [module, setModule] = useState(null) const [admins, setAdmins] = useState(null) const [isLoading, setIsLoading] = useState(true) diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/create/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/create/page.tsx index 57b385b09e..a0666f8603 100644 --- a/frontend/src/app/my/mentorship/programs/[programKey]/modules/create/page.tsx +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/create/page.tsx @@ -18,7 +18,7 @@ import ModuleForm from 'components/ModuleForm' const CreateModulePage = () => { const router = useRouter() - const { programKey } = useParams() as { programKey: string } + const { programKey } = useParams<{ programKey: string }>() const { data: sessionData, status: sessionStatus } = useSession() const [createModule, { loading: mutationLoading }] = useMutation(CreateModuleDocument) diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/page.tsx index a605c27597..e25859a092 100644 --- a/frontend/src/app/my/mentorship/programs/[programKey]/page.tsx +++ b/frontend/src/app/my/mentorship/programs/[programKey]/page.tsx @@ -17,7 +17,7 @@ import DetailsCard from 'components/CardDetailsPage' import LoadingSpinner from 'components/LoadingSpinner' const ProgramDetailsPage = () => { - const { programKey } = useParams() as { programKey: string } + const { programKey } = useParams<{ programKey: string }>() const { data: session } = useSession() const username = (session as ExtendedSession)?.user?.login diff --git a/frontend/src/app/my/mentorship/programs/create/page.tsx b/frontend/src/app/my/mentorship/programs/create/page.tsx index d6a438d1d9..a9cc0a9dda 100644 --- a/frontend/src/app/my/mentorship/programs/create/page.tsx +++ b/frontend/src/app/my/mentorship/programs/create/page.tsx @@ -23,7 +23,7 @@ const CreateProgramPage = () => { const [formData, setFormData] = useState({ name: '', description: '', - menteesLimit: 5, + menteesLimit: 0, startedAt: '', endedAt: '', tags: '', diff --git a/frontend/src/app/organizations/[organizationKey]/layout.tsx b/frontend/src/app/organizations/[organizationKey]/layout.tsx index 3784ee5157..e35abb8273 100644 --- a/frontend/src/app/organizations/[organizationKey]/layout.tsx +++ b/frontend/src/app/organizations/[organizationKey]/layout.tsx @@ -7,6 +7,7 @@ import { GetOrganizationMetadataDocument, } from 'types/__generated__/organizationQueries.generated' import { generateSeoMetadata } from 'utils/metaconfig' +import PageLayout from 'components/PageLayout' export async function generateMetadata({ params, @@ -99,8 +100,15 @@ export default async function OrganizationDetailsLayout({ const { organizationKey } = await params const structuredData = await generateOrganizationStructuredData(organizationKey) + // Fetch organization name for breadcrumb + const { data } = await apolloClient.query({ + query: GetOrganizationMetadataDocument, + variables: { login: organizationKey }, + }) + const orgName = data?.organization?.name || data?.organization?.login || organizationKey + return ( - <> + {structuredData && (